Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 28 additions & 25 deletions lib/kit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
24 changes: 24 additions & 0 deletions lib/kit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading