diff --git a/src/symlinks/manager.test.ts b/src/symlinks/manager.test.ts index 1de4d79..b802c3d 100644 --- a/src/symlinks/manager.test.ts +++ b/src/symlinks/manager.test.ts @@ -11,6 +11,7 @@ import { import { join } from "node:path"; import { tmpdir } from "node:os"; import { ensureSkillsSymlink, verifySymlinks } from "./manager.js"; +import { exec } from "../utils/exec.js"; describe("symlinks", () => { let dir: string; @@ -98,6 +99,52 @@ describe("symlinks", () => { const stat = await lstat(join(targetDir, "skills")); expect(stat.isSymbolicLink()).toBe(true); }); + + it("removes migrated files from git index", async () => { + // Initialize a git repo in the temp dir + await exec("git", ["init"], { cwd: dir }); + await exec("git", ["config", "user.email", "test@test.com"], { + cwd: dir, + }); + await exec("git", ["config", "user.name", "Test"], { cwd: dir }); + + // Create a real skills directory with a committed file + const targetDir = join(dir, ".claude"); + const realSkillsDir = join(targetDir, "skills"); + await mkdir(join(realSkillsDir, "my-skill"), { recursive: true }); + await writeFile( + join(realSkillsDir, "my-skill", "SKILL.md"), + "---\nname: test\n---\n", + ); + + await exec("git", ["add", "."], { cwd: dir }); + await exec("git", ["commit", "-m", "initial"], { cwd: dir }); + + // Verify file is tracked before migration + const { stdout: before } = await exec( + "git", + ["ls-files", ".claude/skills/"], + { cwd: dir }, + ); + expect(before.trim()).toContain("my-skill/SKILL.md"); + + // Run the symlink migration + const result = await ensureSkillsSymlink(agentsDir, targetDir); + expect(result.created).toBe(true); + expect(result.migrated).toContain("my-skill"); + + // Verify file is no longer in git index + const { stdout: after } = await exec( + "git", + ["ls-files", ".claude/skills/"], + { cwd: dir }, + ); + expect(after.trim()).toBe(""); + + // Verify the skill was moved to .agents/skills/ + const agentsEntries = await readdir(join(agentsDir, "skills")); + expect(agentsEntries).toContain("my-skill"); + }); }); describe("verifySymlinks", () => { diff --git a/src/symlinks/manager.ts b/src/symlinks/manager.ts index 799b649..47014da 100644 --- a/src/symlinks/manager.ts +++ b/src/symlinks/manager.ts @@ -1,5 +1,6 @@ import { symlink, readlink, unlink, mkdir, lstat, readdir } from "node:fs/promises"; import { join, relative } from "node:path"; +import { exec } from "../utils/exec.js"; export class SymlinkError extends Error { constructor(message: string) { @@ -48,6 +49,7 @@ export async function ensureSkillsSymlink( // Real directory - migrate contents then replace with symlink if (stat.isDirectory()) { const migrated = await migrateDirectory(skillsLink, skillsSource); + await removeFromGitIndex(targetDir, "skills"); await rmdir(skillsLink); await symlink(relativeTarget, skillsLink); return { created: true, migrated }; @@ -90,6 +92,21 @@ async function rmdir(dir: string): Promise { await rm(dir, { recursive: true }); } +/** + * Best-effort removal of tracked files from git's index. + * Prevents "beyond a symbolic link" errors when a tracked directory + * is replaced by a symlink. + */ +async function removeFromGitIndex(cwd: string, path: string): Promise { + try { + await exec("git", ["rm", "-r", "--cached", "--ignore-unmatch", path], { + cwd, + }); + } catch { + // Silently ignore: not a git repo, git not installed, etc. + } +} + /** * Verify all configured symlinks are correct. * Returns a list of issues found.