From e13a12612c714b1c03b4f93c88db9952af53be31 Mon Sep 17 00:00:00 2001 From: Dmitry Beskov <43372966+besdar@users.noreply.github.com> Date: Tue, 26 May 2026 18:35:38 +0000 Subject: [PATCH] feat(git-client): enhance fetchSkillFiles to handle root paths for sparse-checkout --- src/lib/git-client.test.ts | 55 ++++++++++++++++++++++++++++++++++++++ src/lib/git-client.ts | 22 ++++++++++++--- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/lib/git-client.test.ts b/src/lib/git-client.test.ts index 6d48c2a45..166eae93f 100644 --- a/src/lib/git-client.test.ts +++ b/src/lib/git-client.test.ts @@ -312,6 +312,61 @@ describe("git-client", () => { expect(vi.mocked(logger.warn)).toHaveBeenCalledWith(expect.stringContaining("symlink")); }); + it.each(["", ".", "./"])( + "disables sparse-checkout when skillsPath is %j (repo root)", + async (skillsPath) => { + mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" }); + vi.mocked(createTempDirectory).mockResolvedValue("/tmp/test"); + vi.mocked(removeTempDirectory).mockResolvedValue(undefined); + vi.mocked(directoryExists).mockResolvedValue(true); + vi.mocked(listDirectoryFiles).mockResolvedValue([]); + + await fetchSkillFiles({ + url: "https://example.com/repo.git", + ref: "main", + skillsPath, + }); + + // Must call `sparse-checkout disable`, not `sparse-checkout set ...`. + const calls = mockExecFileAsync.mock.calls.map((c: any[]) => c[1] as string[]); + const disableCall = calls.find( + (args) => args?.includes("sparse-checkout") && args.includes("disable"), + ); + const setCall = calls.find( + (args) => args?.includes("sparse-checkout") && args.includes("set"), + ); + expect(disableCall).toBeDefined(); + expect(setCall).toBeUndefined(); + }, + ); + + it("uses the clone directory itself as the skills root when skillsPath is the repo root", async () => { + mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" }); + vi.mocked(createTempDirectory).mockResolvedValue("/tmp/test"); + vi.mocked(removeTempDirectory).mockResolvedValue(undefined); + const seenDirs: string[] = []; + vi.mocked(directoryExists).mockImplementation(async (p: string) => { + seenDirs.push(p); + // Only treat the clone root as a directory; descended children are + // files so the walk terminates quickly. + return p === "/tmp/test"; + }); + vi.mocked(listDirectoryFiles).mockResolvedValue(["root-file.md"]); + vi.mocked(getFileSize).mockResolvedValue(10); + vi.mocked(readFileContent).mockResolvedValue("content"); + + const files = await fetchSkillFiles({ + url: "https://example.com/repo.git", + ref: "main", + skillsPath: ".", + }); + + expect(files).toHaveLength(1); + expect(files[0]?.relativePath).toBe("root-file.md"); + // No `/.` style path should be probed. + expect(seenDirs).toContain("/tmp/test"); + }); + it("throws GitClientError at max directory depth", async () => { mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" }); vi.mocked(createTempDirectory).mockResolvedValue("/tmp/test"); diff --git a/src/lib/git-client.ts b/src/lib/git-client.ts index cc6c340d8..9b29d3261 100644 --- a/src/lib/git-client.ts +++ b/src/lib/git-client.ts @@ -146,6 +146,13 @@ export async function fetchSkillFiles(params: { } await checkGitAvailable(); const tmpDir = await createTempDirectory("rulesync-git-"); + // Treat empty/"." paths as the repository root. Cone-mode sparse-checkout + // with such patterns only restores the top-level files (it intentionally + // excludes any subdirectory), so we must check out the entire working tree + // instead. Otherwise repositories whose skills live directly at the root + // (e.g. `//SKILL.md` without a `skills/` container) + // would only yield root-level files like README.md. + const isRootPath = skillsPath === "" || skillsPath === "." || skillsPath === "./"; try { await execFileAsync( "git", @@ -163,11 +170,18 @@ export async function fetchSkillFiles(params: { ], { timeout: GIT_TIMEOUT_MS }, ); - await execFileAsync("git", ["-C", tmpDir, "sparse-checkout", "set", "--", skillsPath], { - timeout: GIT_TIMEOUT_MS, - }); + if (isRootPath) { + // Disable sparse-checkout and restore the full tree. + await execFileAsync("git", ["-C", tmpDir, "sparse-checkout", "disable"], { + timeout: GIT_TIMEOUT_MS, + }); + } else { + await execFileAsync("git", ["-C", tmpDir, "sparse-checkout", "set", "--", skillsPath], { + timeout: GIT_TIMEOUT_MS, + }); + } await execFileAsync("git", ["-C", tmpDir, "checkout"], { timeout: GIT_TIMEOUT_MS }); - const skillsDir = join(tmpDir, skillsPath); + const skillsDir = isRootPath ? tmpDir : join(tmpDir, skillsPath); if (!(await directoryExists(skillsDir))) return []; return await walkDirectory(skillsDir, skillsDir, 0, { totalFiles: 0, totalSize: 0 }, logger); } catch (error) {