From e2c122d64cbbb4e4ba47f1384a936d4d944f00f9 Mon Sep 17 00:00:00 2001 From: ArgonarioD Date: Mon, 20 Jul 2026 16:19:59 +0800 Subject: [PATCH] fix(cli): make .agents/ skill mirror best-effort + exclude from dirty check Address review findings on PR #2488: - skill-gen.ts: wrap mirror-root mkdir and per-skill mirror writes in try/catch + warn, so a mirror failure (e.g. .agents/skills is a file) no longer aborts canonical community-skill generation or destroys prior output. Mirroring is now a weak side-flow, matching ai-context.ts. - git.ts: exclude .agents/ + .agents/** from isWorkingTreeDirty so a tracked .agents/ dir doesn't permanently defeat the up-to-date fast path. - README + --skip-skills help (en/zh): note skills also mirror to .agents/skills/ when .agents/ exists, and --skip-skills skips both. Tests: +18 covering mirror failure paths (root-is-file, per-skill fail, delete-then-rewrite ordering, namespace-scoped cleanup), dirty-check excludes (real-edit regression, prefix collision, subdir .agents/, non-git/git-missing conservative fallback), gate on file-not-dir, and idempotency. Co-Authored-By: Claude --- README.md | 8 +- gitnexus/src/cli/i18n/en.ts | 2 +- gitnexus/src/cli/i18n/zh-CN.ts | 2 +- gitnexus/src/cli/index.ts | 2 +- gitnexus/src/cli/skill-gen.ts | 28 +++- gitnexus/src/storage/git.ts | 13 +- gitnexus/test/unit/ai-context.test.ts | 118 ++++++++++++++++ gitnexus/test/unit/git-utils.test.ts | 183 ++++++++++++++++++++++++ gitnexus/test/unit/skill-gen.test.ts | 195 ++++++++++++++++++++++++++ 9 files changed, 535 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ea40b33c27..a67fe21f40 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ flowchart TB | `detect_impact` | Pre-commit change analysis — scope, affected processes, risk level | | `generate_map` | Architecture documentation from the knowledge graph with mermaid diagrams | -### Agent skills installed to `.claude/skills/` automatically +### Agent skills installed to `.claude/skills/` and `.agents/skills/` (if `.agents/` exists) automatically - **Exploring** — navigate unfamiliar code using the knowledge graph - **Debugging** — trace bugs through call chains @@ -198,6 +198,8 @@ flowchart TB **Repo-specific skills** — run `gitnexus analyze --skills` and GitNexus detects the functional areas of your codebase (via Leiden community detection) and generates each one as a direct project skill under `.claude/skills/gitnexus-area-/`. Each skill describes a module's key files, entry points, execution flows, and cross-area connections, and is regenerated on each `--skills` run to stay current. +When a repo contains an `.agents/` directory, the standard and generated skills are also mirrored to `.agents/skills/` (e.g. `.agents/skills/gitnexus-cli/`, `.agents/skills/gitnexus-area-/`) so agents that read repo-local `.agents/skills/` (like Codex) stay in sync. + ## Editor Setup `gitnexus setup` auto-detects your editors and writes the correct global MCP config. Run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list, e.g. `gitnexus setup -c cursor,codex`. @@ -395,7 +397,7 @@ gitnexus analyze --skills # Generate repo-specific skill files from detec gitnexus analyze --skip-embeddings # Skip embedding generation (faster) gitnexus analyze --embeddings [limit] # Enable embedding generation (slower, better search) gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits -gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files +gitnexus analyze --skip-skills # Skip installing standard skill files under .claude/skills/ and .agents/skills/ gitnexus analyze --skip-git # Index folders that are not Git repositories gitnexus analyze --default-branch develop # Branch used in the generated regression-compare example (base_ref) gitnexus analyze --verbose # Log skipped files when parsers are unavailable @@ -451,7 +453,7 @@ Commit a `.gitnexusrc` JSON file at the repo root to preconfigure recurring `ana // over its fix on every analyze. (Alias: "branch".) "defaultBranch": "develop", "skipContextFiles": true, // alias of skipAgentsMd: keep your own AGENTS.md/CLAUDE.md - "skipSkills": true, // don't install standard .claude/skills/gitnexus-* skills + "skipSkills": true, // don't install standard skill files under .claude/skills/ and .agents/skills/ "embeddings": true, // generate embeddings by default "workerTimeout": 60, } diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 3262a10848..98f6de12bb 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -185,7 +185,7 @@ export const en = { 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md', 'help.option.analyze.noStats': 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md', 'help.option.analyze.skipSkills': - 'Skip installing standard GitNexus skill files directly under .claude/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.', + 'Skip installing standard GitNexus skill files under .claude/skills/ and .agents/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.', 'help.option.analyze.indexOnly': 'Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills)', 'help.option.skipGit': diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index b0010748ef..c3c4ccf76e 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -176,7 +176,7 @@ export const zhCN = { 'help.option.analyze.skipAgentsMd': '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块', 'help.option.analyze.noStats': '从 AGENTS.md 和 CLAUDE.md 中省略易变的文件/符号计数', 'help.option.analyze.skipSkills': - '跳过直接安装在 .claude/skills/ 下的标准 GitNexus skill 文件。不抑制 --skills 生成的社区 skill(位于 .claude/skills/gitnexus-area-*)。使用 --index-only 可跳过所有 AI 上下文文件注入。', + '跳过安装在 .claude/skills/ 和 .agents/skills/ 下的标准 GitNexus skill 文件。不抑制 --skills 生成的社区 skill(位于 .claude/skills/gitnexus-area-*)。使用 --index-only 可跳过所有 AI 上下文文件注入。', 'help.option.analyze.indexOnly': '纯索引模式:跳过所有文件注入(AGENTS.md、CLAUDE.md、skills)', 'help.option.skipGit': '将提供的路径/cwd 视为索引根目录,并跳过向上查找 git 根目录', 'help.option.analyze.name': diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 21de562030..c1e4c25bae 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -94,7 +94,7 @@ program .option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md') .option( '--skip-skills', - 'Skip installing standard GitNexus skill files directly under .claude/skills/. ' + + 'Skip installing standard GitNexus skill files under .claude/skills/ and .agents/skills/. ' + 'Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). ' + 'Use --index-only to skip all AI-context file injection.', ) diff --git a/gitnexus/src/cli/skill-gen.ts b/gitnexus/src/cli/skill-gen.ts index bbe18d9965..9e46b1e5ce 100644 --- a/gitnexus/src/cli/skill-gen.ts +++ b/gitnexus/src/cli/skill-gen.ts @@ -80,7 +80,7 @@ export const generateSkillFiles = async ( // exists, mirror the generated community skills there too so those agents // serve the up-to-date copies. const agentsOutputDir = path.join(repoPath, '.agents', 'skills'); - const mirrorToAgents = await shouldMirrorSkillsToAgents(repoPath); + let mirrorToAgents = await shouldMirrorSkillsToAgents(repoPath); // Community skills used to live under an undiscoverable `generated/` // grouping directory. Clear that GitNexus-owned legacy output and @@ -160,8 +160,19 @@ export const generateSkillFiles = async ( // Step 4: Ensure the shared project-skill root exists. Never clear it: it // also contains user-authored and standard GitNexus skills. await fs.mkdir(outputDir, { recursive: true }); + // The .agents/ mirror is a side flow: keep it a weak dependency. If the + // mirror root cannot be created (e.g. `.agents/skills` exists as a file), + // warn and disable mirroring for this run instead of aborting canonical + // community-skill generation. Canonical writes below stay unaffected. if (mirrorToAgents) { - await fs.mkdir(agentsOutputDir, { recursive: true }); + try { + await fs.mkdir(agentsOutputDir, { recursive: true }); + } catch (err) { + console.log( + `Warning: Could not create mirror root ${agentsOutputDir} — .agents/skills mirroring disabled for this run: ${err}`, + ); + mirrorToAgents = false; + } } // Step 5: Generate skill files @@ -214,11 +225,16 @@ export const generateSkillFiles = async ( await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8'); // Mirror to .agents/skills/ for agents that read repo-local skills - // (see mirrorToAgents above). + // (see mirrorToAgents above). Best-effort: a per-skill mirror failure + // must not abort canonical community-skill generation. if (mirrorToAgents) { - const agentsSkillDir = path.join(agentsOutputDir, skillName); - await fs.mkdir(agentsSkillDir, { recursive: true }); - await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), content, 'utf-8'); + try { + const agentsSkillDir = path.join(agentsOutputDir, skillName); + await fs.mkdir(agentsSkillDir, { recursive: true }); + await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), content, 'utf-8'); + } catch (err) { + console.log(`Warning: Could not mirror skill ${skillName} to .agents/skills: ${err}`); + } } const info: GeneratedSkillInfo = { diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index 7a30ba5056..4362fb5da8 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -9,10 +9,13 @@ const chompGitOutput = (value: Buffer): string => value.toString().replace(/\r?\ /** * True when the working tree has uncommitted changes that analyze would * re-index, even at a matching HEAD. Excludes the paths GitNexus writes during - * analyze (.gitnexus/, .claude/, .cursor/, AGENTS.md, CLAUDE.md) so its own - * output never counts as dirty (regression vs PR #1233 behavior). Conservative - * on any git failure. Shared so `analyze`'s fast-path gate and `status`'s - * freshness report agree on what "dirty" means. + * analyze (.gitnexus/, .claude/, .cursor/, AGENTS.md, CLAUDE.md, and the + * repo-local .agents/ mirror) so its own output never counts as dirty + * (regression vs PR #1233 behavior). The entire .agents/ tree is excluded, + * matching the .claude/ treatment, because the skill mirror writes across + * .agents/skills/ and deeper paths. Conservative on any git failure. Shared + * so `analyze`'s fast-path gate and `status`'s freshness report agree on what + * "dirty" means. */ export const isWorkingTreeDirty = (repoPath: string): boolean => { try { @@ -31,6 +34,8 @@ export const isWorkingTreeDirty = (repoPath: string): boolean => { ':(exclude).cursor/**', ':(exclude)AGENTS.md', ':(exclude)CLAUDE.md', + ':(exclude).agents', + ':(exclude).agents/**', ], { cwd: repoPath, diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index 9f2e978c66..cbe3bf4571 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -486,6 +486,124 @@ Old content here. } }); + it('keeps canonical skills intact when .agents/skills is a file (mirror mkdir fails)', async () => { + // MEDIUM 1 (standard-skill half): when .agents/skills exists as a regular + // file, the per-skill mirror mkdir fails. The failure must be warned per + // skill and canonical .claude/skills/ must still hold all 6 skills. + const { _captureLogger } = await import('../../src/core/logger.js'); + const cap = _captureLogger(); + const agentsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-agents-file-')); + const agentsStorage = path.join(agentsDir, '.gitnexus'); + await fs.mkdir(agentsStorage, { recursive: true }); + await fs.mkdir(path.join(agentsDir, '.agents'), { recursive: true }); + // .agents/skills is a file — per-skill mirror mkdir will EEXIST. + await fs.writeFile(path.join(agentsDir, '.agents', 'skills'), 'not a directory'); + try { + const stats = { nodes: 50, edges: 100, processes: 5 }; + const result = await generateAIContextFiles(agentsDir, agentsStorage, 'TestProject', stats); + + // Canonical 6 skills are all present. + expect(result.files).toContain('.claude/skills/gitnexus-*/ (6 skills)'); + for (const name of [ + 'gitnexus-exploring', + 'gitnexus-debugging', + 'gitnexus-impact-analysis', + 'gitnexus-refactoring', + 'gitnexus-guide', + 'gitnexus-cli', + ]) { + await expect( + fs.readFile(path.join(agentsDir, '.claude', 'skills', name, 'SKILL.md'), 'utf-8'), + ).resolves.toHaveProperty('length'); + } + // Mirror failures were warned, not thrown. + const warned = cap.records().some((r) => r.level === 40); // pino warn level + expect(warned).toBe(true); + } finally { + cap.restore(); + await fs.rm(agentsDir, { recursive: true, force: true }); + } + }); + + it('does not mirror and does not create .agents/ when .agents is a file (gate is false)', async () => { + // The gate checks isDirectory(); a file at .agents must NOT trigger + // mirroring and must not throw. + const fileDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-agents-filegate-')); + const fileStorage = path.join(fileDir, '.gitnexus'); + await fs.mkdir(fileStorage, { recursive: true }); + await fs.writeFile(path.join(fileDir, '.agents'), 'not a directory'); + try { + const stats = { nodes: 50, edges: 100, processes: 5 }; + const result = await generateAIContextFiles(fileDir, fileStorage, 'TestProject', stats); + + expect(result.files).toContain('.claude/skills/gitnexus-*/ (6 skills)'); + expect(result.files.some((f) => f.startsWith('.agents/skills/'))).toBe(false); + } finally { + await fs.rm(fileDir, { recursive: true, force: true }); + } + }); + + it('is idempotent across repeated runs (no duplicates, stable mirror content)', async () => { + const agentsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-idem-')); + const agentsStorage = path.join(agentsDir, '.gitnexus'); + await fs.mkdir(agentsStorage, { recursive: true }); + await fs.mkdir(path.join(agentsDir, '.agents'), { recursive: true }); + try { + const stats = { nodes: 50, edges: 100, processes: 5 }; + await generateAIContextFiles(agentsDir, agentsStorage, 'TestProject', stats); + const first = await fs.readFile( + path.join(agentsDir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'), + 'utf-8', + ); + + // Second run — must not duplicate or corrupt. + await generateAIContextFiles(agentsDir, agentsStorage, 'TestProject', stats); + const second = await fs.readFile( + path.join(agentsDir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'), + 'utf-8', + ); + expect(second).toBe(first); + + // Mirror tree has exactly one dir per standard skill (no duplicates). + const entries = await fs.readdir(path.join(agentsDir, '.agents', 'skills'), { + withFileTypes: true, + }); + const skillDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); + expect(skillDirs).toContain('gitnexus-cli'); + expect(skillDirs.filter((n) => n === 'gitnexus-cli')).toHaveLength(1); + } finally { + await fs.rm(agentsDir, { recursive: true, force: true }); + } + }); + + it('does not mirror standard skills when --skip-skills is set', async () => { + const skipDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-skip-mirror-')); + const skipStorage = path.join(skipDir, '.gitnexus'); + await fs.mkdir(skipStorage, { recursive: true }); + await fs.mkdir(path.join(skipDir, '.agents'), { recursive: true }); + try { + const stats = { nodes: 50, edges: 100, processes: 5 }; + const result = await generateAIContextFiles( + skipDir, + skipStorage, + 'TestProject', + stats, + undefined, + { + skipSkills: true, + }, + ); + + expect(result.files).toContain('.claude/skills/gitnexus-*/ (skipped via --skip-skills)'); + expect(result.files.some((f) => f.startsWith('.agents/skills/'))).toBe(false); + await expect( + fs.access(path.join(skipDir, '.agents', 'skills', 'gitnexus-cli')), + ).rejects.toThrow(); + } finally { + await fs.rm(skipDir, { recursive: true, force: true }); + } + }); + it('writes nothing when both skipAgentsMd and skipSkills are true (--index-only, #742)', async () => { // Regression guard for #742. analyzeCommand() resolves --index-only // into BOTH skipAgentsMd=true and skipSkills=true. This test pins diff --git a/gitnexus/test/unit/git-utils.test.ts b/gitnexus/test/unit/git-utils.test.ts index d04524d5c5..ae0277d9ba 100644 --- a/gitnexus/test/unit/git-utils.test.ts +++ b/gitnexus/test/unit/git-utils.test.ts @@ -341,3 +341,186 @@ describe('getCanonicalRepoRoot', () => { } }); }); + +// ─── isWorkingTreeDirty ─────────────────────────────────────────────────── +// +// analyze's fast-path gate. GitNexus writes to .gitnexus/, .claude/, .cursor/, +// AGENTS.md, CLAUDE.md, and the repo-local .agents/ skill mirror during a run; +// those writes must never count as "dirty" or the up-to-date fast path is +// defeated on every re-run. Real temporary git repos exercise the actual +// `git status --porcelain` pathspec exclude list. + +/** Create a fresh git repo in an isolated temp dir and return its path. */ +function makeIsolatedGitRepo(): string { + const dir = makeIsolatedTempDir('gn-dirty-'); + execSync('git init -q', { cwd: dir, stdio: 'ignore' }); + // Set a stable identity so commit doesn't fail on environments without + // global git config (CI containers, fresh sandboxes). + execSync('git config user.email t@t', { cwd: dir, stdio: 'ignore' }); + execSync('git config user.name t', { cwd: dir, stdio: 'ignore' }); + return dir; +} + +describe('isWorkingTreeDirty', () => { + it('returns false for a clean tree with only GitNexus-managed paths written', async () => { + const { isWorkingTreeDirty } = await import('../../src/storage/git.js'); + const repo = makeIsolatedGitRepo(); + try { + // Initial commit so the tree has a HEAD. + fs.writeFileSync(path.join(repo, 'README.md'), 'hi'); + execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' }); + + // Simulate GitNexus writing its managed outputs. + fs.mkdirSync(path.join(repo, '.gitnexus'), { recursive: true }); + fs.writeFileSync(path.join(repo, '.gitnexus', 'meta.json'), '{}'); + fs.mkdirSync(path.join(repo, '.claude', 'skills', 'gitnexus-cli'), { recursive: true }); + fs.writeFileSync(path.join(repo, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md'), 'x'); + fs.mkdirSync(path.join(repo, '.agents', 'skills', 'gitnexus-area-auth'), { + recursive: true, + }); + fs.writeFileSync(path.join(repo, '.agents', 'skills', 'gitnexus-area-auth', 'SKILL.md'), 'x'); + fs.writeFileSync(path.join(repo, 'AGENTS.md'), 'x'); + fs.writeFileSync(path.join(repo, 'CLAUDE.md'), 'x'); + + expect(isWorkingTreeDirty(repo)).toBe(false); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + it('returns true when a real source file changes (regression: excludes must not mask real edits)', async () => { + const { isWorkingTreeDirty } = await import('../../src/storage/git.js'); + const repo = makeIsolatedGitRepo(); + try { + fs.writeFileSync(path.join(repo, 'README.md'), 'hi'); + execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' }); + + // A real business-file edit alongside GitNexus writes. + fs.mkdirSync(path.join(repo, 'src'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'src', 'foo.ts'), 'export const x = 2;'); + fs.mkdirSync(path.join(repo, '.agents', 'skills', 'x'), { recursive: true }); + fs.writeFileSync(path.join(repo, '.agents', 'skills', 'x', 'SKILL.md'), 'x'); + + expect(isWorkingTreeDirty(repo)).toBe(true); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + it('treats the entire .agents/ tree as excluded (root file, nested skills, deep paths)', async () => { + const { isWorkingTreeDirty } = await import('../../src/storage/git.js'); + const repo = makeIsolatedGitRepo(); + try { + fs.writeFileSync(path.join(repo, 'README.md'), 'hi'); + execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' }); + + // Root-level file under .agents/. + fs.mkdirSync(path.join(repo, '.agents'), { recursive: true }); + fs.writeFileSync(path.join(repo, '.agents', 'foo.txt'), 'x'); + // Deep nested mirror path. + fs.mkdirSync(path.join(repo, '.agents', 'skills', 'gitnexus-area-auth'), { + recursive: true, + }); + fs.writeFileSync(path.join(repo, '.agents', 'skills', 'gitnexus-area-auth', 'SKILL.md'), 'x'); + + expect(isWorkingTreeDirty(repo)).toBe(false); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + it('does not error when .agents/ does not exist (no pathspec failure)', async () => { + const { isWorkingTreeDirty } = await import('../../src/storage/git.js'); + const repo = makeIsolatedGitRepo(); + try { + fs.writeFileSync(path.join(repo, 'README.md'), 'hi'); + execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' }); + + expect(isWorkingTreeDirty(repo)).toBe(false); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + it('does not error when .agents is a file rather than a directory', async () => { + const { isWorkingTreeDirty } = await import('../../src/storage/git.js'); + const repo = makeIsolatedGitRepo(); + try { + fs.writeFileSync(path.join(repo, 'README.md'), 'hi'); + execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' }); + + // .agents exists as a regular file (e.g. user created it by mistake). + fs.writeFileSync(path.join(repo, '.agents'), 'not a directory'); + + // Must not throw; the tree is otherwise clean so it is not dirty. + expect(isWorkingTreeDirty(repo)).toBe(false); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + it('does NOT exclude prefix-colliding names like .agentsrc or .claudefoo', async () => { + // pathspec `:(exclude).agents` must not swallow `.agentsrc` (no path + // separator). A change to such a colliding name still counts as dirty. + const { isWorkingTreeDirty } = await import('../../src/storage/git.js'); + const repo = makeIsolatedGitRepo(); + try { + fs.writeFileSync(path.join(repo, 'README.md'), 'hi'); + execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' }); + + fs.writeFileSync(path.join(repo, '.agentsrc'), 'x'); + fs.writeFileSync(path.join(repo, '.claudefoo'), 'x'); + + expect(isWorkingTreeDirty(repo)).toBe(true); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + it('does NOT exclude a nested .agents/ inside a subdirectory (root-relative pathspec)', async () => { + // `:(exclude).agents` is relative to the repo root; a subdirectory's + // .agents/ is unrelated and must still count as dirty. + const { isWorkingTreeDirty } = await import('../../src/storage/git.js'); + const repo = makeIsolatedGitRepo(); + try { + fs.writeFileSync(path.join(repo, 'README.md'), 'hi'); + execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' }); + + fs.mkdirSync(path.join(repo, 'subdir', '.agents'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'subdir', '.agents', 'x'), 'x'); + + expect(isWorkingTreeDirty(repo)).toBe(true); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + it('returns true (conservative) when called outside a git repository', async () => { + const { isWorkingTreeDirty } = await import('../../src/storage/git.js'); + const dir = makeIsolatedTempDir('gn-nongit-'); + try { + // No git init — git status fails, and the gate must fail closed (dirty). + expect(isWorkingTreeDirty(dir)).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('returns true (conservative) when git is not on PATH', async () => { + // PATH cleared so `git` cannot be found. The catch block must return true + // (fail closed) rather than silently treating the tree as clean — a clean + // false-positive would skip re-indexing of a genuinely-changed repo. + const { isWorkingTreeDirty } = await import('../../src/storage/git.js'); + const repo = makeIsolatedGitRepo(); + const savedPath = process.env.PATH; + try { + fs.writeFileSync(path.join(repo, 'README.md'), 'hi'); + execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' }); + process.env.PATH = ''; + expect(isWorkingTreeDirty(repo)).toBe(true); + } finally { + process.env.PATH = savedPath; + fs.rmSync(repo, { recursive: true, force: true }); + } + }); +}); diff --git a/gitnexus/test/unit/skill-gen.test.ts b/gitnexus/test/unit/skill-gen.test.ts index e4e39a2023..0ffb60ec06 100644 --- a/gitnexus/test/unit/skill-gen.test.ts +++ b/gitnexus/test/unit/skill-gen.test.ts @@ -708,6 +708,201 @@ describe('generateSkillFiles — file output', () => { await expect(fs.access(path.join(tmpDir, '.agents'))).rejects.toThrow(); }); + /** + * MEDIUM 1 (reviewer repro): when `.agents/skills` exists as a regular file, + * the mirror root mkdir fails. Mirroring must degrade gracefully (warn + + * disable) and the canonical community skills under .claude/skills/ must + * still be written in full — never deleted-then-not-rewritten. + */ + it('keeps canonical skills intact when .agents/skills is a file (mirror root mkdir fails)', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { graph, communities, memberships } = twoCommSetup(); + // .agents/ exists, but .agents/skills is a file — mkdir will EEXIST. + await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.agents', 'skills'), 'not a directory'); + + await generateSkillFiles( + tmpDir, + 'TestProject', + buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }), + ); + + // Canonical skills are fully present. + const claudeAlpha = await fs.readFile( + path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-alpha', 'SKILL.md'), + 'utf-8', + ); + const claudeBeta = await fs.readFile( + path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-beta', 'SKILL.md'), + 'utf-8', + ); + expect(claudeAlpha.length).toBeGreaterThan(0); + expect(claudeBeta.length).toBeGreaterThan(0); + // Mirror was disabled with a warning, not a thrown error. + expect(logSpy).toHaveBeenCalled(); + }); + + /** + * MEDIUM 1 per-skill: the mirror root is writable, but an individual skill's + * mirror write fails. The failure must be warned and contained — other + * communities' canonical AND mirror writes still succeed. + */ + it('isolates a per-skill mirror write failure to that skill (best-effort)', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { graph, communities, memberships } = twoCommSetup(); + await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true }); + + // Sabotage only the alpha mirror dir: make it a read-only file so the + // per-skill mkdir(agentsSkillDir) throws EEXIST (not a dir) and is caught. + await fs.mkdir(path.join(tmpDir, '.agents', 'skills'), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-alpha'), + 'file blocks dir', + ); + + await generateSkillFiles( + tmpDir, + 'TestProject', + buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }), + ); + + // Canonical for both communities is intact. + await expect( + fs.readFile( + path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-alpha', 'SKILL.md'), + 'utf-8', + ), + ).resolves.toHaveProperty('length'); + const claudeBeta = await fs.readFile( + path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-beta', 'SKILL.md'), + 'utf-8', + ); + expect(claudeBeta.length).toBeGreaterThan(0); + // Beta mirror still written (alpha failure did not abort the loop). + const agentsBeta = await fs.readFile( + path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-beta', 'SKILL.md'), + 'utf-8', + ); + expect(agentsBeta).toBe(claudeBeta); + expect(logSpy).toHaveBeenCalled(); + }); + + /** + * MEDIUM 1 delete-then-rewrite ordering: the canonical gitnexus-area-* + * cleanup runs before the mirror writes. A mirror failure after cleanup + * must not leave canonical missing — canonical is rewritten regardless. + */ + it('rewrites canonical skills after cleanup even when mirroring fails', async () => { + const { graph, communities, memberships } = twoCommSetup(); + await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true }); + + // First run: write canonical + mirror normally. + await generateSkillFiles( + tmpDir, + 'TestProject', + buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }), + ); + const firstAlpha = await fs.readFile( + path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-alpha', 'SKILL.md'), + 'utf-8', + ); + + // Second run with mirror broken: .agents/skills becomes a file. + await fs.rm(path.join(tmpDir, '.agents', 'skills'), { recursive: true, force: true }); + await fs.writeFile(path.join(tmpDir, '.agents', 'skills'), 'now a file'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + + await generateSkillFiles( + tmpDir, + 'TestProject', + buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }), + ); + + // Canonical alpha is still present and content is stable (cleanup deleted + // the old dir, then canonical rewrote it — not lost). + const secondAlpha = await fs.readFile( + path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-alpha', 'SKILL.md'), + 'utf-8', + ); + expect(secondAlpha).toBe(firstAlpha); + }); + + /** + * Mirror cleanup is namespace-scoped: only stale gitnexus-area-* mirror + * dirs are removed; mirrored standard skills and user-authored skills under + * .agents/skills/ survive a re-run. + */ + it('clears only stale gitnexus-area-* mirror dirs, preserving others', async () => { + const { graph, communities, memberships } = twoCommSetup(); + await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true }); + // Pre-existing non-community content that must survive. + await fs.mkdir(path.join(tmpDir, '.agents', 'skills', 'gitnexus-cli'), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'), + 'standard', + ); + await fs.mkdir(path.join(tmpDir, '.agents', 'skills', 'user-author'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.agents', 'skills', 'user-author', 'SKILL.md'), 'mine'); + // Stale community mirror from a prior run. + await fs.mkdir(path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-old'), { + recursive: true, + }); + await fs.writeFile( + path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-old', 'SKILL.md'), + 'stale', + ); + + await generateSkillFiles( + tmpDir, + 'TestProject', + buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }), + ); + + // Stale community mirror gone; non-community content preserved. + await expect( + fs.access(path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-old')), + ).rejects.toThrow(); + expect( + await fs.readFile( + path.join(tmpDir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'), + 'utf-8', + ), + ).toBe('standard'); + expect( + await fs.readFile(path.join(tmpDir, '.agents', 'skills', 'user-author', 'SKILL.md'), 'utf-8'), + ).toBe('mine'); + // Fresh community mirrors written. + await expect( + fs.access(path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-alpha', 'SKILL.md')), + ).resolves.toBeUndefined(); + }); + + /** + * Empty edge case: no significant communities + .agents/ present must not + * write or mirror anything, and must not throw. + */ + it('writes nothing when no communities are significant, even with .agents/ present', async () => { + await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true }); + const graph = createKnowledgeGraph(); + // 2-symbol community — below the 3-symbol threshold. + for (let i = 0; i < 2; i++) { + graph.addNode(makeNode(`fn:n${i}`, `n${i}`, 'Function', `${tmpDir}/f${i}.ts`, 1, false)); + } + const communities = [makeCommunity('c1', 'Tiny', 2)]; + const memberships = [makeMembership('fn:n0', 'c1'), makeMembership('fn:n1', 'c1')]; + + const result = await generateSkillFiles( + tmpDir, + 'TestProject', + buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }), + ); + + expect(result.skills).toEqual([]); + await expect( + fs.access(path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-tiny')), + ).rejects.toThrow(); + }); + /** * SKILL.md files should start with YAML frontmatter containing * name and description fields.