Skip to content

Commit 3e4eae8

Browse files
peyton-altclaude
andcommitted
feat(import): anchor turns to their recorded commits when available
Extends the import commit_sha anchor (PR #1825) to be per-turn: when a Claude Code transcript records the commit(s) a turn made via gitOperation records, the turn's checkpoint anchors to the last such commit that resolves and is an ancestor of the default-branch fallback, instead of always pointing at the default-branch head. Older transcripts (or recorded commits that were squashed/rebased away) keep the existing fallback behavior unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01KY5P3CT3T2K4KGK4QCTBGMA6
1 parent 919f7bc commit 3e4eae8

7 files changed

Lines changed: 396 additions & 13 deletions

File tree

cmd/entire/cli/agentimport/agentimport.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ 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 commits this turn recorded making, in transcript
58+
// order, as written by the agent's gitOperation transcript records
59+
// (kind "committed" only). SHAs may be SHORT — callers must resolve them
60+
// against the repo before use. Nil for agents/transcripts without the
61+
// feature; the anchor then falls back to Options.LinkCommitSHA.
62+
CommitSHAs []string
5763
}
5864

5965
// Importer is the per-agent seam: it locates an agent's transcripts for a repo
@@ -99,10 +105,12 @@ type Options struct {
99105
Now time.Time
100106
DryRun bool
101107

102-
// LinkCommitSHA, when non-empty, is written to each imported checkpoint's
103-
// metadata as commit_sha — the anchor commit the UI shows imported sessions
104-
// against. The caller resolves it (default branch head when resolvable;
105-
// see resolveImportLinkCommitSHA); Run does not.
108+
// LinkCommitSHA, when non-empty, is the fallback anchor written to each
109+
// imported checkpoint's metadata as commit_sha — the commit the UI shows
110+
// imported sessions against. The caller resolves it (default branch head
111+
// when resolvable; see resolveImportLinkCommitSHA); Run does not. A turn
112+
// whose transcript records a resolvable commit that is an ancestor of this
113+
// fallback anchors to that real commit instead (see turnAnchorResolver).
106114
LinkCommitSHA string
107115
}
108116

@@ -142,6 +150,11 @@ func Run(ctx context.Context, repo *git.Repository, imp Importer, opts Options)
142150
}
143151
}
144152

153+
// One resolver per Run: it lazily walks and memoizes opts.LinkCommitSHA's
154+
// ancestor set the first time a turn actually carries a candidate, so a
155+
// whole import run pays for at most one history walk, not one per turn.
156+
anchorResolver := newTurnAnchorResolver(repo, opts.LinkCommitSHA)
157+
145158
for _, sf := range files {
146159
res.SessionsScanned++
147160
full, readErr := os.ReadFile(sf.Path)
@@ -176,7 +189,8 @@ func Run(ctx context.Context, repo *git.Repository, imp Importer, opts Options)
176189
red = r
177190
redacted = true
178191
}
179-
if err := writeTurn(ctx, stores, imp, cid, sf, red, turn, opts.LinkCommitSHA); err != nil {
192+
anchor := anchorResolver.resolve(turn.CommitSHAs)
193+
if err := writeTurn(ctx, stores, imp, cid, sf, red, turn, anchor); err != nil {
180194
return res, err
181195
}
182196
existing[cid.String()] = true

cmd/entire/cli/agentimport/agentimport_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,77 @@ func TestRun_StampsLinkCommitSHA(t *testing.T) {
213213
}
214214
}
215215

216+
// TestRun_AnchorsTurnToRecordedCommit proves a turn whose transcript records a
217+
// resolvable, default-branch-reachable commit anchors to that real commit
218+
// instead of the LinkCommitSHA fallback, while a turn with no recorded commit
219+
// still falls back exactly as before.
220+
func TestRun_AnchorsTurnToRecordedCommit(t *testing.T) {
221+
t.Parallel()
222+
repo, repoDir := initRepoWithCommit(t)
223+
firstCommit, err := repo.Head()
224+
if err != nil {
225+
t.Fatal(err)
226+
}
227+
firstSHA := firstCommit.Hash().String()
228+
229+
// Second commit on the default branch, so tip != first commit.
230+
wt, err := repo.Worktree()
231+
if err != nil {
232+
t.Fatal(err)
233+
}
234+
writeAndCommit(t, wt, repoDir, "f.txt", "y", "second")
235+
tipHead, err := repo.Head()
236+
if err != nil {
237+
t.Fatal(err)
238+
}
239+
tipSHA := tipHead.Hash().String()
240+
241+
claudeDir := t.TempDir()
242+
content := strings.Join([]string{
243+
`{"type":"user","uuid":"u1","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}`,
244+
`{"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"output_tokens":5}}}`,
245+
`{"type":"user","uuid":"tr1","toolUseResult":{"gitOperation":{"commit":{"sha":"` + firstSHA[:7] + `","kind":"committed"}}}}`,
246+
`{"type":"user","uuid":"u2","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}`,
247+
}, "\n") + "\n"
248+
if err := os.WriteFile(filepath.Join(claudeDir, "sess-anchor.jsonl"), []byte(content), 0o644); err != nil {
249+
t.Fatal(err)
250+
}
251+
252+
res, err := Run(context.Background(), repo, claudeImporter{}, Options{
253+
RepoRoot: repoDir, OverridePath: claudeDir,
254+
Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC),
255+
LinkCommitSHA: tipSHA,
256+
})
257+
if err != nil {
258+
t.Fatal(err)
259+
}
260+
if res.TurnsImported != 2 {
261+
t.Fatalf("want 2 imported, got %+v", res)
262+
}
263+
264+
stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{})
265+
if err != nil {
266+
t.Fatal(err)
267+
}
268+
cid1 := DeriveCheckpointID("sess-anchor", "u1")
269+
md1, err := stores.Persistent.ReadSessionMetadata(context.Background(), cid1, 0)
270+
if err != nil {
271+
t.Fatal(err)
272+
}
273+
if md1.CommitSHA != firstSHA {
274+
t.Fatalf("turn1 CommitSHA = %q, want recorded commit %q", md1.CommitSHA, firstSHA)
275+
}
276+
277+
cid2 := DeriveCheckpointID("sess-anchor", "u2")
278+
md2, err := stores.Persistent.ReadSessionMetadata(context.Background(), cid2, 0)
279+
if err != nil {
280+
t.Fatal(err)
281+
}
282+
if md2.CommitSHA != tipSHA {
283+
t.Fatalf("turn2 CommitSHA = %q, want fallback %q", md2.CommitSHA, tipSHA)
284+
}
285+
}
286+
216287
// TestRun_AppliesConfiguredCustomRedaction proves imported transcripts honor
217288
// repo/user-configured custom_redactions (loaded at the command via
218289
// strategy.EnsureRedactionConfigured), not just always-on secret scanning.

cmd/entire/cli/agentimport/claude.go

Lines changed: 34 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,34 @@ 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 are short; resolution happens in Run.
106+
func commitSHAsInRange(rawLines [][]byte, start, end int) []string {
107+
var shas []string
108+
for i := start; i < end && i < len(rawLines); i++ {
109+
var rec struct {
110+
ToolUseResult struct {
111+
GitOperation struct {
112+
Commit struct {
113+
SHA string `json:"sha"`
114+
Kind string `json:"kind"`
115+
} `json:"commit"`
116+
} `json:"gitOperation"`
117+
} `json:"toolUseResult"`
118+
}
119+
if err := json.Unmarshal(rawLines[i], &rec); err != nil {
120+
continue
121+
}
122+
c := rec.ToolUseResult.GitOperation.Commit
123+
if c.SHA != "" && c.Kind == "committed" {
124+
shas = append(shas, c.SHA)
125+
}
126+
}
127+
return shas
128+
}
129+
101130
// isUserPromptLine reports whether a raw JSONL line is a genuine user-prompt
102131
// turn start: type "user" (or role "user") with non-empty extractable text.
103132
// tool_result lines are type "user" but carry no text, so they return false.

cmd/entire/cli/agentimport/claude_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,44 @@ 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.
97+
func TestClaudeSplitTurns_ExtractsCommitSHAs(t *testing.T) {
98+
t.Parallel()
99+
full := []byte(strings.Join([]string{
100+
`{"type":"user","uuid":"u1","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}`,
101+
`{"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"output_tokens":5}}}`,
102+
`{"type":"user","uuid":"tr1","toolUseResult":{"gitOperation":{"commit":{"sha":"fe71aa6","kind":"committed"},"push":{"ok":true}}}}`,
103+
`{"type":"user","uuid":"tr2","toolUseResult":{"gitOperation":{"commit":{"sha":"aabbccd","kind":"committed"}}}}`,
104+
`{"type":"user","uuid":"tr3","toolUseResult":{"gitOperation":{"commit":{"sha":"ddeeff0","kind":"amended"}}}}`,
105+
`{"type":"user","uuid":"tr4","toolUseResult":{"gitOperation":{"push":{"ok":true}}}}`,
106+
`{"type":"user","uuid":"u2","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}`,
107+
}, "\n") + "\n")
108+
109+
turns, err := claudeImporter{}.SplitTurns(SessionFile{Path: filepath.Join(t.TempDir(), "s.jsonl"), SessionID: "s"}, full)
110+
if err != nil {
111+
t.Fatal(err)
112+
}
113+
if len(turns) != 2 {
114+
t.Fatalf("want 2 turns, got %d", len(turns))
115+
}
116+
wantSHAs := []string{"fe71aa6", "aabbccd"}
117+
if len(turns[0].CommitSHAs) != len(wantSHAs) {
118+
t.Fatalf("turn0 CommitSHAs = %v, want %v", turns[0].CommitSHAs, wantSHAs)
119+
}
120+
for i, want := range wantSHAs {
121+
if turns[0].CommitSHAs[i] != want {
122+
t.Errorf("turn0 CommitSHAs[%d] = %q, want %q", i, turns[0].CommitSHAs[i], want)
123+
}
124+
}
125+
if len(turns[1].CommitSHAs) != 0 {
126+
t.Errorf("turn1 CommitSHAs = %v, want empty", turns[1].CommitSHAs)
127+
}
128+
}
129+
92130
func TestClaudeSplitTurns_ToolResultIsNotATurn(t *testing.T) {
93131
t.Parallel()
94132
full := []byte(strings.Join([]string{
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package agentimport
2+
3+
import (
4+
"regexp"
5+
6+
"github.com/go-git/go-git/v6"
7+
"github.com/go-git/go-git/v6/plumbing"
8+
"github.com/go-git/go-git/v6/plumbing/object"
9+
)
10+
11+
// shaCandidatePattern enforces the "candidates are SHAs" contract: a hex
12+
// string of plausible short-to-full sha length. Rejects revision syntax like
13+
// "HEAD" or "HEAD~2" from ever reaching ResolveRevision, where it would
14+
// otherwise resolve as a ref/expression rather than a commit sha.
15+
var shaCandidatePattern = regexp.MustCompile(`^[0-9a-f]{4,64}$`)
16+
17+
// turnAnchorResolver picks the commit_sha anchor for each imported turn in one
18+
// Run: the LAST candidate (transcript order — the turn's end state) that both
19+
// resolves in the repo and is an ancestor of fallback, else fallback itself.
20+
// fallback is the caller-resolved default-branch tip (Options.LinkCommitSHA);
21+
// ancestry against it doubles as the reachability check, so this needs no
22+
// branch-name logic. Candidates are short SHAs from transcript gitOperation
23+
// records; squash-merged or rebased-away commits simply fail to resolve or
24+
// fail ancestry and fall through, as does any candidate that isn't a
25+
// hex sha (e.g. revision syntax like "HEAD") or that resolves ambiguously.
26+
//
27+
// The fallback's ancestor set is walked and memoized once, lazily, on the
28+
// first turn that actually carries a candidate — a single
29+
// object.NewCommitPreorderIter walk per import run rather than one IsAncestor
30+
// walk per candidate. Turns/sessions with no recorded commits (the common
31+
// case for older transcripts) never trigger the walk. Not safe for concurrent
32+
// use — Run calls resolve from a single goroutine.
33+
type turnAnchorResolver struct {
34+
repo *git.Repository
35+
fallback string
36+
ancestors map[plumbing.Hash]struct{} // nil until first candidate-bearing call
37+
}
38+
39+
// newTurnAnchorResolver builds a resolver for one Run. It does no repo work
40+
// until resolve is first called with a non-empty candidate list.
41+
func newTurnAnchorResolver(repo *git.Repository, fallback string) *turnAnchorResolver {
42+
return &turnAnchorResolver{repo: repo, fallback: fallback}
43+
}
44+
45+
// resolve returns the anchor for one turn's candidates. Empty fallback or no
46+
// candidates return fallback unchanged (empty fallback → "" — unanchorable
47+
// repo imports unlinked, matching resolveImportLinkCommitSHA's contract).
48+
func (r *turnAnchorResolver) resolve(candidates []string) string {
49+
if r.fallback == "" || len(candidates) == 0 {
50+
return r.fallback
51+
}
52+
if r.ancestors == nil {
53+
r.ancestors = r.buildAncestors()
54+
}
55+
for i := len(candidates) - 1; i >= 0; i-- {
56+
c := candidates[i]
57+
if !shaCandidatePattern.MatchString(c) {
58+
continue
59+
}
60+
hash, err := r.repo.ResolveRevision(plumbing.Revision(c))
61+
if err != nil || hash == nil {
62+
continue
63+
}
64+
if _, ok := r.ancestors[*hash]; ok {
65+
return hash.String()
66+
}
67+
}
68+
return r.fallback
69+
}
70+
71+
// buildAncestors walks fallback's history exactly once, collecting every
72+
// reachable commit hash (including fallback itself — a commit is its own
73+
// ancestor, matching go-git's IsAncestor semantics). If the fallback commit
74+
// doesn't resolve or the history can't be walked, it returns an empty set —
75+
// every candidate then falls through to the fallback in resolve, same as the
76+
// original per-call error handling this replaces.
77+
func (r *turnAnchorResolver) buildAncestors() map[plumbing.Hash]struct{} {
78+
ancestors := make(map[plumbing.Hash]struct{})
79+
fbCommit, err := r.repo.CommitObject(plumbing.NewHash(r.fallback))
80+
if err != nil {
81+
return ancestors
82+
}
83+
iter := object.NewCommitPreorderIter(fbCommit, nil, nil)
84+
defer iter.Close()
85+
if err := iter.ForEach(func(c *object.Commit) error {
86+
ancestors[c.Hash] = struct{}{}
87+
return nil
88+
}); err != nil {
89+
// Best-effort: return whatever was collected before the walk broke
90+
// (e.g. a missing object deep in history). Any candidate not already
91+
// in this partial set falls through to the fallback in resolve.
92+
return ancestors
93+
}
94+
return ancestors
95+
}

0 commit comments

Comments
 (0)