From 58d344ea8273cefefebb322c7730488dad58f2ca Mon Sep 17 00:00:00 2001 From: ReidenXerx Date: Wed, 8 Jul 2026 16:42:36 +0300 Subject: [PATCH] fix(gitignore): refresh managed block on upgrade, not just fresh install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit appendGitignore returned early when the marker already existed, so an existing install upgraded to a newer kit kept its stale managed block — ignore rules added later (`.gitnexus/`, `.agents/skills/`, the tracked `.gnkit/skills` store scheme) were never applied. Old installs silently missed the "distribute skills via git" gitignore changes. Now the managed block is stripped and rewritten from the current snippet on every install/update (additions and removals both propagate), preserving surrounding user content and staying idempotent. Refactor removeGitignoreSnippet to share the same block-strip helper. Verified on a real v1.1.0 install (enter-control): block refreshed to ignore the index + all IDE skill symlink dirs while the .gnkit/skills store stays trackable. Ports a fix from the discarded main-based branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/kit.mjs | 53 +++++++++++++++++++++++++----------------------- lib/kit.test.mjs | 24 ++++++++++++++++++++++ 2 files changed, 52 insertions(+), 25 deletions(-) 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"),