fix(gbrain-sync): sourceLocalPath handles wrapped {sources:[...]} shape from gbrain v0.20+

gbrain v0.20+ changed `gbrain sources list --json` to return
{sources: [...]} instead of a flat array. sourceLocalPath crashed
upstream with `list.find is not a function` on every /sync-gbrain
invocation against modern gbrain. Accept both shapes for
forward/backward compat, matching probeSource/sourcePageCount in
lib/gbrain-sources.ts.

Contributed by @jakehann11 via #1571. Closes #1567. Supersedes #1564
(@tonyjzhou, same fix, different shape — credit retained).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-05-18 20:32:37 -07:00
parent 0c7ef235ed
commit 59a9b841af
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
2 changed files with 34 additions and 2 deletions

View File

@ -287,13 +287,20 @@ function gbrainSupportsSourcesRename(env?: NodeJS.ProcessEnv): boolean {
* `env` is the environment passed to the spawned `gbrain` process; defaults
* to `process.env`. Tests inject a PATH that points at a gbrain shim so the
* helper can be exercised without a real gbrain CLI.
*
* Shape note: `gbrain sources list --json` returns `{sources: [...]}` (v0.20+);
* older versions returned a flat array. Accept both for forward/backward compat
* (mirrors `probeSource`/`sourcePageCount` in lib/gbrain-sources.ts).
*/
export function sourceLocalPath(sourceId: string, env?: NodeJS.ProcessEnv): string | null {
const list = execGbrainJson<Array<{ id: string; local_path?: string }>>(
const raw = execGbrainJson<unknown>(
["sources", "list", "--json"],
{ baseEnv: env },
);
if (!list) return null;
if (!raw) return null;
const list: Array<{ id?: string; local_path?: string }> = Array.isArray(raw)
? (raw as Array<{ id?: string; local_path?: string }>)
: ((raw as { sources?: Array<{ id?: string; local_path?: string }> }).sources ?? []);
const found = list.find((s) => s.id === sourceId);
return found?.local_path ?? null;
}

View File

@ -837,4 +837,29 @@ describe("sourceLocalPath", () => {
});
expect(sourceLocalPath("any-id", envWithBindir(bindir))).toBeNull();
});
// gbrain v0.20+ wraps the response as `{sources: [...]}`. Older versions
// returned a flat array. sourceLocalPath was returning null (or crashing
// with `list.find is not a function` upstream) because it only handled
// the flat-array shape. Pin both shapes here.
it("handles {sources: [...]} wrapped shape (gbrain v0.20+)", () => {
makeShim(bindir, {
"sources list --json": {
stdout: JSON.stringify({
sources: [
{ id: "other-source", local_path: "/x" },
{ id: "target-id", local_path: "/repo/match" },
],
}),
},
});
expect(sourceLocalPath("target-id", envWithBindir(bindir))).toBe("/repo/match");
});
it("returns null when the source is missing in the wrapped shape", () => {
makeShim(bindir, {
"sources list --json": { stdout: JSON.stringify({ sources: [] }) },
});
expect(sourceLocalPath("missing-id", envWithBindir(bindir))).toBeNull();
});
});