Skip to content

Commit 2a88eba

Browse files
authored
Merge pull request #1825 from entireio/feat/import-commit-link
feat(import): anchor imported checkpoints to the default branch head
2 parents 495b3a7 + bc53e6c commit 2a88eba

14 files changed

Lines changed: 1009 additions & 24 deletions

File tree

api/checkpoint/metadata.go

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ type WriteOptions struct {
3737
// Branch is the branch name where the checkpoint was created (empty if detached HEAD)
3838
Branch string
3939

40+
// CommitSHA links this checkpoint to an existing commit without a trailer.
41+
// It is an anchor — "imported at this point in time" — not attribution.
42+
// Currently set only by `entire import`: imported history has no
43+
// Entire-Checkpoint trailer (we never rewrite existing commits), so import
44+
// stamps the resolved anchor commit here (the default branch head when
45+
// resolvable; see resolveImportLinkCommitSHA for the fallback order).
46+
// Empty for all other writers. This comment is the canonical description;
47+
// Metadata.CommitSHA and CheckpointSummary.CommitSHA point back here.
48+
CommitSHA string
49+
4050
// Transcript is the session transcript content (full.jsonl).
4151
// Must be pre-redacted (via redact.JSONLBytes or redact.AlreadyRedacted for trusted sources).
4252
Transcript redact.RedactedBytes
@@ -324,13 +334,17 @@ type SessionContent struct {
324334

325335
// Metadata contains the metadata stored in metadata.json for each checkpoint.
326336
type Metadata struct {
327-
CLIVersion string `json:"cli_version,omitempty"`
328-
CheckpointID id.CheckpointID `json:"checkpoint_id"`
329-
SessionID string `json:"session_id"`
330-
Strategy string `json:"strategy"`
331-
CreatedAt time.Time `json:"created_at"`
332-
Branch string `json:"branch,omitempty"` // Branch where checkpoint was created (empty if detached HEAD)
333-
CheckpointsCount int `json:"checkpoints_count"`
337+
CLIVersion string `json:"cli_version,omitempty"`
338+
CheckpointID id.CheckpointID `json:"checkpoint_id"`
339+
SessionID string `json:"session_id"`
340+
Strategy string `json:"strategy"`
341+
CreatedAt time.Time `json:"created_at"`
342+
Branch string `json:"branch,omitempty"` // Branch where checkpoint was created (empty if detached HEAD)
343+
// CommitSHA anchors an imported checkpoint to an existing commit; empty for
344+
// non-imported checkpoints, which link via the Entire-Checkpoint trailer.
345+
// See WriteOptions.CommitSHA for the full semantics.
346+
CommitSHA string `json:"commit_sha,omitempty"`
347+
CheckpointsCount int `json:"checkpoints_count"`
334348
// SaveStepCount is the number of SaveStep-recorded steps for this session.
335349
// Honest "real checkpoint work happened" signal (0 = commit-only/fallback
336350
// session), kept separate from the displayed CheckpointsCount prompt count.
@@ -482,10 +496,12 @@ type SessionFilePaths struct {
482496
//
483497
//nolint:revive // Named CheckpointSummary to avoid conflict with existing Summary struct
484498
type CheckpointSummary struct {
485-
CLIVersion string `json:"cli_version,omitempty"`
486-
CheckpointID id.CheckpointID `json:"checkpoint_id"`
487-
Strategy string `json:"strategy"`
488-
Branch string `json:"branch,omitempty"`
499+
CLIVersion string `json:"cli_version,omitempty"`
500+
CheckpointID id.CheckpointID `json:"checkpoint_id"`
501+
Strategy string `json:"strategy"`
502+
Branch string `json:"branch,omitempty"`
503+
// CommitSHA: import-only anchor; see WriteOptions.CommitSHA.
504+
CommitSHA string `json:"commit_sha,omitempty"`
489505
CheckpointsCount int `json:"checkpoints_count"`
490506
FilesTouched []string `json:"files_touched"`
491507
Sessions []SessionFilePaths `json:"sessions"`

cmd/entire/cli/agentimport/agentimport.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ type Turn struct {
5454
// subagent's tokens are counted exactly once rather than re-added on every
5555
// turn after it is discovered.
5656
Tokens *types.TokenUsage
57+
// CommitSHAs are the commit SHAs (possibly abbreviated) this turn's
58+
// transcript records creating, in transcript order. Extraction is
59+
// per-importer (see commitSHAsInRange for Claude Code). Callers must
60+
// resolve them against the repo before use. Nil when the transcript
61+
// records no commits (including agents that don't extract them); the
62+
// anchor then falls back to Options.LinkCommitSHA.
63+
CommitSHAs []string
5764
}
5865

5966
// Importer is the per-agent seam: it locates an agent's transcripts for a repo
@@ -98,6 +105,14 @@ type Options struct {
98105
SessionFilter []string
99106
Now time.Time
100107
DryRun bool
108+
109+
// LinkCommitSHA, when non-empty, is the fallback anchor written to each
110+
// imported checkpoint's metadata as commit_sha — the commit the UI shows
111+
// imported sessions against. The caller resolves it (default branch head
112+
// when resolvable; see resolveImportLinkCommitSHA); Run does not. A turn
113+
// whose transcript records a resolvable commit that is an ancestor of this
114+
// fallback anchors to that real commit instead (see turnAnchorResolver).
115+
LinkCommitSHA string
101116
}
102117

103118
// Result summarizes an import run.
@@ -136,6 +151,11 @@ func Run(ctx context.Context, repo *git.Repository, imp Importer, opts Options)
136151
}
137152
}
138153

154+
// One resolver per Run: it lazily walks and memoizes opts.LinkCommitSHA's
155+
// ancestor set the first time a turn actually carries a candidate, so a
156+
// whole import run pays for at most one history walk, not one per turn.
157+
anchorResolver := newTurnAnchorResolver(repo, opts.LinkCommitSHA, opts.Now)
158+
139159
for _, sf := range files {
140160
res.SessionsScanned++
141161
full, readErr := os.ReadFile(sf.Path)
@@ -170,7 +190,12 @@ func Run(ctx context.Context, repo *git.Repository, imp Importer, opts Options)
170190
red = r
171191
redacted = true
172192
}
173-
if err := writeTurn(ctx, stores, imp, cid, sf, red, turn); err != nil {
193+
anchor, fromCandidate := anchorResolver.resolve(ctx, turn.CommitSHAs)
194+
if len(turn.CommitSHAs) > 0 && !fromCandidate {
195+
logging.Debug(ctx, "import: turn anchor fell back",
196+
"sessionID", sf.SessionID, "turnUUID", turn.UUID, "candidates", len(turn.CommitSHAs))
197+
}
198+
if err := writeTurn(ctx, stores, imp, cid, sf, red, turn, anchor); err != nil {
174199
return res, err
175200
}
176201
existing[cid.String()] = true
@@ -261,7 +286,7 @@ func writeSessionState(ctx context.Context, imp Importer, sf SessionFile, turns
261286
return nil
262287
}
263288

264-
func writeTurn(ctx context.Context, stores *cp.Stores, imp Importer, cid id.CheckpointID, sf SessionFile, red redact.RedactedBytes, turn Turn) error {
289+
func writeTurn(ctx context.Context, stores *cp.Stores, imp Importer, cid id.CheckpointID, sf SessionFile, red redact.RedactedBytes, turn Turn, anchorCommitSHA string) error {
265290
if err := stores.Persistent.Write(ctx, cp.Session(cp.WriteOptions{
266291
CheckpointID: cid,
267292
SessionID: sf.SessionID,
@@ -275,6 +300,7 @@ func writeTurn(ctx context.Context, stores *cp.Stores, imp Importer, cid id.Chec
275300
CheckpointsCount: 1,
276301
CheckpointTranscriptStart: turn.LineStart,
277302
TokenUsage: turn.Tokens,
303+
CommitSHA: anchorCommitSHA,
278304
})); err != nil {
279305
return fmt.Errorf("write imported checkpoint %s: %w", cid, err)
280306
}

cmd/entire/cli/agentimport/agentimport_test.go

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,10 @@ func initRepoWithCommit(t *testing.T) (*git.Repository, string) {
9090
t.Fatal(err)
9191
}
9292
if _, err := wt.Commit("init", &git.CommitOptions{
93-
Author: &object.Signature{Name: "Test", Email: "test@test.com"},
93+
// When must be a real timestamp: the anchor resolver's bounded walk
94+
// stops at commits older than its date cutoff, and a zero-value When
95+
// (year 1) would halt the walk at the first commit.
96+
Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
9497
}); err != nil {
9598
t.Fatal(err)
9699
}
@@ -152,6 +155,138 @@ func TestRun_ImportsAndIsIdempotent(t *testing.T) {
152155
}
153156
}
154157

158+
// TestRun_StampsLinkCommitSHA proves Options.LinkCommitSHA is copied verbatim
159+
// into each imported checkpoint's commit_sha metadata field, and that leaving
160+
// it unset leaves commit_sha empty. Run resolves nothing itself.
161+
func TestRun_StampsLinkCommitSHA(t *testing.T) {
162+
t.Parallel()
163+
repo, repoDir := initRepoWithCommit(t)
164+
const commitSHA = "b01b59663fd4860fd15a9939499be44a14dbf168"
165+
166+
claudeDirWithSHA := t.TempDir()
167+
writeFixtureSession(t, claudeDirWithSHA, "sess-with-sha.jsonl")
168+
res, err := Run(context.Background(), repo, claudeImporter{}, Options{
169+
RepoRoot: repoDir, OverridePath: claudeDirWithSHA,
170+
Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC),
171+
LinkCommitSHA: commitSHA,
172+
})
173+
if err != nil {
174+
t.Fatal(err)
175+
}
176+
if res.TurnsImported != 2 {
177+
t.Fatalf("want 2 imported, got %+v", res)
178+
}
179+
180+
stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{})
181+
if err != nil {
182+
t.Fatal(err)
183+
}
184+
cid := DeriveCheckpointID("sess-with-sha", "u1")
185+
md, err := stores.Persistent.ReadSessionMetadata(context.Background(), cid, 0)
186+
if err != nil {
187+
t.Fatal(err)
188+
}
189+
if md.CommitSHA != commitSHA {
190+
t.Fatalf("expected commit_sha %q, got %q", commitSHA, md.CommitSHA)
191+
}
192+
193+
// A separate session fixture (own sessionID/turn UUIDs) run with
194+
// LinkCommitSHA unset must persist an empty commit_sha. Reusing the same
195+
// session would be idempotently skipped, so this needs its own fixture.
196+
claudeDirNoSHA := t.TempDir()
197+
writeFixtureSession(t, claudeDirNoSHA, "sess-no-sha.jsonl")
198+
res2, err := Run(context.Background(), repo, claudeImporter{}, Options{
199+
RepoRoot: repoDir, OverridePath: claudeDirNoSHA,
200+
Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC),
201+
})
202+
if err != nil {
203+
t.Fatal(err)
204+
}
205+
if res2.TurnsImported != 2 {
206+
t.Fatalf("want 2 imported, got %+v", res2)
207+
}
208+
209+
cid2 := DeriveCheckpointID("sess-no-sha", "u1")
210+
md2, err := stores.Persistent.ReadSessionMetadata(context.Background(), cid2, 0)
211+
if err != nil {
212+
t.Fatal(err)
213+
}
214+
if md2.CommitSHA != "" {
215+
t.Fatalf("expected empty commit_sha, got %q", md2.CommitSHA)
216+
}
217+
}
218+
219+
// TestRun_AnchorsTurnToRecordedCommit proves a turn whose transcript records a
220+
// resolvable, default-branch-reachable commit anchors to that real commit
221+
// instead of the LinkCommitSHA fallback, while a turn with no recorded commit
222+
// still falls back exactly as before.
223+
func TestRun_AnchorsTurnToRecordedCommit(t *testing.T) {
224+
t.Parallel()
225+
repo, repoDir := initRepoWithCommit(t)
226+
firstCommit, err := repo.Head()
227+
if err != nil {
228+
t.Fatal(err)
229+
}
230+
firstSHA := firstCommit.Hash().String()
231+
232+
// Second commit on the default branch, so tip != first commit.
233+
wt, err := repo.Worktree()
234+
if err != nil {
235+
t.Fatal(err)
236+
}
237+
writeAndCommit(t, wt, repoDir, "y", "second")
238+
tipHead, err := repo.Head()
239+
if err != nil {
240+
t.Fatal(err)
241+
}
242+
tipSHA := tipHead.Hash().String()
243+
244+
claudeDir := t.TempDir()
245+
content := strings.Join([]string{
246+
`{"type":"user","uuid":"u1","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}`,
247+
`{"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"output_tokens":5}}}`,
248+
`{"type":"user","uuid":"tr1","toolUseResult":{"gitOperation":{"commit":{"sha":"` + firstSHA[:7] + `","kind":"committed"}}}}`,
249+
`{"type":"user","uuid":"u2","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}`,
250+
}, "\n") + "\n"
251+
if err := os.WriteFile(filepath.Join(claudeDir, "sess-anchor.jsonl"), []byte(content), 0o644); err != nil {
252+
t.Fatal(err)
253+
}
254+
255+
res, err := Run(context.Background(), repo, claudeImporter{}, Options{
256+
RepoRoot: repoDir, OverridePath: claudeDir,
257+
Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC),
258+
LinkCommitSHA: tipSHA,
259+
})
260+
if err != nil {
261+
t.Fatal(err)
262+
}
263+
if res.TurnsImported != 2 {
264+
t.Fatalf("want 2 imported, got %+v", res)
265+
}
266+
267+
stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{})
268+
if err != nil {
269+
t.Fatal(err)
270+
}
271+
cid1 := DeriveCheckpointID("sess-anchor", "u1")
272+
md1, err := stores.Persistent.ReadSessionMetadata(context.Background(), cid1, 0)
273+
if err != nil {
274+
t.Fatal(err)
275+
}
276+
if md1.CommitSHA != firstSHA {
277+
t.Fatalf("turn1 CommitSHA = %q, want recorded commit %q", md1.CommitSHA, firstSHA)
278+
}
279+
280+
cid2 := DeriveCheckpointID("sess-anchor", "u2")
281+
md2, err := stores.Persistent.ReadSessionMetadata(context.Background(), cid2, 0)
282+
if err != nil {
283+
t.Fatal(err)
284+
}
285+
if md2.CommitSHA != tipSHA {
286+
t.Fatalf("turn2 CommitSHA = %q, want fallback %q", md2.CommitSHA, tipSHA)
287+
}
288+
}
289+
155290
// TestRun_AppliesConfiguredCustomRedaction proves imported transcripts honor
156291
// repo/user-configured custom_redactions (loaded at the command via
157292
// strategy.EnsureRedactionConfigured), not just always-on secret scanning.

cmd/entire/cli/agentimport/claude.go

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,12 @@ func (claudeImporter) SplitTurns(sf SessionFile, full []byte) ([]Turn, error) {
5757
return nil, nil
5858
}
5959
return &Turn{
60-
UUID: rec.UUID,
61-
Prompt: transcript.ExtractUserContent(rec.Message),
62-
Model: modelInRange(rawLines, start, end),
63-
CreatedAt: parseTimestamp(rec.Timestamp),
64-
Tokens: tokens,
60+
UUID: rec.UUID,
61+
Prompt: transcript.ExtractUserContent(rec.Message),
62+
Model: modelInRange(rawLines, start, end),
63+
CreatedAt: parseTimestamp(rec.Timestamp),
64+
Tokens: tokens,
65+
CommitSHAs: commitSHAsInRange(rawLines, start, end),
6566
}, nil
6667
})
6768
}
@@ -98,6 +99,35 @@ func modelInRange(rawLines [][]byte, start, end int) string {
9899
return ""
99100
}
100101

102+
// commitSHAsInRange returns the commit SHAs recorded by gitOperation
103+
// tool-result records within [start, end), in order. Only kind "committed"
104+
// is collected — other kinds (or commit-less gitOperation records like
105+
// push/branch/pr) are ignored. SHAs may be abbreviated; resolution happens
106+
// in Run.
107+
func commitSHAsInRange(rawLines [][]byte, start, end int) []string {
108+
var shas []string
109+
for i := start; i < end && i < len(rawLines); i++ {
110+
var rec struct {
111+
ToolUseResult struct {
112+
GitOperation struct {
113+
Commit struct {
114+
SHA string `json:"sha"`
115+
Kind string `json:"kind"`
116+
} `json:"commit"`
117+
} `json:"gitOperation"`
118+
} `json:"toolUseResult"`
119+
}
120+
if err := json.Unmarshal(rawLines[i], &rec); err != nil {
121+
continue
122+
}
123+
c := rec.ToolUseResult.GitOperation.Commit
124+
if c.SHA != "" && c.Kind == "committed" {
125+
shas = append(shas, c.SHA)
126+
}
127+
}
128+
return shas
129+
}
130+
101131
// isUserPromptLine reports whether a raw JSONL line is a genuine user-prompt
102132
// turn start: type "user" (or role "user") with non-empty extractable text.
103133
// tool_result lines are type "user" but carry no text, so they return false.

cmd/entire/cli/agentimport/claude_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,47 @@ func TestClaudeSplitTurns_TwoPromptsBoundedByNext(t *testing.T) {
8989
}
9090
}
9191

92+
// TestClaudeSplitTurns_ExtractsCommitSHAs proves gitOperation tool-result
93+
// records are collected into Turn.CommitSHAs in transcript order, only for
94+
// kind "committed" — a tool_result line carrying a commit is not itself a
95+
// turn boundary (isUserPromptLine already rejects it), so it just attaches to
96+
// the enclosing turn. A garbage (non-JSON) line between the two commit
97+
// records proves one bad line is skipped without dropping the turn's other
98+
// recorded SHAs.
99+
func TestClaudeSplitTurns_ExtractsCommitSHAs(t *testing.T) {
100+
t.Parallel()
101+
full := []byte(strings.Join([]string{
102+
`{"type":"user","uuid":"u1","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}`,
103+
`{"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"output_tokens":5}}}`,
104+
`{"type":"user","uuid":"tr1","toolUseResult":{"gitOperation":{"commit":{"sha":"fe71aa6","kind":"committed"},"push":{"ok":true}}}}`,
105+
`not valid json {{{`,
106+
`{"type":"user","uuid":"tr2","toolUseResult":{"gitOperation":{"commit":{"sha":"aabbccd","kind":"committed"}}}}`,
107+
`{"type":"user","uuid":"tr3","toolUseResult":{"gitOperation":{"commit":{"sha":"ddeeff0","kind":"amended"}}}}`,
108+
`{"type":"user","uuid":"tr4","toolUseResult":{"gitOperation":{"push":{"ok":true}}}}`,
109+
`{"type":"user","uuid":"u2","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}`,
110+
}, "\n") + "\n")
111+
112+
turns, err := claudeImporter{}.SplitTurns(SessionFile{Path: filepath.Join(t.TempDir(), "s.jsonl"), SessionID: "s"}, full)
113+
if err != nil {
114+
t.Fatal(err)
115+
}
116+
if len(turns) != 2 {
117+
t.Fatalf("want 2 turns, got %d", len(turns))
118+
}
119+
wantSHAs := []string{"fe71aa6", "aabbccd"}
120+
if len(turns[0].CommitSHAs) != len(wantSHAs) {
121+
t.Fatalf("turn0 CommitSHAs = %v, want %v", turns[0].CommitSHAs, wantSHAs)
122+
}
123+
for i, want := range wantSHAs {
124+
if turns[0].CommitSHAs[i] != want {
125+
t.Errorf("turn0 CommitSHAs[%d] = %q, want %q", i, turns[0].CommitSHAs[i], want)
126+
}
127+
}
128+
if len(turns[1].CommitSHAs) != 0 {
129+
t.Errorf("turn1 CommitSHAs = %v, want empty", turns[1].CommitSHAs)
130+
}
131+
}
132+
92133
func TestClaudeSplitTurns_ToolResultIsNotATurn(t *testing.T) {
93134
t.Parallel()
94135
full := []byte(strings.Join([]string{

0 commit comments

Comments
 (0)