Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/lib/git-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<tmpDir>/.` 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");
Expand Down
22 changes: 18 additions & 4 deletions src/lib/git-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. `<repo>/<skill-name>/SKILL.md` without a `skills/` container)
// would only yield root-level files like README.md.
const isRootPath = skillsPath === "" || skillsPath === "." || skillsPath === "./";
try {
await execFileAsync(
"git",
Expand All @@ -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) {
Expand Down
Loading