Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/supervisor/git/checkpointService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 <test@example.com>",
);

await service.restore({
threadId: "thread-1",
Expand All @@ -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 <checkpoints@poracode.local>",
);
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();
Expand Down
58 changes: 55 additions & 3 deletions src/supervisor/git/checkpointService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string>,
): Promise<string> {
// 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<string> {
try {
return (await execGit(projectLocation, ["rev-parse", "--verify", "HEAD^{tree}"])).trim();
Expand Down
30 changes: 28 additions & 2 deletions src/supervisor/wsl/bridge/bridge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" };
Expand Down Expand Up @@ -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 {
Expand Down
34 changes: 34 additions & 0 deletions src/supervisor/wsl/bridge/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <checkpoints@poracode.local>",
);
} finally {
await identityBridge.dispose();
}
});

it("runs structured git batches without a shell", async () => {
git(projectRoot, "init");
git(projectRoot, "config", "user.email", "test@example.com");
Expand Down