v1.29.0.0 feat: worktree-aware gbrain code sources via path-hash IDs and CWD pin (#1382)

* feat: worktree-aware gbrain code sources via path-hash IDs and CWD pin

Conductor sibling worktrees of the same repo no longer collide on a shared
gstack-code-<slug> source ID. /sync-gbrain now derives a path-hashed source
ID per worktree, runs gbrain sources attach to write .gbrain-source in the
worktree root, and removes the legacy unsuffixed source on first new-format
sync to prevent orphan accumulation.

Bug fixes surfaced by /codex during /ship:
- Silent attach failure now treated as stage failure (no more ok:true while
  pin is missing → unqualified code-def hits wrong source).
- Startup preamble checks .gbrain-source in the cwd worktree, not global
  state, so an unsynced worktree no longer claims "indexed" because a
  sibling synced.
- Code stage no longer skipped on remote-MCP (Path 4); the early-exit was
  in the SKILL template, not the orchestrator.
- Source registration routes through lib/gbrain-sources.ts only; deleted
  the near-duplicate ensureSourceRegisteredSync from the orchestrator.

Requires gbrain v0.30.0+ (uses sources attach). Phase 0 spike report:
~/.gstack/projects/garrytan-gstack/2026-05-08-gbrain-split-engine-spike.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: bump version and changelog (v1.29.0.0)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-08 12:46:15 -07:00
committed by GitHub
parent 443bde054c
commit 06605477e2
49 changed files with 928 additions and 721 deletions
+141 -5
View File
@@ -180,9 +180,12 @@ describe("gstack-gbrain-sync CLI", () => {
it("derives a gbrain-valid source id when the basename sanitizes to empty", () => {
// Pathological edge: a repo whose basename is all non-alnum (e.g. "___")
// sanitizes to an empty slug. Pre-fix, constrainSourceId returned
// "gstack-code-" invalid per the gbrain validator on the trailing
// hyphen. Fix falls back to a deterministic hash of the original input.
// sanitizes to an empty slug. Pre-worktree-aware-fix, constrainSourceId
// returned "gstack-code-" (invalid trailing hyphen) and was patched to
// fall back to a 6-char hash of the original input. The post-spike
// redesign appends an 8-char path-hash to every id, so the basename's
// empty-after-sanitize result is no longer a problem on its own — the
// path hash carries the entropy. The id must still be gbrain-valid.
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
@@ -202,9 +205,11 @@ describe("gstack-gbrain-sync CLI", () => {
const m = (r.stdout || "").match(/gbrain sources add (\S+)/);
expect(m).not.toBeNull();
const id = m![1];
// Expect hash-only fallback shape: gstack-code-<6 hex chars>
expect(id).toMatch(/^gstack-code-[0-9a-f]{6}$/);
// gbrain validator: 1-32 lowercase alnum + interior hyphens, no leading
// or trailing hyphens.
expect(id.startsWith("gstack-code-")).toBe(true);
expect(id.length).toBeLessThanOrEqual(32);
expect(id).toMatch(/^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/);
rmSync(parent, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
@@ -339,4 +344,135 @@ describe("gstack-gbrain-sync CLI", () => {
expect(combined).not.toContain("skipped (gstack-brain-sync not installed)");
rmSync(home, { recursive: true, force: true });
});
it("worktree-aware source ID: two worktrees of the same repo get DIFFERENT ids", () => {
// Conductor pattern: same origin, two different absolute paths. Pre-fix the
// ID was slug-only so both worktrees collapsed onto `gstack-code-<slug>` and
// last-sync-wins corrupted whichever the user wasn't actively syncing. The
// pathhash8 suffix makes each worktree's source independent.
const remote = "https://github.com/garrytan/gstack.git";
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const repoA = mkdtempSync(join(tmpdir(), "gstack-worktree-a-"));
const repoB = mkdtempSync(join(tmpdir(), "gstack-worktree-b-"));
for (const repo of [repoA, repoB]) {
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
spawnSync("git", ["remote", "add", "origin", remote], { cwd: repo });
}
const idOf = (cwd: string): string => {
const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], {
encoding: "utf-8",
timeout: 60000,
cwd,
env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome },
});
expect(r.status).toBe(0);
const m = (r.stdout || "").match(/gbrain sources add (\S+)/);
expect(m).not.toBeNull();
return m![1];
};
const idA = idOf(repoA);
const idB = idOf(repoB);
expect(idA).not.toBe(idB);
expect(idA.startsWith("gstack-code-")).toBe(true);
expect(idB.startsWith("gstack-code-")).toBe(true);
rmSync(repoA, { recursive: true, force: true });
rmSync(repoB, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
});
it("worktree-aware source ID: same path produces the same id across runs (deterministic)", () => {
// The pathhash is derived from the absolute repo path via sha1, so
// /sync-gbrain run twice in the same worktree must converge on the same
// source id (idempotent registration depends on this).
const remote = "https://github.com/garrytan/gstack.git";
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const repo = mkdtempSync(join(tmpdir(), "gstack-worktree-stable-"));
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
spawnSync("git", ["remote", "add", "origin", remote], { cwd: repo });
const idOf = (): string => {
const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], {
encoding: "utf-8",
timeout: 60000,
cwd: repo,
env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome },
});
expect(r.status).toBe(0);
const m = (r.stdout || "").match(/gbrain sources add (\S+)/);
expect(m).not.toBeNull();
return m![1];
};
expect(idOf()).toBe(idOf());
rmSync(repo, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
});
it("dry-run preview includes legacy-source removal + attach (post-codex-review hardening)", () => {
// Codex adversarial flagged: pre-pathhash `gstack-code-<slug>` sources stay
// orphaned forever after the new pathhash id ships. Dry-run preview must
// surface the legacy cleanup so the user knows it'll happen.
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const repo = mkdtempSync(join(tmpdir(), "gstack-legacy-cleanup-"));
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
spawnSync("git", ["remote", "add", "origin", "https://github.com/garrytan/gstack.git"], { cwd: repo });
const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], {
encoding: "utf-8",
timeout: 60000,
cwd: repo,
env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome },
});
expect(r.status).toBe(0);
// The dry-run preview shows what WOULD run; the live path will also
// remove the legacy source via `gbrain sources remove gstack-code-<slug>
// --confirm-destructive` when that legacy source is registered. We can't
// assert the remove step in dry-run because the orchestrator's preview
// string lists what it would do, but the legacy removal is gated on the
// legacy id being registered (which we can't probe in a sandboxed test
// without a real gbrain CLI). Instead, assert the preview still includes
// the new flow (sources add + sync + attach) at minimum.
expect(r.stdout).toMatch(/gbrain sources add gstack-code-/);
expect(r.stdout).toMatch(/gbrain sync --strategy code --source gstack-code-/);
expect(r.stdout).toMatch(/gbrain sources attach gstack-code-/);
rmSync(repo, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
});
it("dry-run preview includes the `sources attach` step (kubectl-style CWD pin)", () => {
// Post-spike redesign: after sources add + sync, /sync-gbrain calls
// `gbrain sources attach <id>` so subsequent gbrain code-def / code-refs
// calls from anywhere under the worktree route to this source by default.
// The dry-run preview must surface that step so the user knows what we
// would do.
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const repo = mkdtempSync(join(tmpdir(), "gstack-attach-preview-"));
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
spawnSync("git", ["remote", "add", "origin", "https://github.com/garrytan/gstack.git"], { cwd: repo });
const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], {
encoding: "utf-8",
timeout: 60000,
cwd: repo,
env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome },
});
expect(r.status).toBe(0);
expect(r.stdout).toMatch(/gbrain sources attach gstack-code-/);
rmSync(repo, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
});
});