diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 732ee430c..aa62b2e6f 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -459,6 +459,13 @@ async function runCodeImport(args: CliArgs): Promise { }; } + // 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, @@ -475,6 +482,39 @@ async function runCodeImport(args: CliArgs): Promise { }; } +/** + * 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(); @@ -674,8 +714,10 @@ async function main(): Promise { process.exit(exitCode); } -main().catch((err) => { - console.error(`gstack-gbrain-sync fatal: ${err instanceof Error ? err.message : String(err)}`); - releaseLock(); - process.exit(1); -}); +if (import.meta.main) { + main().catch((err) => { + console.error(`gstack-gbrain-sync fatal: ${err instanceof Error ? err.message : String(err)}`); + releaseLock(); + process.exit(1); + }); +} diff --git a/test/gbrain-source-gitignore.test.ts b/test/gbrain-source-gitignore.test.ts new file mode 100644 index 000000000..1fd1db05e --- /dev/null +++ b/test/gbrain-source-gitignore.test.ts @@ -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); + } + }); +});