mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Merge pull request #2488 from ArgonarioD/main
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Devcontainer Smoke / Config-transform unit tests (push) Has been cancelled
Devcontainer Smoke / Build devcontainer image (push) Has been cancelled
Skill copy sync / shipped skills drift guard (push) Has been cancelled
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Devcontainer Smoke / Config-transform unit tests (push) Has been cancelled
Devcontainer Smoke / Build devcontainer image (push) Has been cancelled
Skill copy sync / shipped skills drift guard (push) Has been cancelled
feat(cli): mirror skills to .agents/skills/ when .agents/ exists
This commit is contained in:
commit
91b22676ce
11 changed files with 734 additions and 15 deletions
|
|
@ -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-<name>/`. 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-<name>/`) 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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -362,13 +362,32 @@ async function upsertGitNexusSection(
|
|||
}
|
||||
|
||||
/**
|
||||
* Install GitNexus skills as direct children of .claude/skills/
|
||||
* Works natively with Claude Code, Cursor, and GitHub Copilot
|
||||
* Some agents read skills from a repo-local `.agents/skills/` directory and
|
||||
* prefer it over the global `~/.agents/skills/` install. When the repo contains
|
||||
* an `.agents/` directory, skills written to `.claude/skills/` are mirrored
|
||||
* there too so those agents serve the up-to-date copies.
|
||||
*/
|
||||
async function installSkills(repoPath: string): Promise<string[]> {
|
||||
export async function shouldMirrorSkillsToAgents(repoPath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(path.join(repoPath, '.agents'));
|
||||
return stat.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install GitNexus skills as direct children of .claude/skills/
|
||||
* Works natively with Claude Code, Cursor, and GitHub Copilot.
|
||||
* Mirrored to .agents/skills/ when .agents/ exists.
|
||||
*/
|
||||
async function installSkills(
|
||||
repoPath: string,
|
||||
): Promise<{ skills: string[]; agentsMirror: boolean }> {
|
||||
const skillsDir = path.join(repoPath, '.claude', 'skills');
|
||||
const legacySkillsDir = path.join(skillsDir, 'gitnexus');
|
||||
const installedSkills: string[] = [];
|
||||
const agentsMirror = await shouldMirrorSkillsToAgents(repoPath);
|
||||
|
||||
for (const skill of STANDARD_SKILL_CATALOG.filter(
|
||||
(entry) => entry.distributions.project && entry.distributions.npm,
|
||||
|
|
@ -402,6 +421,18 @@ Use GitNexus tools to accomplish this task.
|
|||
}
|
||||
|
||||
await fs.writeFile(skillPath, skillContent, 'utf-8');
|
||||
|
||||
// Mirror to .agents/skills/ for agents that read repo-local skills
|
||||
if (agentsMirror) {
|
||||
try {
|
||||
const agentsSkillDir = path.join(repoPath, '.agents', 'skills', skill.name);
|
||||
await fs.mkdir(agentsSkillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), skillContent, 'utf-8');
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Warning: Could not mirror skill ${skill.name} to .agents/skills:`);
|
||||
}
|
||||
}
|
||||
|
||||
installedSkills.push(skill.name);
|
||||
|
||||
// Previous releases installed these known standard skills one level too
|
||||
|
|
@ -418,7 +449,7 @@ Use GitNexus tools to accomplish this task.
|
|||
}
|
||||
}
|
||||
|
||||
return installedSkills;
|
||||
return { skills: installedSkills, agentsMirror };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -496,9 +527,14 @@ export async function generateAIContextFiles(
|
|||
|
||||
// Install standard skills directly under .claude/skills/ (unless --skip-skills)
|
||||
if (!options?.skipSkills) {
|
||||
const installedSkills = await installSkills(repoPath);
|
||||
const { skills: installedSkills, agentsMirror } = await installSkills(repoPath);
|
||||
if (installedSkills.length > 0) {
|
||||
createdFiles.push(`.claude/skills/gitnexus-*/ (${installedSkills.length} skills)`);
|
||||
if (agentsMirror) {
|
||||
createdFiles.push(
|
||||
`.agents/skills/gitnexus-*/ (${installedSkills.length} skills mirrored for .agents)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
createdFiles.push('.claude/skills/gitnexus-*/ (skipped via --skip-skills)');
|
||||
|
|
|
|||
|
|
@ -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 directly 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':
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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 directly 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.',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { PipelineResult } from '../types/pipeline.js';
|
|||
import { CommunityNode, CommunityMembership } from '../core/ingestion/community-processor.js';
|
||||
import { ProcessNode } from '../core/ingestion/process-processor.js';
|
||||
import { KnowledgeGraph } from '../core/graph/types.js';
|
||||
import { shouldMirrorSkillsToAgents } from './ai-context.js';
|
||||
|
||||
const GENERATED_SKILL_PREFIX = 'gitnexus-area-';
|
||||
const MAX_SKILL_NAME_LENGTH = 64;
|
||||
|
|
@ -74,6 +75,12 @@ export const generateSkillFiles = async (
|
|||
const { communityResult, processResult, graph } = pipelineResult;
|
||||
const outputDir = path.join(repoPath, '.claude', 'skills');
|
||||
const legacyOutputDir = path.join(outputDir, 'generated');
|
||||
// Some agents prioritize repo-local .agents/skills over the global
|
||||
// ~/.agents/skills install (see shouldMirrorSkillsToAgents). When .agents/
|
||||
// exists, mirror the generated community skills there too so those agents
|
||||
// serve the up-to-date copies.
|
||||
const agentsOutputDir = path.join(repoPath, '.agents', 'skills');
|
||||
let mirrorToAgents = await shouldMirrorSkillsToAgents(repoPath);
|
||||
|
||||
// Community skills used to live under an undiscoverable `generated/`
|
||||
// grouping directory. Clear that GitNexus-owned legacy output and
|
||||
|
|
@ -95,6 +102,24 @@ export const generateSkillFiles = async (
|
|||
/* legacy output may not exist */
|
||||
}
|
||||
|
||||
// Mirror cleanup: clear only stale GitNexus-generated community skills under
|
||||
// .agents/skills/ (reserved gitnexus-area-* namespace), preserving mirrored
|
||||
// standard skills and any user-authored skills. Never clear the whole root.
|
||||
if (mirrorToAgents) {
|
||||
try {
|
||||
const entries = await fs.readdir(agentsOutputDir, { withFileTypes: true });
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith(GENERATED_SKILL_PREFIX))
|
||||
.map((entry) =>
|
||||
fs.rm(path.join(agentsOutputDir, entry.name), { recursive: true, force: true }),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
/* mirror root may not exist yet */
|
||||
}
|
||||
}
|
||||
|
||||
if (!communityResult || !communityResult.memberships.length) {
|
||||
console.log('\n Skills: no communities detected, skipping skill generation');
|
||||
return { skills: [], outputPath: outputDir };
|
||||
|
|
@ -135,6 +160,20 @@ 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) {
|
||||
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
|
||||
const skills: GeneratedSkillInfo[] = [];
|
||||
|
|
@ -185,6 +224,19 @@ export const generateSkillFiles = async (
|
|||
await fs.mkdir(skillDir, { recursive: true });
|
||||
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). Best-effort: a per-skill mirror failure
|
||||
// must not abort canonical community-skill generation.
|
||||
if (mirrorToAgents) {
|
||||
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 = {
|
||||
name: skillName,
|
||||
label: community.label,
|
||||
|
|
@ -201,6 +253,11 @@ export const generateSkillFiles = async (
|
|||
console.log(
|
||||
`\n ${skills.length} skills generated \u2192 .claude/skills/${GENERATED_SKILL_PREFIX}*/`,
|
||||
);
|
||||
if (mirrorToAgents) {
|
||||
console.log(
|
||||
` ${skills.length} skills mirrored \u2192 .agents/skills/${GENERATED_SKILL_PREFIX}*/ (.agents)`,
|
||||
);
|
||||
}
|
||||
|
||||
return { skills, outputPath: outputDir };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,10 +10,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 {
|
||||
|
|
@ -32,6 +35,8 @@ export const isWorkingTreeDirty = (repoPath: string): boolean => {
|
|||
':(exclude).cursor/**',
|
||||
':(exclude)AGENTS.md',
|
||||
':(exclude)CLAUDE.md',
|
||||
':(exclude).agents',
|
||||
':(exclude).agents/**',
|
||||
],
|
||||
{
|
||||
cwd: repoPath,
|
||||
|
|
|
|||
|
|
@ -429,6 +429,181 @@ Old content here.
|
|||
}
|
||||
});
|
||||
|
||||
it('mirrors standard skills to .agents/skills/ when .agents/ exists', async () => {
|
||||
// Some agents prefer repo-local .agents/skills over the global
|
||||
// ~/.agents/skills install. When the repo contains an .agents/ directory,
|
||||
// installSkills() must mirror the same SKILL.md files there so those agents
|
||||
// serve up-to-date repo-specific skills instead of stale global copies.
|
||||
const agentsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-agents-'));
|
||||
const agentsStorage = path.join(agentsDir, '.gitnexus');
|
||||
await fs.mkdir(agentsStorage, { recursive: true });
|
||||
// Opt-in: create the repo-local .agents/ directory.
|
||||
await fs.mkdir(path.join(agentsDir, '.agents'), { recursive: true });
|
||||
try {
|
||||
const stats = { nodes: 50, edges: 100, processes: 5 };
|
||||
const result = await generateAIContextFiles(agentsDir, agentsStorage, 'TestProject', stats);
|
||||
|
||||
// Canonical .claude copy is always written (flat layout, #2434).
|
||||
expect(result.files).toContain('.claude/skills/gitnexus-*/ (6 skills)');
|
||||
// Mirror is reported.
|
||||
expect(result.files).toContain('.agents/skills/gitnexus-*/ (6 skills mirrored for .agents)');
|
||||
|
||||
const claudeSkill = await fs.readFile(
|
||||
path.join(agentsDir, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
const agentsSkill = await fs.readFile(
|
||||
path.join(agentsDir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(agentsSkill).toBe(claudeSkill);
|
||||
expect(agentsSkill.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await fs.rm(agentsDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not mirror skills to .agents/ when the directory is absent', async () => {
|
||||
// Without an .agents/ opt-in, only the canonical .claude/skills/ copy is
|
||||
// written — no .agents/ tree should be created.
|
||||
const noAgentsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-no-agents-'));
|
||||
const noAgentsStorage = path.join(noAgentsDir, '.gitnexus');
|
||||
await fs.mkdir(noAgentsStorage, { recursive: true });
|
||||
try {
|
||||
const stats = { nodes: 50, edges: 100, processes: 5 };
|
||||
const result = await generateAIContextFiles(
|
||||
noAgentsDir,
|
||||
noAgentsStorage,
|
||||
'TestProject',
|
||||
stats,
|
||||
);
|
||||
|
||||
expect(result.files).toContain('.claude/skills/gitnexus-*/ (6 skills)');
|
||||
expect(result.files.some((f) => f.startsWith('.agents/skills/'))).toBe(false);
|
||||
await expect(fs.access(path.join(noAgentsDir, '.agents'))).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.rm(noAgentsDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -643,6 +643,266 @@ describe('generateSkillFiles — file output', () => {
|
|||
await expect(fs.access(path.join(skillsRoot, 'gitnexus-area-old'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
/**
|
||||
* When the repo contains an .agents/ directory, generated community skills
|
||||
* must be mirrored to .agents/skills/ (flat gitnexus-area-* layout, #2434)
|
||||
* so agents that prefer repo-local .agents/skills over the global
|
||||
* ~/.agents/skills install serve the up-to-date set. The mirror content must
|
||||
* match the .claude copy.
|
||||
*/
|
||||
it('mirrors generated skills to .agents/skills/ when .agents/ exists', async () => {
|
||||
const { graph, communities, memberships } = twoCommSetup();
|
||||
await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true });
|
||||
|
||||
await generateSkillFiles(
|
||||
tmpDir,
|
||||
'TestProject',
|
||||
buildPipelineResult({
|
||||
graph,
|
||||
repoPath: tmpDir,
|
||||
communities,
|
||||
memberships,
|
||||
}),
|
||||
);
|
||||
|
||||
const claudeAlpha = await fs.readFile(
|
||||
path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-alpha', 'SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
const agentsAlpha = await fs.readFile(
|
||||
path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-alpha', 'SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
const agentsBeta = await fs.readFile(
|
||||
path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-beta', 'SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(agentsAlpha).toBe(claudeAlpha);
|
||||
expect(agentsBeta.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Without an .agents/ opt-in, no .agents/skills/ tree should be created —
|
||||
* only the canonical .claude/skills/ copy is written.
|
||||
*/
|
||||
it('does not mirror generated skills to .agents/ when the directory is absent', async () => {
|
||||
const { graph, communities, memberships } = twoCommSetup();
|
||||
|
||||
await generateSkillFiles(
|
||||
tmpDir,
|
||||
'TestProject',
|
||||
buildPipelineResult({
|
||||
graph,
|
||||
repoPath: tmpDir,
|
||||
communities,
|
||||
memberships,
|
||||
}),
|
||||
);
|
||||
|
||||
// Canonical copy exists, mirror does not.
|
||||
const claudeAlpha = await fs.readFile(
|
||||
path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-alpha', 'SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(claudeAlpha.length).toBeGreaterThan(0);
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ describe('--skip-git CLI flag', () => {
|
|||
expect(helpOutput).toContain('--skip-agents-md');
|
||||
expect(helpOutput).toContain('--skip-skills');
|
||||
expect(helpOutput).toContain('directly under .claude/skills/');
|
||||
expect(helpOutput).toContain('.agents/skills/');
|
||||
expect(helpOutput).toContain('.claude/skills/gitnexus-area-*');
|
||||
expect(helpOutput).toContain('--index-only');
|
||||
expect(helpOutput).not.toContain('--no-git');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue