From ec76d8dacd8c500d8c59d26c463e9c3760d8a418 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Thu, 27 Aug 2026 00:32:56 -0700 Subject: [PATCH] fix(git): fall back when checkpoint identity is missing - Retry checkpoint commits with a Poracode identity - Apply fallback behavior to the WSL bridge and bump its version - Add coverage for identity-free repositories --- src/supervisor/git/checkpointService.test.ts | 54 ++++++++++++++++++ src/supervisor/git/checkpointService.ts | 58 +++++++++++++++++++- src/supervisor/wsl/bridge/bridge.mjs | 30 +++++++++- src/supervisor/wsl/bridge/bridge.test.ts | 34 ++++++++++++ 4 files changed, 171 insertions(+), 5 deletions(-) diff --git a/src/supervisor/git/checkpointService.test.ts b/src/supervisor/git/checkpointService.test.ts index 4519d7817..98028b0a3 100644 --- a/src/supervisor/git/checkpointService.test.ts +++ b/src/supervisor/git/checkpointService.test.ts @@ -47,6 +47,27 @@ function git(cwd: string, ...args: string[]): string { return execFileSync("git", args, { cwd, encoding: "utf8" }); } +/** + * Hide the developer's own global/system git identity so the repository really + * has none, mirroring a fresh machine. Returns a restore callback. + */ +function hideGitIdentityConfig(dir: string): () => void { + const previous = { + GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL, + GIT_CONFIG_SYSTEM: process.env.GIT_CONFIG_SYSTEM, + GIT_CONFIG_NOSYSTEM: process.env.GIT_CONFIG_NOSYSTEM, + }; + process.env.GIT_CONFIG_GLOBAL = join(dir, "absent-global-config"); + process.env.GIT_CONFIG_SYSTEM = join(dir, "absent-system-config"); + process.env.GIT_CONFIG_NOSYSTEM = "1"; + return () => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }; +} + describe.skipIf(!hasGit())("GitCheckpointService", () => { it("captures turn snapshots and restores tracked plus untracked files", async () => { const { dir, location } = makeRepo(); @@ -70,6 +91,9 @@ describe.skipIf(!hasGit())("GitCheckpointService", () => { expect(after.baseRef).toBe(before.ref); expect(after.changedFiles.map((file) => file.path).sort()).toEqual(["README.md", "new.txt"]); + expect(git(dir, "log", "-1", "--format=%an <%ae>", before.ref).trim()).toBe( + "Poracode Test ", + ); await service.restore({ threadId: "thread-1", @@ -95,6 +119,36 @@ describe.skipIf(!hasGit())("GitCheckpointService", () => { ]); }, 45_000); + it("snapshots a repository that has no configured git identity", async () => { + const { dir, location } = makeRepo(); + // Simulate a machine with no user.name/user.email anywhere: dropping the + // local values plus `user.useConfigOnly` makes git refuse to auto-detect, + // which is exactly the "Author identity unknown" failure reported in the wild. + git(dir, "config", "--unset", "user.email"); + git(dir, "config", "--unset", "user.name"); + git(dir, "config", "user.useConfigOnly", "true"); + const service = new GitCheckpointService(); + const restoreEnv = hideGitIdentityConfig(dir); + + try { + writeFileSync(join(dir, "new.txt"), "new\n"); + const checkpoint = await service.create({ + threadId: "thread-1", + checkpointItemId: "user-1", + projectLocation: location, + }); + + expect(git(dir, "log", "-1", "--format=%an <%ae>", checkpoint.ref).trim()).toBe( + "Poracode ", + ); + await expect( + service.list({ threadId: "thread-1", projectLocation: location }), + ).resolves.toMatchObject({ checkpoints: [{ ref: checkpoint.ref }] }); + } finally { + restoreEnv(); + } + }, 45_000); + it("reports a missing base checkpoint without surfacing raw git ref errors", async () => { const { location } = makeRepo(); const service = new GitCheckpointService(); diff --git a/src/supervisor/git/checkpointService.ts b/src/supervisor/git/checkpointService.ts index d6f78021a..391db23f7 100644 --- a/src/supervisor/git/checkpointService.ts +++ b/src/supervisor/git/checkpointService.ts @@ -16,6 +16,31 @@ const LEGACY_REF_ROOT = "refs/lightcode/checkpoints"; type CheckpointMetadata = FileCheckpointRecord | FileCheckpointTurn; +/** + * Checkpoints are internal, never-published commits, so a repository without a + * configured `user.name`/`user.email` must still be able to snapshot. These env + * vars outrank config, so they are applied ONLY as a retry after git reports a + * missing identity — a configured identity keeps authoring its own snapshots. + */ +export const CHECKPOINT_FALLBACK_IDENT_ENV: Record = { + GIT_AUTHOR_NAME: "Poracode", + GIT_AUTHOR_EMAIL: "checkpoints@poracode.local", + GIT_COMMITTER_NAME: "Poracode", + GIT_COMMITTER_EMAIL: "checkpoints@poracode.local", +}; + +const MISSING_IDENTITY_RE = + /identity unknown|unable to auto-detect email|empty ident name|no name was given|no email was given/i; + +export function isMissingGitIdentityError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const parts = [ + "message" in error ? String((error as { message: unknown }).message ?? "") : "", + "stderr" in error ? String((error as { stderr: unknown }).stderr ?? "") : "", + ]; + return parts.some((part) => MISSING_IDENTITY_RE.test(part)); +} + export function buildCheckpointCommitInput( tree: string, head: string | null, @@ -173,9 +198,7 @@ export class GitCheckpointService { const tree = (await execGit(projectLocation, ["write-tree"], { env })).trim(); const head = await resolveHeadCommit(projectLocation); const commitInput = buildCheckpointCommitInput(tree, head, { ...metadata, ref }); - const commit = ( - await execGit(projectLocation, commitInput.args, { env, input: commitInput.input }) - ).trim(); + const commit = (await commitCheckpointTree(projectLocation, commitInput, env)).trim(); await execGit(projectLocation, ["update-ref", ref, commit]); return { threadId: metadata.threadId, @@ -216,6 +239,35 @@ export class GitCheckpointService { } } +/** + * Run `commit-tree`, retrying once with a fallback identity when the repository + * (and the user's global config) provides none. Without the retry every + * checkpoint fails with "Author identity unknown" on a fresh machine. + */ +async function commitCheckpointTree( + projectLocation: ProjectLocation, + commitInput: { args: string[]; input: string }, + env: Record, +): Promise { + // Force English error text so isMissingGitIdentityError() can match it + // regardless of the machine's system locale. + const identityCheckEnv = { ...env, LC_ALL: "C" }; + try { + return await execGit(projectLocation, commitInput.args, { + env: identityCheckEnv, + input: commitInput.input, + }); + } catch (error) { + if (!isMissingGitIdentityError(error) && !isMissingGitIdentityError((error as Error)?.cause)) { + throw error; + } + return await execGit(projectLocation, commitInput.args, { + env: { ...env, ...CHECKPOINT_FALLBACK_IDENT_ENV }, + input: commitInput.input, + }); + } +} + async function resolveHeadTree(projectLocation: ProjectLocation): Promise { try { return (await execGit(projectLocation, ["rev-parse", "--verify", "HEAD^{tree}"])).trim(); diff --git a/src/supervisor/wsl/bridge/bridge.mjs b/src/supervisor/wsl/bridge/bridge.mjs index ebce5f0a0..1932db537 100644 --- a/src/supervisor/wsl/bridge/bridge.mjs +++ b/src/supervisor/wsl/bridge/bridge.mjs @@ -51,7 +51,7 @@ import { isAbsolute, normalize, resolve as resolvePath } from "node:path/posix"; import { createRequire } from "node:module"; // Bumped on every behavioural change. Windows side reads this via regex. -const BRIDGE_VERSION = "2.13.0"; +const BRIDGE_VERSION = "2.14.0"; /** * Lazily loads `@parcel/watcher` (staged next to this script as @@ -884,6 +884,32 @@ async function processBatchHandler(req, body) { return { status: 200, data: { results } }; } +// Checkpoints are internal, never-published commits, so a repo (or distro) with +// no configured `user.name`/`user.email` must still snapshot. These env vars +// outrank config, so they are only applied as a retry after git reports a +// missing identity — a configured identity keeps authoring its own snapshots. +const CHECKPOINT_FALLBACK_IDENT_ENV = { + GIT_AUTHOR_NAME: "Poracode", + GIT_AUTHOR_EMAIL: "checkpoints@poracode.local", + GIT_COMMITTER_NAME: "Poracode", + GIT_COMMITTER_EMAIL: "checkpoints@poracode.local", +}; + +const MISSING_IDENTITY_RE = + /identity unknown|unable to auto-detect email|empty ident name|no name was given|no email was given/i; + +function commitCheckpointTree(args, cwd, env, input) { + // Force English error text so MISSING_IDENTITY_RE can match it regardless + // of the distro's system locale. + try { + return git(args, cwd, { ...env, LC_ALL: "C" }, input); + } catch (err) { + const text = `${err?.stderr ?? ""} ${err?.message ?? ""}`; + if (!MISSING_IDENTITY_RE.test(text)) throw err; + return git(args, cwd, { ...env, ...CHECKPOINT_FALLBACK_IDENT_ENV }, input); + } +} + function gitCheckpointSnapshotHandler(req, body) { const projectRoot = resolveSafePath(body.projectRoot, body.projectRoot); if (!projectRoot) return { status: 400, code: "ESCAPE", message: "projectRoot is invalid" }; @@ -911,7 +937,7 @@ function gitCheckpointSnapshotHandler(req, body) { const head = gitMaybe(["rev-parse", "--verify", "HEAD"], projectRoot); const commitArgs = ["commit-tree", tree, ...(head ? ["-p", head] : []), "-F", "-"]; const message = `Poracode checkpoint\n\n${JSON.stringify(body.metadata)}\n`; - const commit = git(commitArgs, projectRoot, env, message).trim(); + const commit = commitCheckpointTree(commitArgs, projectRoot, env, message).trim(); git(["update-ref", body.ref, commit], projectRoot); return { status: 200, data: { commit } }; } finally { diff --git a/src/supervisor/wsl/bridge/bridge.test.ts b/src/supervisor/wsl/bridge/bridge.test.ts index 9a334894f..59f8a1d43 100644 --- a/src/supervisor/wsl/bridge/bridge.test.ts +++ b/src/supervisor/wsl/bridge/bridge.test.ts @@ -386,6 +386,40 @@ describeOnPosix("bridge.mjs fs endpoints", () => { ).toBe(false); }); + it("falls back to a Poracode identity when the repository has no git identity", async () => { + git(projectRoot, "init"); + // Fresh distros can have no user.name/user.email in any config scope. + // Route the bridge's own global/system config at nonexistent files so it + // sees exactly that, instead of inheriting the host's real identity. + const identityBridge = await startBridge({ + GIT_CONFIG_GLOBAL: join(projectRoot, "absent-global-config"), + GIT_CONFIG_SYSTEM: join(projectRoot, "absent-system-config"), + GIT_CONFIG_NOSYSTEM: "1", + }); + try { + const metadata = { + threadId: "thread-1", + checkpointItemId: "user-1", + capturedAt: "2026-05-16T00:00:00.000Z", + ref: "refs/poracode/checkpoints/dGhyZWFkLTE/dXNlci0x", + }; + const { status, body } = await post(`${identityBridge.baseUrl}/v1/git/checkpoint-snapshot`, { + projectRoot, + ref: metadata.ref, + metadata, + }); + + expect(status).toBe(200); + const envelope = body as { ok: boolean; data: { commit: string } }; + expect(envelope.ok).toBe(true); + expect(git(projectRoot, "log", "-1", "--format=%an <%ae>", envelope.data.commit).trim()).toBe( + "Poracode ", + ); + } finally { + await identityBridge.dispose(); + } + }); + it("runs structured git batches without a shell", async () => { git(projectRoot, "init"); git(projectRoot, "config", "user.email", "test@example.com");