Skip to content

Commit 89adb23

Browse files
committed
Use the operator git identity for session checkpoints
Cycle commits shell out to system git with a synthetic harness author, so operator commit-author hooks reject every tool cycle. Using global user.name and user.email when both are set makes those hooks see a real identity.
1 parent 496d621 commit 89adb23

3 files changed

Lines changed: 147 additions & 14 deletions

File tree

docs/IMPLEMENTATION.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,10 @@ Session runtime state lives under the global projects tree (not in the repo):
358358

359359
`createOptimizedContextStore` (`src/session/optimized-context-store.ts`) wraps the
360360
Interchange git store to keep per-checkpoint cost independent of session length.
361+
Checkpoint commits go through system git and use the operator's global
362+
`user.name` / `user.email` when both are set, so commit-author hooks see a real
363+
identity; otherwise they fall back to Interchange's harness author
364+
(`interchange-harness`, `harness@interchange.local`).
361365
The append-only snapshots (`turns.jsonl`, `prompt.jsonl`) are written as rolling
362366
segments (`turns-0001.jsonl`, ...) that seal at 256KB, so `git add` re-hashes only
363367
the small active segment instead of the whole growing file. Segment zero keeps the

src/session/optimized-context-store.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import fs from "node:fs";
33
import os from "node:os";
44
import path from "node:path";
55
import type { ConversationTurn } from "@intx/types/runtime";
6-
import { createOptimizedContextStore, loadRecentTurns } from "./optimized-context-store.js";
6+
import {
7+
createOptimizedContextStore,
8+
loadRecentTurns,
9+
resolveCheckpointAuthor,
10+
} from "./optimized-context-store.js";
711
import { segmentFileName, listSegmentFiles } from "./incremental-jsonl.js";
812

913
const TURNS_FILE = "turns.jsonl";
@@ -12,6 +16,39 @@ function tempDir(): string {
1216
return fs.mkdtempSync(path.join(os.tmpdir(), "opt-store-"));
1317
}
1418

19+
function isolatedGitEnv(gitconfig: string): NodeJS.ProcessEnv {
20+
const dir = tempDir();
21+
const config = path.join(dir, "gitconfig");
22+
fs.writeFileSync(config, gitconfig);
23+
return {
24+
...process.env,
25+
GIT_CONFIG_GLOBAL: config,
26+
GIT_CONFIG_SYSTEM: "/dev/null",
27+
GIT_CONFIG_NOSYSTEM: "1",
28+
HOME: dir,
29+
};
30+
}
31+
32+
async function headAuthor(dir: string): Promise<{ name: string; email: string }> {
33+
const proc = Bun.spawn(["git", "-C", dir, "log", "-1", "--format=%an%n%ae"], {
34+
stdout: "pipe",
35+
stderr: "pipe",
36+
});
37+
const [exitCode, stdout, stderr] = await Promise.all([
38+
proc.exited,
39+
new Response(proc.stdout).text(),
40+
new Response(proc.stderr).text(),
41+
]);
42+
if (exitCode !== 0) {
43+
throw new Error(`git log failed: ${stderr.trim() || stdout.trim()}`);
44+
}
45+
const [name, email] = stdout.trimEnd().split("\n");
46+
if (name === undefined || email === undefined) {
47+
throw new Error(`unexpected git log author output: ${JSON.stringify(stdout)}`);
48+
}
49+
return { name, email };
50+
}
51+
1552
function turn(text: string): ConversationTurn {
1653
return { role: "user", content: [{ type: "text", text }], timestamp: 1 };
1754
}
@@ -472,4 +509,52 @@ describe("createOptimizedContextStore checkpoint", () => {
472509
const atHead = await store.readAt(head.hash);
473510
expect(atHead).toHaveLength(total);
474511
}, 20_000);
512+
513+
test("records the operator identity on the cycle commit", async () => {
514+
const dir = tempDir();
515+
const store = await createOptimizedContextStore(dir, {
516+
author: { name: "Sawyer", email: "sawyer@dirtroad.dev" },
517+
});
518+
await store.writeMetadata({
519+
pendingOperations: [],
520+
tokenUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 },
521+
});
522+
await store.commit({ message: "checkpoint: tool-execution" });
523+
524+
expect(await headAuthor(dir)).toEqual({
525+
name: "Sawyer",
526+
email: "sawyer@dirtroad.dev",
527+
});
528+
});
529+
});
530+
531+
describe("resolveCheckpointAuthor", () => {
532+
test("uses global user.name and user.email when both are set", async () => {
533+
const env = isolatedGitEnv(`[user]\n\tname = Sawyer\n\temail = sawyer@dirtroad.dev\n`);
534+
await expect(resolveCheckpointAuthor(env)).resolves.toEqual({
535+
name: "Sawyer",
536+
email: "sawyer@dirtroad.dev",
537+
});
538+
});
539+
540+
test("falls back to the harness identity when global config is missing", async () => {
541+
const env = isolatedGitEnv("");
542+
await expect(resolveCheckpointAuthor(env)).resolves.toEqual({
543+
name: "interchange-harness",
544+
email: "harness@interchange.local",
545+
});
546+
});
547+
548+
test("falls back when only one of name or email is set", async () => {
549+
const nameOnly = isolatedGitEnv(`[user]\n\tname = Sawyer\n`);
550+
const emailOnly = isolatedGitEnv(`[user]\n\temail = sawyer@dirtroad.dev\n`);
551+
await expect(resolveCheckpointAuthor(nameOnly)).resolves.toEqual({
552+
name: "interchange-harness",
553+
email: "harness@interchange.local",
554+
});
555+
await expect(resolveCheckpointAuthor(emailOnly)).resolves.toEqual({
556+
name: "interchange-harness",
557+
email: "harness@interchange.local",
558+
});
559+
});
475560
});

src/session/optimized-context-store.ts

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,16 @@ const TOOL_OUTPUT_DIR = "tool-output";
2929

3030
const log = getLogger([LOG_NAMESPACE_ROOT, "session", "context-store"]);
3131

32-
const AUTHOR = {
32+
export type CheckpointAuthor = {
33+
name: string;
34+
email: string;
35+
};
36+
37+
// Cycle commits shell out to system git, so operator commit-author hooks see
38+
// this identity. Prefer their global git user when both name and email are
39+
// set; otherwise keep Interchange's harness fallback so machines without a
40+
// git identity still checkpoint.
41+
const HARNESS_AUTHOR: CheckpointAuthor = {
3342
name: "interchange-harness",
3443
email: "harness@interchange.local",
3544
};
@@ -299,16 +308,20 @@ export async function loadRecentTurns(dir: string, minTurns: number): Promise<Co
299308
return turns;
300309
}
301310

302-
async function runGit(dir: string, args: string[]): Promise<string> {
311+
async function runGit(dir: string, args: string[], author?: CheckpointAuthor): Promise<string> {
303312
const proc = Bun.spawn(["git", "-C", dir, ...args], {
304313
stdout: "pipe",
305314
stderr: "pipe",
306315
env: {
307316
...process.env,
308-
GIT_AUTHOR_NAME: AUTHOR.name,
309-
GIT_AUTHOR_EMAIL: AUTHOR.email,
310-
GIT_COMMITTER_NAME: AUTHOR.name,
311-
GIT_COMMITTER_EMAIL: AUTHOR.email,
317+
...(author === undefined
318+
? {}
319+
: {
320+
GIT_AUTHOR_NAME: author.name,
321+
GIT_AUTHOR_EMAIL: author.email,
322+
GIT_COMMITTER_NAME: author.name,
323+
GIT_COMMITTER_EMAIL: author.email,
324+
}),
312325
},
313326
});
314327
const [exitCode, stdout, stderr] = await Promise.all([
@@ -322,6 +335,34 @@ async function runGit(dir: string, args: string[]): Promise<string> {
322335
return stdout.trimEnd();
323336
}
324337

338+
async function gitConfigGlobal(key: string, env: NodeJS.ProcessEnv): Promise<string | null> {
339+
const proc = Bun.spawn(["git", "config", "--global", "--get", key], {
340+
stdout: "pipe",
341+
stderr: "pipe",
342+
env,
343+
});
344+
const [exitCode, stdout] = await Promise.all([proc.exited, new Response(proc.stdout).text()]);
345+
if (exitCode !== 0) return null;
346+
const value = stdout.trim();
347+
return value.length > 0 ? value : null;
348+
}
349+
350+
/**
351+
* Author for Corbits cycle commits. Uses the operator's global git identity
352+
* when both `user.name` and `user.email` are set; otherwise the Interchange
353+
* harness identity.
354+
*/
355+
export async function resolveCheckpointAuthor(
356+
env: NodeJS.ProcessEnv = process.env,
357+
): Promise<CheckpointAuthor> {
358+
const [name, email] = await Promise.all([
359+
gitConfigGlobal("user.name", env),
360+
gitConfigGlobal("user.email", env),
361+
]);
362+
if (name === null || email === null) return HARNESS_AUTHOR;
363+
return { name, email };
364+
}
365+
325366
/**
326367
* Names of the tail turn segments (`turns-0001.jsonl`, ...) present in a commit
327368
* tree, in segment order. The base store reads the zeroth segment itself; these
@@ -395,7 +436,11 @@ async function reconcileSegmentStaging(
395436
* segment files so `git add` re-hashes only the small active segment, and only
396437
* spilled tool-output blobs that are new since the last commit are staged.
397438
*/
398-
export async function createOptimizedContextStore(dir: string): Promise<ContextStore> {
439+
export async function createOptimizedContextStore(
440+
dir: string,
441+
opts?: { author?: CheckpointAuthor },
442+
): Promise<ContextStore> {
443+
const author = opts?.author ?? (await resolveCheckpointAuthor());
399444
const base = await createIsogitStore(dir);
400445
const pendingBlobFilepaths = new Set<string>();
401446
const pendingSegmentPaths = new Set<string>();
@@ -550,12 +595,11 @@ export async function createOptimizedContextStore(dir: string): Promise<ContextS
550595
if (remove.length > 0) {
551596
await runGit(dir, ["rm", "--cached", "--ignore-unmatch", "--", ...remove]);
552597
}
553-
await runGit(dir, [
554-
"commit",
555-
"-m",
556-
options.message,
557-
`--author=${AUTHOR.name} <${AUTHOR.email}>`,
558-
]);
598+
await runGit(
599+
dir,
600+
["commit", "-m", options.message, `--author=${author.name} <${author.email}>`],
601+
author,
602+
);
559603
pendingBlobFilepaths.clear();
560604
pendingSegmentPaths.clear();
561605
return describeHead(dir, options.message);

0 commit comments

Comments
 (0)