mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-09 22:57:33 +02:00
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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user