mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
fix: stop analyze from clobbering customized GitNexus skills (#3124)
* fix: preserve customized SKILL.md files during analyze Skip overwriting standard GitNexus skill files when they already differ from the bundled template, and keep divergent nested leftovers instead of deleting them. Fixes #3080 Co-authored-by: Cursor <cursoragent@cursor.com> * docs: note --skip-agents-md does not skip skills Point operators at --skip-skills so the AGENTS/CLAUDE skip flag is not mistaken for a full agent-file freeze. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(review): treat unreadable SKILL.md as an error, not missing Only ENOENT counts as absent so EACCES cannot fall through to recursive rm. Strengthen the skipSkills nested leftover test with a bundle-identical file that install would otherwise delete. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): prettier-format ai-context unit tests Co-authored-by: Cursor <cursoragent@cursor.com> * docs: clarify --skip-agents-md vs community --skills Co-authored-by: Cursor <cursoragent@cursor.com> * test: pin GITNEXUS_LANG=en for skip-agents-md help assertions Co-authored-by: Cursor <cursoragent@cursor.com> * fix: preserve customized skills in analyze summaries and setup Report preserved vs written skills, keep leftover nested directories that contain extra files, and skip setup overwrite of divergent SKILL.md. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3124) Preserve operator-edited files inside directory skills during setup, and restore GITNEXUS_TEST_SKILLS_ROOT after the Antigravity skill tests. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3124) Copy directory-skill companions even when SKILL.md is customized, while still skipping files whose bytes already differ from the bundle. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9f82ffd6bf
commit
a82362765c
9 changed files with 492 additions and 33 deletions
|
|
@ -240,7 +240,7 @@ gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS
|
|||
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
|
||||
gitnexus embeddings install # Fetch the optional local embedding stack on demand (--cuda, --force)
|
||||
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
|
||||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
|
||||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits (does not skip standard skills; use --skip-skills; community --skills files are unaffected)
|
||||
gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files
|
||||
gitnexus analyze --skip-git # Index folders that are not Git repositories
|
||||
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import path from 'path';
|
|||
import { fileURLToPath } from 'url';
|
||||
import { type GeneratedSkillInfo } from './generated-skill.js';
|
||||
import { STANDARD_SKILL_CATALOG } from './standard-skills.js';
|
||||
import { isEnoent } from './editor-targets.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
// ESM equivalent of __dirname
|
||||
|
|
@ -440,17 +441,84 @@ export async function shouldMirrorSkillsToAgents(repoPath: string): Promise<bool
|
|||
}
|
||||
}
|
||||
|
||||
const SKILL_PRESERVE_HINT =
|
||||
'delete the file to refresh from the bundled template, or pass --skip-skills to skip skill install';
|
||||
|
||||
async function readUtf8IfPresent(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await fs.readFile(filePath, 'utf-8');
|
||||
} catch (err) {
|
||||
if (isEnoent(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function skillBytesDiverge(existing: string | null, bundled: string): boolean {
|
||||
return existing !== null && existing !== bundled;
|
||||
}
|
||||
|
||||
/** Write bundled skill bytes unless an existing file already differs. */
|
||||
async function writeSkillUnlessDivergent(filePath: string, content: string): Promise<boolean> {
|
||||
const existing = await readUtf8IfPresent(filePath);
|
||||
if (skillBytesDiverge(existing, content)) {
|
||||
logger.warn(`Preserved customized skill ${filePath}; ${SKILL_PRESERVE_HINT}.`);
|
||||
return true;
|
||||
}
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, content, 'utf-8');
|
||||
return false;
|
||||
}
|
||||
|
||||
async function inspectLegacySkillDir(
|
||||
legacyDir: string,
|
||||
): Promise<{ nestedExisting: string | null; hasSiblings: boolean } | null> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(legacyDir);
|
||||
} catch (err) {
|
||||
if (isEnoent(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
const nestedExisting = entries.includes('SKILL.md')
|
||||
? await fs.readFile(path.join(legacyDir, 'SKILL.md'), 'utf-8')
|
||||
: null;
|
||||
return {
|
||||
nestedExisting,
|
||||
hasSiblings: entries.some((entry) => entry !== 'SKILL.md'),
|
||||
};
|
||||
}
|
||||
|
||||
function formatSkillInstallLine(
|
||||
prefix: string,
|
||||
total: number,
|
||||
preserved: number,
|
||||
allWrittenSuffix: string,
|
||||
partialSuffix: string,
|
||||
): string {
|
||||
if (preserved > 0) {
|
||||
return `${prefix} (${total - preserved} written, ${preserved} ${partialSuffix})`;
|
||||
}
|
||||
return `${prefix} (${total} ${allWrittenSuffix})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }> {
|
||||
async function installSkills(repoPath: string): Promise<{
|
||||
skills: string[];
|
||||
agentsMirror: boolean;
|
||||
claudePreserved: number;
|
||||
agentsPreserved: number;
|
||||
legacyPreserved: number;
|
||||
}> {
|
||||
const skillsDir = path.join(repoPath, '.claude', 'skills');
|
||||
const legacySkillsDir = path.join(skillsDir, 'gitnexus');
|
||||
const installedSkills: string[] = [];
|
||||
let claudePreserved = 0;
|
||||
let agentsPreserved = 0;
|
||||
let legacyPreserved = 0;
|
||||
const agentsMirror = await shouldMirrorSkillsToAgents(repoPath);
|
||||
|
||||
for (const skill of STANDARD_SKILL_CATALOG.filter(
|
||||
|
|
@ -460,9 +528,6 @@ async function installSkills(
|
|||
const skillPath = path.join(skillDir, 'SKILL.md');
|
||||
|
||||
try {
|
||||
// Create skill directory
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
|
||||
// Try to read from package skills directory
|
||||
const packageSkillPath = path.join(__dirname, '..', '..', 'skills', `${skill.name}.md`);
|
||||
let skillContent: string;
|
||||
|
|
@ -484,14 +549,13 @@ Use GitNexus tools to accomplish this task.
|
|||
`;
|
||||
}
|
||||
|
||||
await fs.writeFile(skillPath, skillContent, 'utf-8');
|
||||
if (await writeSkillUnlessDivergent(skillPath, skillContent)) claudePreserved += 1;
|
||||
|
||||
// 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');
|
||||
const agentsSkillPath = path.join(repoPath, '.agents', 'skills', skill.name, 'SKILL.md');
|
||||
if (await writeSkillUnlessDivergent(agentsSkillPath, skillContent)) agentsPreserved += 1;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Warning: Could not mirror skill ${skill.name} to .agents/skills:`);
|
||||
}
|
||||
|
|
@ -503,7 +567,20 @@ Use GitNexus tools to accomplish this task.
|
|||
// deep. Remove only the child owned by this installer; unknown siblings
|
||||
// under the legacy grouping directory may be user-authored and survive.
|
||||
try {
|
||||
await fs.rm(path.join(legacySkillsDir, skill.name), { recursive: true, force: true });
|
||||
const legacyDir = path.join(legacySkillsDir, skill.name);
|
||||
const nestedSkill = path.join(legacyDir, 'SKILL.md');
|
||||
const leftover = await inspectLegacySkillDir(legacyDir);
|
||||
if (leftover !== null && skillBytesDiverge(leftover.nestedExisting, skillContent)) {
|
||||
logger.warn(`Preserved customized skill ${nestedSkill}; ${SKILL_PRESERVE_HINT}.`);
|
||||
legacyPreserved += 1;
|
||||
} else if (leftover?.hasSiblings) {
|
||||
logger.warn(
|
||||
`Preserved legacy skill directory ${legacyDir} because it contains operator-owned files.`,
|
||||
);
|
||||
legacyPreserved += 1;
|
||||
} else if (leftover !== null) {
|
||||
await fs.rm(legacyDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Warning: Could not remove legacy skill ${skill.name}:`);
|
||||
}
|
||||
|
|
@ -513,7 +590,13 @@ Use GitNexus tools to accomplish this task.
|
|||
}
|
||||
}
|
||||
|
||||
return { skills: installedSkills, agentsMirror };
|
||||
return {
|
||||
skills: installedSkills,
|
||||
agentsMirror,
|
||||
claudePreserved,
|
||||
agentsPreserved,
|
||||
legacyPreserved,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -592,12 +675,37 @@ export async function generateAIContextFiles(
|
|||
|
||||
// Install standard skills directly under .claude/skills/ (unless --skip-skills)
|
||||
if (!options?.skipSkills) {
|
||||
const { skills: installedSkills, agentsMirror } = await installSkills(repoPath);
|
||||
const {
|
||||
skills: installedSkills,
|
||||
agentsMirror,
|
||||
claudePreserved,
|
||||
agentsPreserved,
|
||||
legacyPreserved,
|
||||
} = await installSkills(repoPath);
|
||||
if (installedSkills.length > 0) {
|
||||
createdFiles.push(`.claude/skills/gitnexus-*/ (${installedSkills.length} skills)`);
|
||||
createdFiles.push(
|
||||
formatSkillInstallLine(
|
||||
'.claude/skills/gitnexus-*/',
|
||||
installedSkills.length,
|
||||
claudePreserved,
|
||||
'skills',
|
||||
'preserved',
|
||||
),
|
||||
);
|
||||
if (agentsMirror) {
|
||||
createdFiles.push(
|
||||
`.agents/skills/gitnexus-*/ (${installedSkills.length} skills mirrored for .agents)`,
|
||||
formatSkillInstallLine(
|
||||
'.agents/skills/gitnexus-*/',
|
||||
installedSkills.length,
|
||||
agentsPreserved,
|
||||
'skills mirrored for .agents',
|
||||
'preserved for .agents',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (legacyPreserved > 0) {
|
||||
createdFiles.push(
|
||||
`.claude/skills/gitnexus/<name>/ (legacy directories preserved: ${legacyPreserved})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ export const en = {
|
|||
'help.option.analyze.skills':
|
||||
'Generate repo-specific skill files from detected communities (no-op when --index-only is also set).',
|
||||
'help.option.analyze.skipAgentsMd':
|
||||
'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md',
|
||||
'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md. Does not skip standard skills in .claude/skills or .agents/skills; use --skip-skills for those. Community skills from --skills are unaffected.',
|
||||
'help.option.analyze.noStats': 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md',
|
||||
'help.option.analyze.selfCommit':
|
||||
'Auto-commit AGENTS.md/CLAUDE.md changes after analyze (opt-in, off by default). Scoped to only those two files (never `git add -A`); no-ops if neither exists, neither changed, or the repo has no git identity configured.',
|
||||
|
|
|
|||
|
|
@ -188,7 +188,8 @@ export const zhCN = {
|
|||
'重建时删除现有嵌入。默认情况下,未传 `--embeddings` 的 `analyze` 会保留索引中已有嵌入。',
|
||||
'help.option.analyze.skills':
|
||||
'根据检测到的社区生成仓库专属 skill 文件(同时设置 --index-only 时无效)。',
|
||||
'help.option.analyze.skipAgentsMd': '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块',
|
||||
'help.option.analyze.skipAgentsMd':
|
||||
'跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块。不会跳过 .claude/skills 或 .agents/skills 下的标准 skill;如需跳过那些请使用 --skip-skills。--skills 生成的社区 skill 不受影响。',
|
||||
'help.option.analyze.noStats': '从 AGENTS.md 和 CLAUDE.md 中省略易变的文件/符号计数',
|
||||
'help.option.analyze.selfCommit':
|
||||
'在 analyze 后自动提交 AGENTS.md/CLAUDE.md 的变更(默认关闭,需显式开启)。仅限这两个文件(绝不使用 `git add -A`);若两者均不存在、均未变更,或仓库未配置 git 身份,则不执行任何操作。',
|
||||
|
|
|
|||
|
|
@ -76,7 +76,10 @@ program
|
|||
'Generate repo-specific skill files from detected communities ' +
|
||||
'(no-op when --index-only is also set).',
|
||||
)
|
||||
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
|
||||
.option(
|
||||
'--skip-agents-md',
|
||||
'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md. Does not skip standard skills in .claude/skills or .agents/skills; use --skip-skills for those. Community skills from --skills are unaffected.',
|
||||
)
|
||||
.option(
|
||||
'--pdg',
|
||||
'Build the control-flow-graph / PDG substrate (BasicBlock nodes + CFG edges) ' +
|
||||
|
|
|
|||
|
|
@ -1090,14 +1090,30 @@ async function installSkillsTo(targetDir: string): Promise<string[]> {
|
|||
const skillDir = path.join(targetDir, skillName);
|
||||
|
||||
try {
|
||||
if (source.isDirectory) {
|
||||
const dirSource = path.join(skillsRoot, skillName);
|
||||
await copyDirRecursive(dirSource, skillDir);
|
||||
} else {
|
||||
const flatSource = path.join(skillsRoot, `${skillName}.md`);
|
||||
const content = await fs.readFile(flatSource, 'utf-8');
|
||||
const sourceSkillPath = source.isDirectory
|
||||
? path.join(skillsRoot, skillName, 'SKILL.md')
|
||||
: path.join(skillsRoot, `${skillName}.md`);
|
||||
const destinationSkillPath = path.join(skillDir, 'SKILL.md');
|
||||
const [sourceSkillContent, destinationSkillContent] = await Promise.all([
|
||||
fs.readFile(sourceSkillPath, 'utf-8'),
|
||||
fs.readFile(destinationSkillPath, 'utf-8').catch((err) => {
|
||||
if (!isEnoent(err)) throw err;
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
|
||||
const preserved =
|
||||
destinationSkillContent !== null && destinationSkillContent !== sourceSkillContent;
|
||||
if (preserved && !source.isDirectory) {
|
||||
console.log(
|
||||
`[gitnexus] preserved customized skill ${destinationSkillPath}; ` +
|
||||
'delete the file and rerun setup to refresh it.',
|
||||
);
|
||||
} else if (source.isDirectory) {
|
||||
await copyDirRecursive(path.join(skillsRoot, skillName), skillDir);
|
||||
} else if (!preserved) {
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8');
|
||||
await fs.writeFile(destinationSkillPath, sourceSkillContent, 'utf-8');
|
||||
}
|
||||
|
||||
// A directory superseded by a shipped rename is warned about, never
|
||||
|
|
@ -1113,7 +1129,7 @@ async function installSkillsTo(targetDir: string): Promise<string[]> {
|
|||
);
|
||||
}
|
||||
}
|
||||
installed.push(skillName);
|
||||
if (!preserved) installed.push(skillName);
|
||||
} catch {
|
||||
// Source skill not found — skip
|
||||
}
|
||||
|
|
@ -1133,9 +1149,23 @@ async function copyDirRecursive(src: string, dest: string): Promise<void> {
|
|||
const destPath = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await copyDirRecursive(srcPath, destPath);
|
||||
} else {
|
||||
await fs.copyFile(srcPath, destPath);
|
||||
continue;
|
||||
}
|
||||
const [srcBuf, destBuf] = await Promise.all([
|
||||
fs.readFile(srcPath),
|
||||
fs.readFile(destPath).catch((err) => {
|
||||
if (!isEnoent(err)) throw err;
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
if (destBuf !== null && !destBuf.equals(srcBuf)) {
|
||||
console.log(
|
||||
`[gitnexus] preserved customized skill ${destPath}; ` +
|
||||
'delete the file and rerun setup to refresh it.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await fs.writeFile(destPath, srcBuf);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
refreshBaseRefLine,
|
||||
markdownSafeBranch,
|
||||
} from '../../src/cli/ai-context.js';
|
||||
import { _captureLogger } from '../../src/core/logger.js';
|
||||
|
||||
describe('generateAIContextFiles', () => {
|
||||
let tmpDir: string;
|
||||
|
|
@ -497,7 +498,11 @@ Old content here.
|
|||
await expect(
|
||||
fs.access(path.join(dir, '.claude', 'skills', 'gitnexus-exploring', 'SKILL.md')),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(fs.access(legacyKnown)).rejects.toThrow();
|
||||
// Divergent nested SKILL.md is preserved (#3080); only byte-identical
|
||||
// leftovers are still removed.
|
||||
await expect(fs.readFile(path.join(legacyKnown, 'SKILL.md'), 'utf-8')).resolves.toBe(
|
||||
'legacy',
|
||||
);
|
||||
await expect(fs.readFile(path.join(legacyUnknown, 'SKILL.md'), 'utf-8')).resolves.toBe(
|
||||
'custom nested',
|
||||
);
|
||||
|
|
@ -538,6 +543,198 @@ Old content here.
|
|||
}
|
||||
});
|
||||
|
||||
it('skipSkills does not remove nested leftover standard skills (#3080 / AE4)', async () => {
|
||||
const skipDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-skip-nested-'));
|
||||
const skipStorage = path.join(skipDir, '.gitnexus');
|
||||
const nested = path.join(skipDir, '.claude', 'skills', 'gitnexus', 'gitnexus-cli');
|
||||
const bundled = await fs.readFile(
|
||||
path.join(__dirname, '../../skills/gitnexus-cli.md'),
|
||||
'utf-8',
|
||||
);
|
||||
await fs.mkdir(nested, { recursive: true });
|
||||
await fs.mkdir(skipStorage, { recursive: true });
|
||||
await fs.writeFile(path.join(nested, 'SKILL.md'), bundled, 'utf-8');
|
||||
try {
|
||||
await generateAIContextFiles(skipDir, skipStorage, 'TestProject', { nodes: 1 }, undefined, {
|
||||
skipAgentsMd: true,
|
||||
skipSkills: true,
|
||||
});
|
||||
await expect(fs.readFile(path.join(nested, 'SKILL.md'), 'utf-8')).resolves.toBe(bundled);
|
||||
await expect(
|
||||
fs.access(path.join(skipDir, '.claude', 'skills', 'gitnexus-cli')),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.rm(skipDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves customized flat SKILL.md under skipAgentsMd (#3080 / AE1)', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-flat-'));
|
||||
const storage = path.join(dir, '.gitnexus');
|
||||
const cliSkill = path.join(dir, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md');
|
||||
await fs.mkdir(path.dirname(cliSkill), { recursive: true });
|
||||
await fs.mkdir(storage, { recursive: true });
|
||||
await fs.writeFile(cliSkill, 'CUSTOM-COMMITTED-SKILL-3080-cli\n', 'utf-8');
|
||||
const cap = _captureLogger();
|
||||
try {
|
||||
const result = await generateAIContextFiles(
|
||||
dir,
|
||||
storage,
|
||||
'TestProject',
|
||||
{ nodes: 1 },
|
||||
undefined,
|
||||
{ skipAgentsMd: true },
|
||||
);
|
||||
expect(result.files).toContain('AGENTS.md (skipped via --skip-agents-md)');
|
||||
expect(result.files.some((f) => f.includes('skipped via --skip-skills'))).toBe(false);
|
||||
await expect(fs.readFile(cliSkill, 'utf-8')).resolves.toBe(
|
||||
'CUSTOM-COMMITTED-SKILL-3080-cli\n',
|
||||
);
|
||||
const msgs = cap.records().map((r) => r.msg ?? '');
|
||||
expect(msgs.some((m) => m.includes(cliSkill) && m.includes('--skip-skills'))).toBe(true);
|
||||
} finally {
|
||||
cap.restore();
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a missing standard skill from the bundle (#3080 / AE2)', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-missing-'));
|
||||
const storage = path.join(dir, '.gitnexus');
|
||||
await fs.mkdir(storage, { recursive: true });
|
||||
try {
|
||||
await generateAIContextFiles(dir, storage, 'TestProject', { nodes: 1 }, undefined, {
|
||||
skipAgentsMd: true,
|
||||
});
|
||||
const created = await fs.readFile(
|
||||
path.join(dir, '.claude', 'skills', 'gitnexus-debugging', 'SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
const bundled = await fs.readFile(
|
||||
path.join(__dirname, '../../skills/gitnexus-debugging.md'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(created).toBe(bundled);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rewrites a SKILL.md that already matches the current bundle (R2)', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-ident-'));
|
||||
const storage = path.join(dir, '.gitnexus');
|
||||
const dest = path.join(dir, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md');
|
||||
const bundled = await fs.readFile(
|
||||
path.join(__dirname, '../../skills/gitnexus-cli.md'),
|
||||
'utf-8',
|
||||
);
|
||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
||||
await fs.mkdir(storage, { recursive: true });
|
||||
await fs.writeFile(dest, bundled, 'utf-8');
|
||||
const writeSpy = vi.spyOn(fs, 'writeFile');
|
||||
try {
|
||||
await generateAIContextFiles(dir, storage, 'TestProject', { nodes: 1 }, undefined, {
|
||||
skipAgentsMd: true,
|
||||
});
|
||||
await expect(fs.readFile(dest, 'utf-8')).resolves.toBe(bundled);
|
||||
expect(writeSpy).toHaveBeenCalledWith(dest, bundled, 'utf-8');
|
||||
} finally {
|
||||
writeSpy.mockRestore();
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('removes nested leftover when SKILL.md matches the bundle', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-nested-ident-'));
|
||||
const storage = path.join(dir, '.gitnexus');
|
||||
const nested = path.join(dir, '.claude', 'skills', 'gitnexus', 'gitnexus-cli');
|
||||
const bundled = await fs.readFile(
|
||||
path.join(__dirname, '../../skills/gitnexus-cli.md'),
|
||||
'utf-8',
|
||||
);
|
||||
await fs.mkdir(nested, { recursive: true });
|
||||
await fs.mkdir(storage, { recursive: true });
|
||||
await fs.writeFile(path.join(nested, 'SKILL.md'), bundled, 'utf-8');
|
||||
try {
|
||||
await generateAIContextFiles(dir, storage, 'TestProject', { nodes: 1 }, undefined, {
|
||||
skipAgentsMd: true,
|
||||
});
|
||||
await expect(fs.access(nested)).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves nested leftover siblings even when SKILL.md matches the bundle', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-nested-sibling-'));
|
||||
const storage = path.join(dir, '.gitnexus');
|
||||
const nested = path.join(dir, '.claude', 'skills', 'gitnexus', 'gitnexus-cli');
|
||||
const bundled = await fs.readFile(
|
||||
path.join(__dirname, '../../skills/gitnexus-cli.md'),
|
||||
'utf-8',
|
||||
);
|
||||
await fs.mkdir(nested, { recursive: true });
|
||||
await fs.mkdir(storage, { recursive: true });
|
||||
await fs.writeFile(path.join(nested, 'SKILL.md'), bundled, 'utf-8');
|
||||
await fs.writeFile(path.join(nested, 'notes.md'), 'operator notes\n', 'utf-8');
|
||||
const cap = _captureLogger();
|
||||
try {
|
||||
const result = await generateAIContextFiles(
|
||||
dir,
|
||||
storage,
|
||||
'TestProject',
|
||||
{ nodes: 1 },
|
||||
undefined,
|
||||
{ skipAgentsMd: true },
|
||||
);
|
||||
await expect(fs.readFile(path.join(nested, 'notes.md'), 'utf-8')).resolves.toBe(
|
||||
'operator notes\n',
|
||||
);
|
||||
expect(result.files).toContain(
|
||||
'.claude/skills/gitnexus/<name>/ (legacy directories preserved: 1)',
|
||||
);
|
||||
expect(cap.records().some((record) => record.msg?.includes('operator-owned files'))).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
cap.restore();
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves a divergent .agents mirror while writing a missing .claude copy', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-agents-'));
|
||||
const storage = path.join(dir, '.gitnexus');
|
||||
const agentsSkill = path.join(dir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md');
|
||||
await fs.mkdir(path.dirname(agentsSkill), { recursive: true });
|
||||
await fs.mkdir(storage, { recursive: true });
|
||||
await fs.writeFile(agentsSkill, 'CUSTOM-AGENTS-MIRROR\n', 'utf-8');
|
||||
try {
|
||||
const result = await generateAIContextFiles(
|
||||
dir,
|
||||
storage,
|
||||
'TestProject',
|
||||
{ nodes: 1 },
|
||||
undefined,
|
||||
{
|
||||
skipAgentsMd: true,
|
||||
},
|
||||
);
|
||||
await expect(fs.readFile(agentsSkill, 'utf-8')).resolves.toBe('CUSTOM-AGENTS-MIRROR\n');
|
||||
const claudeCopy = await fs.readFile(
|
||||
path.join(dir, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(claudeCopy).not.toBe('CUSTOM-AGENTS-MIRROR\n');
|
||||
expect(claudeCopy.length).toBeGreaterThan(0);
|
||||
expect(result.files).toContain(
|
||||
'.agents/skills/gitnexus-*/ (5 written, 1 preserved for .agents)',
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,11 @@ describe('setupAntigravity', () => {
|
|||
});
|
||||
};
|
||||
|
||||
const restoreSkillsRoot = (previous: string | undefined) => {
|
||||
if (previous === undefined) delete process.env.GITNEXUS_TEST_SKILLS_ROOT;
|
||||
else process.env.GITNEXUS_TEST_SKILLS_ROOT = previous;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -263,6 +268,7 @@ describe('setupAntigravity', () => {
|
|||
'---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n',
|
||||
'utf-8',
|
||||
);
|
||||
const originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT;
|
||||
process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot;
|
||||
|
||||
try {
|
||||
|
|
@ -278,7 +284,117 @@ describe('setupAntigravity', () => {
|
|||
fs.access(path.join(skillsDir, 'gitnexus-test', 'SKILL.md')),
|
||||
).resolves.toBeUndefined();
|
||||
} finally {
|
||||
delete process.env.GITNEXUS_TEST_SKILLS_ROOT;
|
||||
restoreSkillsRoot(originalSkillsRoot);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves a customized installed skill when setup is rerun', async () => {
|
||||
const fixtureSkillsRoot = path.join(tempHome, 'fixture-skills');
|
||||
const installedSkill = path.join(
|
||||
tempHome,
|
||||
'.gemini',
|
||||
'antigravity',
|
||||
'skills',
|
||||
'gitnexus-test',
|
||||
'SKILL.md',
|
||||
);
|
||||
await fs.mkdir(fixtureSkillsRoot, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(fixtureSkillsRoot, 'gitnexus-test.md'),
|
||||
'---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n',
|
||||
'utf-8',
|
||||
);
|
||||
const originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT;
|
||||
process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot;
|
||||
|
||||
try {
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
await fs.writeFile(installedSkill, 'customized by operator\n', 'utf-8');
|
||||
|
||||
await setupCommand();
|
||||
|
||||
await expect(fs.readFile(installedSkill, 'utf-8')).resolves.toBe('customized by operator\n');
|
||||
} finally {
|
||||
restoreSkillsRoot(originalSkillsRoot);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves customized files inside a directory skill when SKILL.md still matches', async () => {
|
||||
const fixtureSkillsRoot = path.join(tempHome, 'fixture-skills');
|
||||
const skillDir = path.join(tempHome, '.gemini', 'antigravity', 'skills', 'gitnexus-test');
|
||||
const referencePath = path.join(skillDir, 'references', 'note.md');
|
||||
await fs.mkdir(path.join(fixtureSkillsRoot, 'gitnexus-test', 'references'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(fixtureSkillsRoot, 'gitnexus-test', 'SKILL.md'),
|
||||
'---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n',
|
||||
'utf-8',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(fixtureSkillsRoot, 'gitnexus-test', 'references', 'note.md'),
|
||||
'bundled note\n',
|
||||
'utf-8',
|
||||
);
|
||||
const originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT;
|
||||
process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot;
|
||||
|
||||
try {
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
await fs.writeFile(referencePath, 'operator note\n', 'utf-8');
|
||||
|
||||
await setupCommand();
|
||||
|
||||
await expect(fs.readFile(referencePath, 'utf-8')).resolves.toBe('operator note\n');
|
||||
await expect(fs.readFile(path.join(skillDir, 'SKILL.md'), 'utf-8')).resolves.toBe(
|
||||
'---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n',
|
||||
);
|
||||
} finally {
|
||||
restoreSkillsRoot(originalSkillsRoot);
|
||||
}
|
||||
});
|
||||
|
||||
it('copies new bundled companions even when SKILL.md was customized', async () => {
|
||||
const fixtureSkillsRoot = path.join(tempHome, 'fixture-skills');
|
||||
const skillDir = path.join(tempHome, '.gemini', 'antigravity', 'skills', 'gitnexus-test');
|
||||
await fs.mkdir(path.join(fixtureSkillsRoot, 'gitnexus-test', 'references'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(fixtureSkillsRoot, 'gitnexus-test', 'SKILL.md'),
|
||||
'---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n',
|
||||
'utf-8',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(fixtureSkillsRoot, 'gitnexus-test', 'references', 'note.md'),
|
||||
'bundled note\n',
|
||||
'utf-8',
|
||||
);
|
||||
const originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT;
|
||||
process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot;
|
||||
|
||||
try {
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'customized by operator\n', 'utf-8');
|
||||
await fs.writeFile(
|
||||
path.join(fixtureSkillsRoot, 'gitnexus-test', 'references', 'added.md'),
|
||||
'new bundled companion\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
await setupCommand();
|
||||
|
||||
await expect(fs.readFile(path.join(skillDir, 'SKILL.md'), 'utf-8')).resolves.toBe(
|
||||
'customized by operator\n',
|
||||
);
|
||||
await expect(
|
||||
fs.readFile(path.join(skillDir, 'references', 'added.md'), 'utf-8'),
|
||||
).resolves.toBe('new bundled companion\n');
|
||||
} finally {
|
||||
restoreSkillsRoot(originalSkillsRoot);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,11 +39,15 @@ describe('--skip-git CLI flag', () => {
|
|||
cwd: path.resolve(__dirname, '../..'),
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
env: { ...process.env, GITNEXUS_LANG: 'en' },
|
||||
});
|
||||
|
||||
expect(helpOutput).toContain('--skip-git');
|
||||
expect(helpOutput).toContain('--skip-agents-md');
|
||||
expect(helpOutput).toContain('--skip-skills');
|
||||
const helpFlat = helpOutput.replace(/\s+/g, ' ');
|
||||
expect(helpFlat).toContain('--skip-agents-md');
|
||||
expect(helpFlat).toContain('Does not skip standard skills in .claude/skills');
|
||||
expect(helpFlat).toContain('Community skills from --skills are unaffected');
|
||||
expect(helpFlat).toContain('--skip-skills');
|
||||
expect(helpOutput).toContain('directly under .claude/skills/');
|
||||
expect(helpOutput).toContain('.agents/skills/');
|
||||
expect(helpOutput).toContain('.claude/skills/gitnexus-area-*');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue