diff --git a/lib/kit.mjs b/lib/kit.mjs index 1983215..b85ad5e 100644 --- a/lib/kit.mjs +++ b/lib/kit.mjs @@ -146,37 +146,40 @@ export function mergePackageScripts(targetRoot, repoName) { export function appendGitignore(targetRoot, runtime = "both") { const gi = path.join(targetRoot, ".gitignore"); const existing = fs.existsSync(gi) ? fs.readFileSync(gi, "utf8") : ""; - if (existing.includes(GITIGNORE_MARKER)) return []; - const snippet = buildGitignoreSnippet(parseRuntime(runtime)); - fs.appendFileSync(gi, snippet); - return snippet.trim().split("\n").filter(Boolean); + const block = buildGitignoreSnippet(parseRuntime(runtime)).trim(); + // Refresh the managed block on every install/update so upgrades pick up ignore + // rules added in newer versions (e.g. .agents/skills, .gitnexus/, the tracked + // store) instead of leaving an older install's stale block untouched. + const base = stripManagedGitignoreBlock(existing).replace(/\n+$/, ""); + const next = base ? `${base}\n\n${block}\n` : `${block}\n`; + fs.writeFileSync(gi, next); + return block.split("\n").filter((l) => l && !l.startsWith("#")); +} + +/** + * Remove the managed block (marker comment + its contiguous non-blank lines), + * absorbing one surrounding blank line so we don't leave doubled blanks. + * @param {string} text @returns {string} + */ +function stripManagedGitignoreBlock(text) { + if (!text.includes(GITIGNORE_MARKER)) return text; + const lines = text.split("\n"); + let start = lines.findIndex((l) => l.includes(GITIGNORE_MARKER)); + if (start === -1) return text; + let end = start; + while (end < lines.length && lines[end].trim() !== "") end++; + if (start > 0 && lines[start - 1].trim() === "") start--; + if (end < lines.length && lines[end].trim() === "") end++; + lines.splice(start, end - start); + return lines.join("\n"); } /** @param {string} targetRoot */ export function removeGitignoreSnippet(targetRoot) { const gi = path.join(targetRoot, ".gitignore"); if (!fs.existsSync(gi)) return; - const lines = fs.readFileSync(gi, "utf8").split("\n"); - const out = []; - let skipping = false; - for (const line of lines) { - if (line.includes(GITIGNORE_MARKER)) { - skipping = true; - continue; - } - if (skipping) { - if ( - line.trim() === "" && - out.length > 0 && - out[out.length - 1]?.trim() === "" - ) { - skipping = false; - } - continue; - } - out.push(line); - } - fs.writeFileSync(gi, out.join("\n").replace(/\n+$/, "\n")); + const stripped = stripManagedGitignoreBlock(fs.readFileSync(gi, "utf8")); + fs.writeFileSync(gi, stripped.replace(/\n+$/, "\n")); } /** @param {string} targetRoot @param {string[]} [keys] Keys to remove (defaults to all managed). */ diff --git a/lib/kit.test.mjs b/lib/kit.test.mjs index 41b775f..ab1ae1c 100644 --- a/lib/kit.test.mjs +++ b/lib/kit.test.mjs @@ -650,6 +650,30 @@ describe("gitnexus-agent-kit", () => { assert.ok(GITIGNORE_MARKER.includes("GitNexus + gitnexus-agent-kit")); }); + it("appendGitignore refreshes an existing managed block on upgrade", async () => { + const { appendGitignore } = await import("./kit.mjs"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gn-gi-refresh-")); + // An older install: managed block predates .agents/skills and .gitnexus/. + fs.writeFileSync( + path.join(tmp, ".gitignore"), + `node_modules/\n\n${GITIGNORE_MARKER} (safe to remove)\n.tmp-agent/\n.cursor/skills/\n`, + ); + appendGitignore(tmp, "all"); + const gi = fs.readFileSync(path.join(tmp, ".gitignore"), "utf8"); + // Block refreshed to the current scheme: graph index + all IDE skill dirs ignored. + assert.ok(gi.includes(".gitnexus/"), "adds .gitnexus/ ignore on upgrade"); + assert.ok(gi.includes(".agents/skills/"), "adds .agents/skills on upgrade"); + assert.ok(gi.includes(".claude/skills/"), "adds .claude/skills on upgrade"); + // User content preserved; marker not duplicated. + assert.ok(gi.includes("node_modules/")); + assert.equal(gi.split(GITIGNORE_MARKER).length - 1, 1); + // Idempotent: a second pass keeps a single block. + appendGitignore(tmp, "all"); + const gi2 = fs.readFileSync(path.join(tmp, ".gitignore"), "utf8"); + assert.equal(gi2.split(GITIGNORE_MARKER).length - 1, 1); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + it("enforcement rule includes graph+embeddings gates", () => { const rule = fs.readFileSync( path.join(BUNDLE_ROOT, ".cursor/rules/00-gitnexus-enforcement.mdc"),