fix(gbrain-sync): add .gbrain-source to consumer repo .gitignore (#1384)

The v1.29.0.0 changelog promised .gbrain-source would be added to the
consuming repo's .gitignore so the per-worktree pin stays local, but the
change actually only added it to gstack's own .gitignore. Without the
consumer-side entry, the pin gets committed and Conductor sibling
worktrees of the same repo + branch step on each other's pin every time
anyone commits.

Add ensureGbrainSourceGitignored after a successful gbrain sources
attach in runCodeImport. Idempotent on repeat runs (line-trim match),
creates .gitignore if missing, logs a warning and continues on
permission errors so a read-only checkout doesn't fail the sync.

Gate the top-level main() call behind import.meta.main so tests can
import the helper without triggering a full sync run on module load.

Tests in test/gbrain-source-gitignore.test.ts cover: create-when-missing,
append-without-trailing-newline, append-with-trailing-newline,
idempotent on repeat, recognize whitespace-surrounded entry, no-throw
on read-only file. 6 pass.
This commit is contained in:
genisis0x 2026-05-15 16:13:54 +05:30 committed by Garry Tan
parent 0fb7fa6c1e
commit 2b5dd909ae
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
2 changed files with 136 additions and 0 deletions

View File

@ -666,6 +666,13 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
};
}
// v1.29.0.0 changelog promised the per-worktree pin would be ignored in the
// consuming repo, but the change actually only added .gbrain-source to
// gstack's own .gitignore. Without the consumer-side entry, the pin gets
// committed and breaks the per-worktree promise: Conductor sibling worktrees
// step on each other's pin every time anyone commits (#1384).
ensureGbrainSourceGitignored(root);
return {
name: "code",
ran: true,
@ -682,6 +689,39 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
};
}
/**
* Ensure `.gbrain-source` is listed in the consumer repo's `.gitignore`.
*
* Idempotent: only appends when the entry is not already present (matched on
* trimmed lines so a leading/trailing whitespace difference doesn't add a
* second copy). Wraps writes in try/catch so a read-only checkout or weird
* perms logs a warning and lets the rest of the sync continue.
*/
export function ensureGbrainSourceGitignored(root: string): void {
const gitignorePath = join(root, ".gitignore");
try {
let existing = "";
try {
existing = readFileSync(gitignorePath, "utf-8");
} catch {
// No .gitignore yet — we'll create it.
}
const alreadyIgnored = existing
.split("\n")
.some((line) => line.trim() === ".gbrain-source");
if (alreadyIgnored) {
return;
}
const sep = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
writeFileSync(gitignorePath, existing + sep + ".gbrain-source\n");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(
`[sync:code] could not add .gbrain-source to ${gitignorePath}: ${msg}`,
);
}
}
function runMemoryIngest(args: CliArgs): StageResult {
const t0 = Date.now();

View File

@ -0,0 +1,96 @@
/**
* Unit tests for the `.gbrain-source` gitignore append done by
* `runCodeImport` after a successful `gbrain sources attach`.
*
* Covers #1384: v1.29.0.0 changelog promised the per-worktree pin would be
* ignored in the consuming repo, but the change actually only added
* `.gbrain-source` to gstack's own `.gitignore`. Without the consumer-side
* entry, Conductor sibling worktrees commit the pin and clobber each other.
*/
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, chmodSync, statSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { ensureGbrainSourceGitignored } from "../bin/gstack-gbrain-sync";
describe("ensureGbrainSourceGitignored", () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "gstack-gbrain-gitignore-"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
it("creates .gitignore with the pin entry when none exists", () => {
const gitignorePath = join(root, ".gitignore");
expect(existsSync(gitignorePath)).toBe(false);
ensureGbrainSourceGitignored(root);
expect(existsSync(gitignorePath)).toBe(true);
expect(readFileSync(gitignorePath, "utf-8")).toBe(".gbrain-source\n");
});
it("appends the pin entry to an existing .gitignore without trailing newline", () => {
const gitignorePath = join(root, ".gitignore");
writeFileSync(gitignorePath, "node_modules\n.env");
ensureGbrainSourceGitignored(root);
expect(readFileSync(gitignorePath, "utf-8")).toBe(
"node_modules\n.env\n.gbrain-source\n",
);
});
it("appends the pin entry to an existing .gitignore with trailing newline", () => {
const gitignorePath = join(root, ".gitignore");
writeFileSync(gitignorePath, "node_modules\n.env\n");
ensureGbrainSourceGitignored(root);
expect(readFileSync(gitignorePath, "utf-8")).toBe(
"node_modules\n.env\n.gbrain-source\n",
);
});
it("is idempotent: does not duplicate the pin entry on a second call", () => {
const gitignorePath = join(root, ".gitignore");
writeFileSync(gitignorePath, "node_modules\n.gbrain-source\n.env\n");
ensureGbrainSourceGitignored(root);
ensureGbrainSourceGitignored(root);
const lines = readFileSync(gitignorePath, "utf-8").split("\n");
const hits = lines.filter((line) => line.trim() === ".gbrain-source");
expect(hits.length).toBe(1);
});
it("recognizes the entry even when it has surrounding whitespace", () => {
const gitignorePath = join(root, ".gitignore");
writeFileSync(gitignorePath, "node_modules\n .gbrain-source \n");
ensureGbrainSourceGitignored(root);
const lines = readFileSync(gitignorePath, "utf-8").split("\n");
const hits = lines.filter((line) => line.trim() === ".gbrain-source");
expect(hits.length).toBe(1);
});
it("does not throw when the .gitignore is read-only", () => {
const gitignorePath = join(root, ".gitignore");
writeFileSync(gitignorePath, "node_modules\n");
const originalMode = statSync(gitignorePath).mode;
chmodSync(gitignorePath, 0o444);
try {
// Must not throw — sync stage continues on write failure.
expect(() => ensureGbrainSourceGitignored(root)).not.toThrow();
} finally {
chmodSync(gitignorePath, originalMode);
}
});
});