From 4f3e2e970a4048050bd6b14a00cc587d899ee810 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 6 May 2026 11:01:14 -0700 Subject: [PATCH] fix+test(gbrain-sync): handle empty-slug edge in constrainSourceId, add no-origin and basename-empty regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #1330 (merged in the prior commit) addressed the dot-in-host and length-overflow cases for source-id derivation, but constrainSourceId silently returned "${prefix}-" when the input sanitized to an empty slug — invalid per gbrain's `^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$` validator on the trailing hyphen. Adds an explicit empty-slug branch that falls back to a sha1-prefixed id ("gstack-code-<6hex>") so the output stays gbrain-valid for every input shape. Two new regression tests cover the corners PR #1330's coverage left exposed: - no-origin fallback: a cwd repo with no `origin` remote configured must still derive a valid id from the basename. - basename-sanitizes-to-empty: a repo whose path basename is all non-alnum (e.g. "___") must produce the hash-only fallback, not an invalid trailing-hyphen id. Both run the CLI inside temp git repos for genuine end-to-end coverage (matches the pattern PR #1330 established for its own four remote-shape cases). Co-Authored-By: Richard Dubach --- bin/gstack-gbrain-sync.ts | 8 +++++ test/gstack-gbrain-sync.test.ts | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 48563aa3..483e7264 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -188,6 +188,14 @@ function deriveCodeSourceId(repoPath: string): string { function constrainSourceId(prefix: string, raw: string): string { const MAX = 32; const slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + // Empty slug after sanitize (e.g. raw was all non-alnum like "___") would + // produce "${prefix}-" which fails gbrain's validator on the trailing + // hyphen. Fall back to a deterministic hash of the original input so the + // result is stable across runs of the same repo. + if (!slug) { + const hash = createHash("sha1").update(raw || "_empty").digest("hex").slice(0, 6); + return `${prefix}-${hash}`; + } const full = `${prefix}-${slug}`; if (full.length <= MAX) return full; const hash = createHash("sha1").update(slug).digest("hex").slice(0, 6); diff --git a/test/gstack-gbrain-sync.test.ts b/test/gstack-gbrain-sync.test.ts index 6693615d..2e0f7e5e 100644 --- a/test/gstack-gbrain-sync.test.ts +++ b/test/gstack-gbrain-sync.test.ts @@ -149,6 +149,67 @@ describe("gstack-gbrain-sync CLI", () => { } }); + it("derives a gbrain-valid source id when the cwd repo has NO origin remote", () => { + // Fallback path in deriveCodeSourceId(): no `origin` remote configured, + // so the slug comes from the repo basename. The fallback must still + // produce a gbrain-valid id (no dots, ≤32 chars, no trailing hyphen). + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + mkdirSync(gstackHome, { recursive: true }); + const repo = mkdtempSync(join(tmpdir(), "gstack-no-origin-")); + spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }); + // No `git remote add origin` — this is the no-remote case. + + 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(); + const id = m![1]; + 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(repo, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + }); + + 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. + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + mkdirSync(gstackHome, { recursive: true }); + const parent = mkdtempSync(join(tmpdir(), "gstack-empty-base-")); + const repo = join(parent, "___"); + mkdirSync(repo); + spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }); + // No `origin` remote — forces the basename-fallback path. + + 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(); + const id = m![1]; + // Expect hash-only fallback shape: gstack-code-<6 hex chars> + expect(id).toMatch(/^gstack-code-[0-9a-f]{6}$/); + expect(id.length).toBeLessThanOrEqual(32); + + rmSync(parent, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + }); + it("dry-run does NOT acquire the lock file (lock is for write paths only)", () => { const home = makeTestHome(); const gstackHome = join(home, ".gstack");