diff --git a/api/checkpoint/metadata.go b/api/checkpoint/metadata.go index 5a39e7ecd7..2202e102ac 100644 --- a/api/checkpoint/metadata.go +++ b/api/checkpoint/metadata.go @@ -37,6 +37,16 @@ type WriteOptions struct { // Branch is the branch name where the checkpoint was created (empty if detached HEAD) Branch string + // CommitSHA links this checkpoint to an existing commit without a trailer. + // It is an anchor — "imported at this point in time" — not attribution. + // Currently set only by `entire import`: imported history has no + // Entire-Checkpoint trailer (we never rewrite existing commits), so import + // stamps the resolved anchor commit here (the default branch head when + // resolvable; see resolveImportLinkCommitSHA for the fallback order). + // Empty for all other writers. This comment is the canonical description; + // Metadata.CommitSHA and CheckpointSummary.CommitSHA point back here. + CommitSHA string + // Transcript is the session transcript content (full.jsonl). // Must be pre-redacted (via redact.JSONLBytes or redact.AlreadyRedacted for trusted sources). Transcript redact.RedactedBytes @@ -316,13 +326,17 @@ type SessionContent struct { // Metadata contains the metadata stored in metadata.json for each checkpoint. type Metadata struct { - CLIVersion string `json:"cli_version,omitempty"` - CheckpointID id.CheckpointID `json:"checkpoint_id"` - SessionID string `json:"session_id"` - Strategy string `json:"strategy"` - CreatedAt time.Time `json:"created_at"` - Branch string `json:"branch,omitempty"` // Branch where checkpoint was created (empty if detached HEAD) - CheckpointsCount int `json:"checkpoints_count"` + CLIVersion string `json:"cli_version,omitempty"` + CheckpointID id.CheckpointID `json:"checkpoint_id"` + SessionID string `json:"session_id"` + Strategy string `json:"strategy"` + CreatedAt time.Time `json:"created_at"` + Branch string `json:"branch,omitempty"` // Branch where checkpoint was created (empty if detached HEAD) + // CommitSHA anchors an imported checkpoint to an existing commit; empty for + // non-imported checkpoints, which link via the Entire-Checkpoint trailer. + // See WriteOptions.CommitSHA for the full semantics. + CommitSHA string `json:"commit_sha,omitempty"` + CheckpointsCount int `json:"checkpoints_count"` // SaveStepCount is the number of SaveStep-recorded steps for this session. // Honest "real checkpoint work happened" signal (0 = commit-only/fallback // session), kept separate from the displayed CheckpointsCount prompt count. @@ -474,10 +488,12 @@ type SessionFilePaths struct { // //nolint:revive // Named CheckpointSummary to avoid conflict with existing Summary struct type CheckpointSummary struct { - CLIVersion string `json:"cli_version,omitempty"` - CheckpointID id.CheckpointID `json:"checkpoint_id"` - Strategy string `json:"strategy"` - Branch string `json:"branch,omitempty"` + CLIVersion string `json:"cli_version,omitempty"` + CheckpointID id.CheckpointID `json:"checkpoint_id"` + Strategy string `json:"strategy"` + Branch string `json:"branch,omitempty"` + // CommitSHA: import-only anchor; see WriteOptions.CommitSHA. + CommitSHA string `json:"commit_sha,omitempty"` CheckpointsCount int `json:"checkpoints_count"` FilesTouched []string `json:"files_touched"` Sessions []SessionFilePaths `json:"sessions"` diff --git a/cmd/entire/cli/agentimport/agentimport.go b/cmd/entire/cli/agentimport/agentimport.go index 9f9fb79773..ab2bd30331 100644 --- a/cmd/entire/cli/agentimport/agentimport.go +++ b/cmd/entire/cli/agentimport/agentimport.go @@ -54,6 +54,13 @@ type Turn struct { // subagent's tokens are counted exactly once rather than re-added on every // turn after it is discovered. Tokens *types.TokenUsage + // CommitSHAs are the commit SHAs (possibly abbreviated) this turn's + // transcript records creating, in transcript order. Extraction is + // per-importer (see commitSHAsInRange for Claude Code). Callers must + // resolve them against the repo before use. Nil when the transcript + // records no commits (including agents that don't extract them); the + // anchor then falls back to Options.LinkCommitSHA. + CommitSHAs []string } // Importer is the per-agent seam: it locates an agent's transcripts for a repo @@ -98,6 +105,14 @@ type Options struct { SessionFilter []string Now time.Time DryRun bool + + // LinkCommitSHA, when non-empty, is the fallback anchor written to each + // imported checkpoint's metadata as commit_sha — the commit the UI shows + // imported sessions against. The caller resolves it (default branch head + // when resolvable; see resolveImportLinkCommitSHA); Run does not. A turn + // whose transcript records a resolvable commit that is an ancestor of this + // fallback anchors to that real commit instead (see turnAnchorResolver). + LinkCommitSHA string } // Result summarizes an import run. @@ -136,6 +151,11 @@ func Run(ctx context.Context, repo *git.Repository, imp Importer, opts Options) } } + // One resolver per Run: it lazily walks and memoizes opts.LinkCommitSHA's + // ancestor set the first time a turn actually carries a candidate, so a + // whole import run pays for at most one history walk, not one per turn. + anchorResolver := newTurnAnchorResolver(repo, opts.LinkCommitSHA, opts.Now) + for _, sf := range files { res.SessionsScanned++ full, readErr := os.ReadFile(sf.Path) @@ -170,7 +190,12 @@ func Run(ctx context.Context, repo *git.Repository, imp Importer, opts Options) red = r redacted = true } - if err := writeTurn(ctx, stores, imp, cid, sf, red, turn); err != nil { + anchor, fromCandidate := anchorResolver.resolve(ctx, turn.CommitSHAs) + if len(turn.CommitSHAs) > 0 && !fromCandidate { + logging.Debug(ctx, "import: turn anchor fell back", + "sessionID", sf.SessionID, "turnUUID", turn.UUID, "candidates", len(turn.CommitSHAs)) + } + if err := writeTurn(ctx, stores, imp, cid, sf, red, turn, anchor); err != nil { return res, err } existing[cid.String()] = true @@ -261,7 +286,7 @@ func writeSessionState(ctx context.Context, imp Importer, sf SessionFile, turns return nil } -func writeTurn(ctx context.Context, stores *cp.Stores, imp Importer, cid id.CheckpointID, sf SessionFile, red redact.RedactedBytes, turn Turn) error { +func writeTurn(ctx context.Context, stores *cp.Stores, imp Importer, cid id.CheckpointID, sf SessionFile, red redact.RedactedBytes, turn Turn, anchorCommitSHA string) error { if err := stores.Persistent.Write(ctx, cp.Session(cp.WriteOptions{ CheckpointID: cid, SessionID: sf.SessionID, @@ -275,6 +300,7 @@ func writeTurn(ctx context.Context, stores *cp.Stores, imp Importer, cid id.Chec CheckpointsCount: 1, CheckpointTranscriptStart: turn.LineStart, TokenUsage: turn.Tokens, + CommitSHA: anchorCommitSHA, })); err != nil { return fmt.Errorf("write imported checkpoint %s: %w", cid, err) } diff --git a/cmd/entire/cli/agentimport/agentimport_test.go b/cmd/entire/cli/agentimport/agentimport_test.go index ca4779252e..ea4c84af8e 100644 --- a/cmd/entire/cli/agentimport/agentimport_test.go +++ b/cmd/entire/cli/agentimport/agentimport_test.go @@ -90,7 +90,10 @@ func initRepoWithCommit(t *testing.T) (*git.Repository, string) { t.Fatal(err) } if _, err := wt.Commit("init", &git.CommitOptions{ - Author: &object.Signature{Name: "Test", Email: "test@test.com"}, + // When must be a real timestamp: the anchor resolver's bounded walk + // stops at commits older than its date cutoff, and a zero-value When + // (year 1) would halt the walk at the first commit. + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, }); err != nil { t.Fatal(err) } @@ -152,6 +155,138 @@ func TestRun_ImportsAndIsIdempotent(t *testing.T) { } } +// TestRun_StampsLinkCommitSHA proves Options.LinkCommitSHA is copied verbatim +// into each imported checkpoint's commit_sha metadata field, and that leaving +// it unset leaves commit_sha empty. Run resolves nothing itself. +func TestRun_StampsLinkCommitSHA(t *testing.T) { + t.Parallel() + repo, repoDir := initRepoWithCommit(t) + const commitSHA = "b01b59663fd4860fd15a9939499be44a14dbf168" + + claudeDirWithSHA := t.TempDir() + writeFixtureSession(t, claudeDirWithSHA, "sess-with-sha.jsonl") + res, err := Run(context.Background(), repo, claudeImporter{}, Options{ + RepoRoot: repoDir, OverridePath: claudeDirWithSHA, + Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC), + LinkCommitSHA: commitSHA, + }) + if err != nil { + t.Fatal(err) + } + if res.TurnsImported != 2 { + t.Fatalf("want 2 imported, got %+v", res) + } + + stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{}) + if err != nil { + t.Fatal(err) + } + cid := DeriveCheckpointID("sess-with-sha", "u1") + md, err := stores.Persistent.ReadSessionMetadata(context.Background(), cid, 0) + if err != nil { + t.Fatal(err) + } + if md.CommitSHA != commitSHA { + t.Fatalf("expected commit_sha %q, got %q", commitSHA, md.CommitSHA) + } + + // A separate session fixture (own sessionID/turn UUIDs) run with + // LinkCommitSHA unset must persist an empty commit_sha. Reusing the same + // session would be idempotently skipped, so this needs its own fixture. + claudeDirNoSHA := t.TempDir() + writeFixtureSession(t, claudeDirNoSHA, "sess-no-sha.jsonl") + res2, err := Run(context.Background(), repo, claudeImporter{}, Options{ + RepoRoot: repoDir, OverridePath: claudeDirNoSHA, + Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC), + }) + if err != nil { + t.Fatal(err) + } + if res2.TurnsImported != 2 { + t.Fatalf("want 2 imported, got %+v", res2) + } + + cid2 := DeriveCheckpointID("sess-no-sha", "u1") + md2, err := stores.Persistent.ReadSessionMetadata(context.Background(), cid2, 0) + if err != nil { + t.Fatal(err) + } + if md2.CommitSHA != "" { + t.Fatalf("expected empty commit_sha, got %q", md2.CommitSHA) + } +} + +// TestRun_AnchorsTurnToRecordedCommit proves a turn whose transcript records a +// resolvable, default-branch-reachable commit anchors to that real commit +// instead of the LinkCommitSHA fallback, while a turn with no recorded commit +// still falls back exactly as before. +func TestRun_AnchorsTurnToRecordedCommit(t *testing.T) { + t.Parallel() + repo, repoDir := initRepoWithCommit(t) + firstCommit, err := repo.Head() + if err != nil { + t.Fatal(err) + } + firstSHA := firstCommit.Hash().String() + + // Second commit on the default branch, so tip != first commit. + wt, err := repo.Worktree() + if err != nil { + t.Fatal(err) + } + writeAndCommit(t, wt, repoDir, "y", "second") + tipHead, err := repo.Head() + if err != nil { + t.Fatal(err) + } + tipSHA := tipHead.Hash().String() + + claudeDir := t.TempDir() + content := strings.Join([]string{ + `{"type":"user","uuid":"u1","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}`, + `{"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"output_tokens":5}}}`, + `{"type":"user","uuid":"tr1","toolUseResult":{"gitOperation":{"commit":{"sha":"` + firstSHA[:7] + `","kind":"committed"}}}}`, + `{"type":"user","uuid":"u2","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}`, + }, "\n") + "\n" + if err := os.WriteFile(filepath.Join(claudeDir, "sess-anchor.jsonl"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + res, err := Run(context.Background(), repo, claudeImporter{}, Options{ + RepoRoot: repoDir, OverridePath: claudeDir, + Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC), + LinkCommitSHA: tipSHA, + }) + if err != nil { + t.Fatal(err) + } + if res.TurnsImported != 2 { + t.Fatalf("want 2 imported, got %+v", res) + } + + stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{}) + if err != nil { + t.Fatal(err) + } + cid1 := DeriveCheckpointID("sess-anchor", "u1") + md1, err := stores.Persistent.ReadSessionMetadata(context.Background(), cid1, 0) + if err != nil { + t.Fatal(err) + } + if md1.CommitSHA != firstSHA { + t.Fatalf("turn1 CommitSHA = %q, want recorded commit %q", md1.CommitSHA, firstSHA) + } + + cid2 := DeriveCheckpointID("sess-anchor", "u2") + md2, err := stores.Persistent.ReadSessionMetadata(context.Background(), cid2, 0) + if err != nil { + t.Fatal(err) + } + if md2.CommitSHA != tipSHA { + t.Fatalf("turn2 CommitSHA = %q, want fallback %q", md2.CommitSHA, tipSHA) + } +} + // TestRun_AppliesConfiguredCustomRedaction proves imported transcripts honor // repo/user-configured custom_redactions (loaded at the command via // strategy.EnsureRedactionConfigured), not just always-on secret scanning. diff --git a/cmd/entire/cli/agentimport/claude.go b/cmd/entire/cli/agentimport/claude.go index ef2595ae3f..79efb700c5 100644 --- a/cmd/entire/cli/agentimport/claude.go +++ b/cmd/entire/cli/agentimport/claude.go @@ -57,11 +57,12 @@ func (claudeImporter) SplitTurns(sf SessionFile, full []byte) ([]Turn, error) { return nil, nil } return &Turn{ - UUID: rec.UUID, - Prompt: transcript.ExtractUserContent(rec.Message), - Model: modelInRange(rawLines, start, end), - CreatedAt: parseTimestamp(rec.Timestamp), - Tokens: tokens, + UUID: rec.UUID, + Prompt: transcript.ExtractUserContent(rec.Message), + Model: modelInRange(rawLines, start, end), + CreatedAt: parseTimestamp(rec.Timestamp), + Tokens: tokens, + CommitSHAs: commitSHAsInRange(rawLines, start, end), }, nil }) } @@ -98,6 +99,35 @@ func modelInRange(rawLines [][]byte, start, end int) string { return "" } +// commitSHAsInRange returns the commit SHAs recorded by gitOperation +// tool-result records within [start, end), in order. Only kind "committed" +// is collected — other kinds (or commit-less gitOperation records like +// push/branch/pr) are ignored. SHAs may be abbreviated; resolution happens +// in Run. +func commitSHAsInRange(rawLines [][]byte, start, end int) []string { + var shas []string + for i := start; i < end && i < len(rawLines); i++ { + var rec struct { + ToolUseResult struct { + GitOperation struct { + Commit struct { + SHA string `json:"sha"` + Kind string `json:"kind"` + } `json:"commit"` + } `json:"gitOperation"` + } `json:"toolUseResult"` + } + if err := json.Unmarshal(rawLines[i], &rec); err != nil { + continue + } + c := rec.ToolUseResult.GitOperation.Commit + if c.SHA != "" && c.Kind == "committed" { + shas = append(shas, c.SHA) + } + } + return shas +} + // isUserPromptLine reports whether a raw JSONL line is a genuine user-prompt // turn start: type "user" (or role "user") with non-empty extractable text. // tool_result lines are type "user" but carry no text, so they return false. diff --git a/cmd/entire/cli/agentimport/claude_test.go b/cmd/entire/cli/agentimport/claude_test.go index a6355e4d6b..27f3706a34 100644 --- a/cmd/entire/cli/agentimport/claude_test.go +++ b/cmd/entire/cli/agentimport/claude_test.go @@ -89,6 +89,47 @@ func TestClaudeSplitTurns_TwoPromptsBoundedByNext(t *testing.T) { } } +// TestClaudeSplitTurns_ExtractsCommitSHAs proves gitOperation tool-result +// records are collected into Turn.CommitSHAs in transcript order, only for +// kind "committed" — a tool_result line carrying a commit is not itself a +// turn boundary (isUserPromptLine already rejects it), so it just attaches to +// the enclosing turn. A garbage (non-JSON) line between the two commit +// records proves one bad line is skipped without dropping the turn's other +// recorded SHAs. +func TestClaudeSplitTurns_ExtractsCommitSHAs(t *testing.T) { + t.Parallel() + full := []byte(strings.Join([]string{ + `{"type":"user","uuid":"u1","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}`, + `{"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"output_tokens":5}}}`, + `{"type":"user","uuid":"tr1","toolUseResult":{"gitOperation":{"commit":{"sha":"fe71aa6","kind":"committed"},"push":{"ok":true}}}}`, + `not valid json {{{`, + `{"type":"user","uuid":"tr2","toolUseResult":{"gitOperation":{"commit":{"sha":"aabbccd","kind":"committed"}}}}`, + `{"type":"user","uuid":"tr3","toolUseResult":{"gitOperation":{"commit":{"sha":"ddeeff0","kind":"amended"}}}}`, + `{"type":"user","uuid":"tr4","toolUseResult":{"gitOperation":{"push":{"ok":true}}}}`, + `{"type":"user","uuid":"u2","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}`, + }, "\n") + "\n") + + turns, err := claudeImporter{}.SplitTurns(SessionFile{Path: filepath.Join(t.TempDir(), "s.jsonl"), SessionID: "s"}, full) + if err != nil { + t.Fatal(err) + } + if len(turns) != 2 { + t.Fatalf("want 2 turns, got %d", len(turns)) + } + wantSHAs := []string{"fe71aa6", "aabbccd"} + if len(turns[0].CommitSHAs) != len(wantSHAs) { + t.Fatalf("turn0 CommitSHAs = %v, want %v", turns[0].CommitSHAs, wantSHAs) + } + for i, want := range wantSHAs { + if turns[0].CommitSHAs[i] != want { + t.Errorf("turn0 CommitSHAs[%d] = %q, want %q", i, turns[0].CommitSHAs[i], want) + } + } + if len(turns[1].CommitSHAs) != 0 { + t.Errorf("turn1 CommitSHAs = %v, want empty", turns[1].CommitSHAs) + } +} + func TestClaudeSplitTurns_ToolResultIsNotATurn(t *testing.T) { t.Parallel() full := []byte(strings.Join([]string{ diff --git a/cmd/entire/cli/agentimport/turn_anchor.go b/cmd/entire/cli/agentimport/turn_anchor.go new file mode 100644 index 0000000000..c3d661c5ed --- /dev/null +++ b/cmd/entire/cli/agentimport/turn_anchor.go @@ -0,0 +1,153 @@ +package agentimport + +import ( + "context" + "regexp" + "time" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/object" + "github.com/go-git/go-git/v6/plumbing/storer" + + "github.com/entireio/cli/cmd/entire/cli/logging" +) + +// shaCandidatePattern enforces the "candidates are SHAs" contract: a hex +// string of plausible short-to-full sha length. Rejects revision syntax like +// "HEAD" or "HEAD~2" from ever reaching ResolveRevision, where it would +// otherwise resolve as a ref/expression rather than a commit sha. It does NOT +// stop a hex-named ref: a branch or tag literally named e.g. "beef" still +// resolves as that ref before a commit sha would. That's accepted here — the +// ancestry gate below still bounds the result to default-branch history, and +// this anchor is display-only. +var shaCandidatePattern = regexp.MustCompile(`^[0-9a-f]{4,64}$`) + +// Ancestor-walk bounds. Candidates come from transcripts at most LookbackDays +// old, so any anchorable commit is recent; walking further buys nothing. +// ancestorWalkSlack absorbs committer-clock skew and rebases that backdate +// commits. The commit cap is a backstop for repos with pathological committer +// dates (the date cutoff can't be trusted to trigger there) and bounds both +// walk time and ancestor-set memory outright. +const ( + ancestorWalkSlack = 60 * 24 * time.Hour + ancestorWalkMaxCommits = 50_000 +) + +// turnAnchorResolver picks the commit_sha anchor for each imported turn in one +// Run: the LAST candidate (transcript order — the turn's end state) that both +// resolves in the repo and is an ancestor of fallback, else fallback itself. +// fallback is the caller-resolved default-branch tip (Options.LinkCommitSHA); +// ancestry against it doubles as the reachability check, so this needs no +// branch-name logic. Candidates are abbreviated commit SHAs recorded by the +// turn's transcript; squash-merged or rebased-away commits simply fail to +// resolve or fail ancestry and fall through, as does any candidate that isn't +// a hex sha (e.g. revision syntax like "HEAD"). Ambiguous short SHAs are NOT +// detected — go-git's ResolveRevision resolves them to an arbitrary matching +// commit rather than erroring; the ancestry gate bounds the resulting damage +// to mis-anchoring within default-branch history, never outside it. +// +// The fallback's ancestor set is walked and memoized once, lazily, on the +// first turn that actually carries a candidate. The walk is bounded — it +// emits newest-first (committer-time order) and stops at commits older than +// the lookback window plus slack, or at ancestorWalkMaxCommits — so both walk +// time and set memory stay capped on huge histories. A commit beyond either +// bound misses the set and its turn falls back, which is consistent: no +// importable turn can reference a commit that old. Turns/sessions with no +// recorded commits (the common case for older transcripts) never trigger the +// walk. Not safe for concurrent use — Run calls resolve from a single +// goroutine. +type turnAnchorResolver struct { + repo *git.Repository + fallback string + cutoff time.Time // commits with committer time before this are not collected + maxWalk int // hard cap on commits visited (overridable in tests) + ancestors map[plumbing.Hash]struct{} // nil until first candidate-bearing call +} + +// newTurnAnchorResolver builds a resolver for one Run. It does no repo work +// until resolve is first called with a non-empty candidate list. fallback +// must be a full hex sha when non-empty — resolveImportLinkCommitSHA +// guarantees this; a short fallback would silently degrade to the empty +// ancestor-set path. now anchors the walk's date cutoff (Options.Now; zero +// falls back to the wall clock). +func newTurnAnchorResolver(repo *git.Repository, fallback string, now time.Time) *turnAnchorResolver { + if now.IsZero() { + now = time.Now() + } + return &turnAnchorResolver{ + repo: repo, + fallback: fallback, + cutoff: now.Add(-(time.Duration(LookbackDays)*24*time.Hour + ancestorWalkSlack)), + maxWalk: ancestorWalkMaxCommits, + } +} + +// resolve returns the anchor for one turn's candidates and whether it came +// from a candidate (as opposed to the fallback — callers use this to log +// genuine fallbacks without misreporting the turn whose recorded commit IS +// the fallback tip). Empty fallback or no candidates return fallback +// unchanged (empty fallback → "" — unanchorable repo imports unlinked, +// matching resolveImportLinkCommitSHA's contract). +func (r *turnAnchorResolver) resolve(ctx context.Context, candidates []string) (anchor string, fromCandidate bool) { + if r.fallback == "" || len(candidates) == 0 { + return r.fallback, false + } + if r.ancestors == nil { + r.ancestors = r.buildAncestors(ctx) + } + for i := len(candidates) - 1; i >= 0; i-- { + c := candidates[i] + if !shaCandidatePattern.MatchString(c) { + continue + } + hash, err := r.repo.ResolveRevision(plumbing.Revision(c)) + if err != nil || hash == nil { + continue + } + if _, ok := r.ancestors[*hash]; ok { + return hash.String(), true + } + } + return r.fallback, false +} + +// buildAncestors walks fallback's history once, newest-first, collecting +// reachable commit hashes (including fallback itself — a commit is its own +// ancestor, matching go-git's IsAncestor semantics) until the date cutoff or +// commit cap stops it. Committer-time ordering is what makes early-stopping +// sound: with newest-first emission, the first commit older than the cutoff +// means everything after it is older too (modulo clock skew, absorbed by +// ancestorWalkSlack) — a depth-first walk could not stop early without +// cutting off recent commits on unvisited merge branches. If the fallback +// commit doesn't resolve or the walk fails, it returns an empty (or partial) +// set — every candidate not already collected then falls through to the +// fallback in resolve. Failure paths are logged at Debug: import decisions +// are one-shot (a re-run skips already-imported turns), so an unlogged +// failure here destroys the only evidence of why every turn in this run +// anchored to the fallback instead of its recorded commit. +func (r *turnAnchorResolver) buildAncestors(ctx context.Context) map[plumbing.Hash]struct{} { + ancestors := make(map[plumbing.Hash]struct{}) + iter, err := r.repo.Log(&git.LogOptions{ + From: plumbing.NewHash(r.fallback), + Order: git.LogOrderCommitterTime, + }) + if err != nil { + logging.Debug(ctx, "import: anchor ancestor walk unavailable, all turns fall back", + "fallback", r.fallback, "error", err.Error()) + return ancestors + } + defer iter.Close() + if err := iter.ForEach(func(c *object.Commit) error { + if len(ancestors) >= r.maxWalk || c.Committer.When.Before(r.cutoff) { + return storer.ErrStop + } + ancestors[c.Hash] = struct{}{} + return nil + }); err != nil { + logging.Debug(ctx, "import: anchor ancestor walk truncated", + "fallback", r.fallback, "ancestors_collected", len(ancestors), "error", err.Error()) + return ancestors + } + return ancestors +} diff --git a/cmd/entire/cli/agentimport/turn_anchor_test.go b/cmd/entire/cli/agentimport/turn_anchor_test.go new file mode 100644 index 0000000000..7e97c1143c --- /dev/null +++ b/cmd/entire/cli/agentimport/turn_anchor_test.go @@ -0,0 +1,232 @@ +package agentimport + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/object" + + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +// buildAnchorTestRepo builds: +// +// main: C1 ── C2 (fallback anchor = C2's full sha) +// side: C1 ── S1 (side branch commit — resolvable but NOT an ancestor of C2) +// +// and returns the repo plus the full SHAs of C1, C2, and S1. turnAnchorResolver +// never consults HEAD/current branch, so the repo is left checked out on the +// side branch after this helper runs — that's fine for these tests. +func buildAnchorTestRepo(t *testing.T) (repo *git.Repository, c1, c2, s1 string) { + t.Helper() + repo, repoDir := initRepoWithCommit(t) + wt, err := repo.Worktree() + if err != nil { + t.Fatal(err) + } + head, err := repo.Head() + if err != nil { + t.Fatal(err) + } + c1 = head.Hash().String() + + // C2 on the default branch. + writeAndCommit(t, wt, repoDir, "c2", "second") + head, err = repo.Head() + if err != nil { + t.Fatal(err) + } + c2 = head.Hash().String() + + // side branch off C1, with a commit S1 that the default branch never merges. + if err := wt.Checkout(&git.CheckoutOptions{ + Hash: plumbing.NewHash(c1), + Branch: plumbing.NewBranchReferenceName("side"), + Create: true, + }); err != nil { + t.Fatal(err) + } + writeAndCommit(t, wt, repoDir, "s1", "side commit") + head, err = repo.Head() + if err != nil { + t.Fatal(err) + } + s1 = head.Hash().String() + + return repo, c1, c2, s1 +} + +func writeAndCommit(t *testing.T, wt *git.Worktree, repoDir, content, msg string) { + t.Helper() + testutil.WriteFile(t, repoDir, "f.txt", content) + if _, err := wt.Add("f.txt"); err != nil { + t.Fatal(err) + } + if _, err := wt.Commit(msg, &git.CommitOptions{ + // When must be a real timestamp: the anchor resolver's bounded walk + // stops at commits older than its date cutoff, and a zero-value When + // (year 1) would halt the walk at the first commit. + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }); err != nil { + t.Fatal(err) + } +} + +func TestResolveTurnAnchor_PicksLastReachableCandidate(t *testing.T) { + t.Parallel() + repo, c1, c2, _ := buildAnchorTestRepo(t) + r := newTurnAnchorResolver(repo, c2, time.Now()) + + got, fromCandidate := r.resolve(context.Background(), []string{c1[:7], c2[:7]}) + if got != c2 { + t.Fatalf("resolve = %q, want last candidate (full) %q", got, c2) + } + // The winning candidate happens to equal the fallback tip — resolve must + // still report it as a candidate match, not a fallback (the caller's + // "fell back" debug log keys off this). + if !fromCandidate { + t.Fatal("resolve reported fallback for a turn whose candidate matched") + } +} + +// TestResolveTurnAnchor_ReportsFallback proves the fromCandidate return is +// false when the anchor genuinely came from the fallback (unreachable +// candidate), so the caller's debug log fires only for real fallbacks. +func TestResolveTurnAnchor_ReportsFallback(t *testing.T) { + t.Parallel() + repo, _, c2, s1 := buildAnchorTestRepo(t) + r := newTurnAnchorResolver(repo, c2, time.Now()) + + got, fromCandidate := r.resolve(context.Background(), []string{s1[:7]}) + if got != c2 || fromCandidate { + t.Fatalf("resolve = (%q, %v), want fallback %q with fromCandidate=false", got, fromCandidate, c2) + } +} + +// TestResolveTurnAnchor_DateCutoffBoundsWalk proves the ancestor walk stops at +// commits older than the lookback-plus-slack cutoff: a candidate commit +// backdated past the cutoff misses the (bounded) ancestor set and its turn +// falls back, even though the commit is genuinely reachable from the tip. +func TestResolveTurnAnchor_DateCutoffBoundsWalk(t *testing.T) { + t.Parallel() + repo, repoDir := initRepoWithCommit(t) + wt, err := repo.Worktree() + if err != nil { + t.Fatal(err) + } + // A commit far older than LookbackDays+slack, then a fresh tip on top. + old := time.Now().Add(-365 * 24 * time.Hour) + testutil.WriteFile(t, repoDir, "f.txt", "old") + if _, err := wt.Add("f.txt"); err != nil { + t.Fatal(err) + } + oldHash, err := wt.Commit("backdated", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: old}, + Committer: &object.Signature{Name: "Test", Email: "test@test.com", When: old}, + }) + if err != nil { + t.Fatal(err) + } + writeAndCommit(t, wt, repoDir, "tip", "fresh tip") + head, err := repo.Head() + if err != nil { + t.Fatal(err) + } + tip := head.Hash().String() + + r := newTurnAnchorResolver(repo, tip, time.Now()) + got, fromCandidate := r.resolve(context.Background(), []string{oldHash.String()[:7]}) + if got != tip || fromCandidate { + t.Fatalf("resolve = (%q, %v), want fallback %q: pre-cutoff commit must miss the bounded walk", got, fromCandidate, tip) + } +} + +// TestResolveTurnAnchor_MaxWalkCapBoundsWalk proves the commit-count cap: with +// maxWalk forced to 1, only the tip is collected, so an older (but recent and +// reachable) candidate misses the set and falls back. +func TestResolveTurnAnchor_MaxWalkCapBoundsWalk(t *testing.T) { + t.Parallel() + repo, c1, c2, _ := buildAnchorTestRepo(t) + r := newTurnAnchorResolver(repo, c2, time.Now()) + r.maxWalk = 1 + + got, fromCandidate := r.resolve(context.Background(), []string{c1[:7]}) + if got != c2 || fromCandidate { + t.Fatalf("resolve = (%q, %v), want fallback %q: capped walk must not collect c1", got, fromCandidate, c2) + } + // The tip itself was collected before the cap hit, so it still anchors. + if got, fromCandidate := r.resolve(context.Background(), []string{c2[:7]}); got != c2 || !fromCandidate { + t.Fatalf("resolve = (%q, %v), want tip as candidate match", got, fromCandidate) + } +} + +func TestResolveTurnAnchor_SkipsUnreachableAndUnresolvable(t *testing.T) { + t.Parallel() + repo, _, c2, s1 := buildAnchorTestRepo(t) + r := newTurnAnchorResolver(repo, c2, time.Now()) + ctx := context.Background() + + // s1 resolves but is not an ancestor of the fallback c2. + if got, _ := r.resolve(ctx, []string{s1[:7]}); got != c2 { + t.Fatalf("unreachable candidate: resolve = %q, want fallback %q", got, c2) + } + + // "deadbeef" is valid hex but doesn't resolve to anything in this repo. + if got, _ := r.resolve(ctx, []string{"deadbeef"}); got != c2 { + t.Fatalf("unresolvable candidate: resolve = %q, want fallback %q", got, c2) + } + + // nil candidates. + if got, _ := r.resolve(ctx, nil); got != c2 { + t.Fatalf("nil candidates: resolve = %q, want fallback %q", got, c2) + } +} + +// TestResolveTurnAnchor_RejectsRevisionSyntax proves a candidate that looks +// like git revision syntax rather than a sha (e.g. "HEAD") is rejected before +// ever reaching ResolveRevision, so it can't resolve as an expression and +// falls through to the fallback like any other unresolvable candidate. +func TestResolveTurnAnchor_RejectsRevisionSyntax(t *testing.T) { + t.Parallel() + repo, _, c2, _ := buildAnchorTestRepo(t) + r := newTurnAnchorResolver(repo, c2, time.Now()) + ctx := context.Background() + + if got, _ := r.resolve(ctx, []string{"HEAD"}); got != c2 { + t.Fatalf("revision syntax candidate: resolve = %q, want fallback %q", got, c2) + } + if got, _ := r.resolve(ctx, []string{"HEAD~2"}); got != c2 { + t.Fatalf("revision syntax candidate: resolve = %q, want fallback %q", got, c2) + } +} + +func TestResolveTurnAnchor_EmptyFallback(t *testing.T) { + t.Parallel() + repo, c1, _, _ := buildAnchorTestRepo(t) + r := newTurnAnchorResolver(repo, "", time.Now()) + + if got, _ := r.resolve(context.Background(), []string{c1[:7]}); got != "" { + t.Fatalf("empty fallback: resolve = %q, want empty", got) + } +} + +// TestResolveTurnAnchor_FallbackDoesNotResolve proves a non-empty, +// well-formed (full hex, 40 chars) fallback that simply doesn't exist in the +// repo degrades gracefully: buildAncestors' CommitObject lookup fails, the +// ancestor set stays empty, every candidate falls through, and resolve +// returns the (unresolvable) fallback string verbatim rather than panicking. +func TestResolveTurnAnchor_FallbackDoesNotResolve(t *testing.T) { + t.Parallel() + repo, c1, _, _ := buildAnchorTestRepo(t) + fallback := strings.Repeat("ca", 20) // valid hex, 40 chars, not a real object + r := newTurnAnchorResolver(repo, fallback, time.Now()) + + got, _ := r.resolve(context.Background(), []string{c1[:7]}) + if got != fallback { + t.Fatalf("resolve = %q, want unresolvable fallback %q", got, fallback) + } +} diff --git a/cmd/entire/cli/checkpoint/persistent.go b/cmd/entire/cli/checkpoint/persistent.go index 6d82820c75..05af5b3c41 100644 --- a/cmd/entire/cli/checkpoint/persistent.go +++ b/cmd/entire/cli/checkpoint/persistent.go @@ -739,6 +739,7 @@ func (s *treeWriter) writeSessionToSubdirectory(ctx context.Context, opts WriteO Strategy: opts.Strategy, CreatedAt: checkpointCreatedAt(opts), Branch: opts.Branch, + CommitSHA: opts.CommitSHA, CheckpointsCount: opts.CheckpointsCount, SaveStepCount: opts.SaveStepCount, FilesTouched: opts.FilesTouched, @@ -800,6 +801,7 @@ func (s *treeWriter) writeCheckpointSummary(opts WriteOptions, basePath string, // was imported (Kind == "imported"). Compared as a literal because the // session package imports checkpoint, so we can't reference its constant. imported := opts.Kind == "imported" + commitSHA := opts.CommitSHA rootMetadataPath := checkpointSubtreePath(basePath, paths.MetadataFileName) if entry, exists := entries[rootMetadataPath]; exists { existingSummary, readErr := s.readSummaryFromBlob(entry.Hash) @@ -816,6 +818,12 @@ func (s *treeWriter) writeCheckpointSummary(opts WriteOptions, basePath string, if !imported { imported = existingSummary.Imported } + // A later write to the same checkpoint (e.g. a review session + // attached to it) carries no CommitSHA; the imported anchor + // must survive that rewrite rather than be cleared. + if commitSHA == "" { + commitSHA = existingSummary.CommitSHA + } } } @@ -824,6 +832,7 @@ func (s *treeWriter) writeCheckpointSummary(opts WriteOptions, basePath string, CLIVersion: versioninfo.Version, Strategy: opts.Strategy, Branch: opts.Branch, + CommitSHA: commitSHA, CheckpointsCount: checkpointsCount, FilesTouched: filesTouched, Sessions: sessions, diff --git a/cmd/entire/cli/checkpoint/persistent_imported_test.go b/cmd/entire/cli/checkpoint/persistent_imported_test.go index ba2dfb550c..8f4cff582c 100644 --- a/cmd/entire/cli/checkpoint/persistent_imported_test.go +++ b/cmd/entire/cli/checkpoint/persistent_imported_test.go @@ -1,7 +1,9 @@ package checkpoint import ( + "bytes" "context" + "encoding/json" "testing" "github.com/go-git/go-git/v6" @@ -13,8 +15,11 @@ import ( "github.com/entireio/cli/redact" ) -func TestWrite_ImportedSurfacesOnList(t *testing.T) { - t.Parallel() +// newImportedTestStore builds a GitStore over a fresh temp repo with one +// commit (so HEAD exists) and returns it with a redacted one-line transcript, +// shared setup for the imported-checkpoint tests below. +func newImportedTestStore(t *testing.T) (*GitStore, redact.RedactedBytes) { + t.Helper() tempDir := t.TempDir() testutil.InitRepo(t, tempDir) repo, err := git.PlainOpen(tempDir) @@ -36,12 +41,17 @@ func TestWrite_ImportedSurfacesOnList(t *testing.T) { t.Fatal(err) } - store := NewGitStore(repo, DefaultV1Refs()) red, err := redact.JSONLBytes([]byte(`{"type":"user","uuid":"u1","message":{"role":"user","content":"hi"}}` + "\n")) if err != nil { t.Fatal(err) } - err = store.Write(context.Background(), Session{ + return NewGitStore(repo, DefaultV1Refs()), red +} + +func TestWrite_ImportedSurfacesOnList(t *testing.T) { + t.Parallel() + store, red := newImportedTestStore(t) + err := store.Write(context.Background(), Session{ CheckpointID: id.MustCheckpointID("aabbccddeeff"), SessionID: "s1", Strategy: "import", @@ -63,3 +73,128 @@ func TestWrite_ImportedSurfacesOnList(t *testing.T) { t.Fatalf("expected 1 imported checkpoint, got %+v", infos) } } + +func TestImportedCheckpoint_CommitSHAPersisted(t *testing.T) { + t.Parallel() + store, red := newImportedTestStore(t) + + const commitSHA = "b01b59663fd4860fd15a9939499be44a14dbf168" + ctx := context.Background() + cid := id.MustCheckpointID("aabbccddeeff") + err := store.Write(ctx, Session{ + CheckpointID: cid, + SessionID: "s1", + Strategy: "import", + Kind: "imported", + Agent: agent.AgentTypeClaudeCode, + Transcript: red, + Prompts: []string{"hi"}, + CheckpointsCount: 1, + CommitSHA: commitSHA, + }) + if err != nil { + t.Fatalf("write imported checkpoint: %v", err) + } + + md, err := store.ReadSessionMetadata(ctx, cid, 0) + if err != nil { + t.Fatalf("read session metadata: %v", err) + } + if md.CommitSHA != commitSHA { + t.Fatalf("expected session metadata commit_sha %q, got %q", commitSHA, md.CommitSHA) + } + + summary, err := store.Read(ctx, cid) + if err != nil { + t.Fatalf("read checkpoint summary: %v", err) + } + if summary.CommitSHA != commitSHA { + t.Fatalf("expected checkpoint summary commit_sha %q, got %q", commitSHA, summary.CommitSHA) + } + + // omitempty guard: a checkpoint written without CommitSHA must not surface + // "commit_sha" in the marshaled metadata at all. + cid2 := id.MustCheckpointID("112233445566") + err = store.Write(ctx, Session{ + CheckpointID: cid2, + SessionID: "s1", + Strategy: "import", + Kind: "imported", + Agent: agent.AgentTypeClaudeCode, + Transcript: red, + Prompts: []string{"hi"}, + CheckpointsCount: 1, + }) + if err != nil { + t.Fatalf("write second imported checkpoint: %v", err) + } + + md2, err := store.ReadSessionMetadata(ctx, cid2, 0) + if err != nil { + t.Fatalf("read second session metadata: %v", err) + } + if md2.CommitSHA != "" { + t.Fatalf("expected empty commit_sha, got %q", md2.CommitSHA) + } + rawMD, err := json.Marshal(md2) + if err != nil { + t.Fatalf("marshal metadata: %v", err) + } + if bytes.Contains(rawMD, []byte(`"commit_sha"`)) { + t.Fatalf("expected marshaled metadata to omit commit_sha when unset, got %s", rawMD) + } +} + +// TestImportedCheckpoint_CommitSHASurvivesSummaryRewrite proves the root +// summary's preserve-on-rewrite: a later write to the SAME checkpoint that +// carries no CommitSHA (e.g. a review session attached to it) must not clear +// the stamped anchor from the root CheckpointSummary. Session-level Metadata +// is deliberately NOT preserved the same way — each session's metadata.json +// records what its own write carried. +func TestImportedCheckpoint_CommitSHASurvivesSummaryRewrite(t *testing.T) { + t.Parallel() + store, red := newImportedTestStore(t) + + const commitSHA = "b01b59663fd4860fd15a9939499be44a14dbf168" + ctx := context.Background() + cid := id.MustCheckpointID("ddeeff001122") + err := store.Write(ctx, Session{ + CheckpointID: cid, + SessionID: "s1", + Strategy: "import", + Kind: "imported", + Agent: agent.AgentTypeClaudeCode, + Transcript: red, + Prompts: []string{"hi"}, + CheckpointsCount: 1, + CommitSHA: commitSHA, + }) + if err != nil { + t.Fatalf("write imported checkpoint: %v", err) + } + + // Second write to the same checkpoint, different session, no CommitSHA. + err = store.Write(ctx, Session{ + CheckpointID: cid, + SessionID: "s2", + Strategy: "manual-commit", + Agent: agent.AgentTypeClaudeCode, + Transcript: red, + Prompts: []string{"review it"}, + CheckpointsCount: 1, + }) + if err != nil { + t.Fatalf("second write to same checkpoint: %v", err) + } + + summary, err := store.Read(ctx, cid) + if err != nil { + t.Fatalf("read checkpoint summary: %v", err) + } + if summary.CommitSHA != commitSHA { + t.Fatalf("root summary commit_sha must survive a CommitSHA-less rewrite: expected %q, got %q", commitSHA, summary.CommitSHA) + } + if len(summary.Sessions) != 2 { + t.Fatalf("expected both sessions in the rewritten summary, got %d", len(summary.Sessions)) + } +} diff --git a/cmd/entire/cli/import_cmd.go b/cmd/entire/cli/import_cmd.go index f0ebd55061..6722a981bd 100644 --- a/cmd/entire/cli/import_cmd.go +++ b/cmd/entire/cli/import_cmd.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/entireio/cli/cmd/entire/cli/agentimport" + "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/strategy" ) @@ -56,6 +57,14 @@ fails even with --dry-run.`, imp.AgentType()), } defer repo.Close() + // Best-effort file logging (like explain/resume): without Init, + // logging.Debug below is a no-op. WorktreeRoot already succeeded, + // so this cannot create .entire/logs/ outside a repo. + logging.SetLogLevelGetter(GetLogLevel) + if err := logging.Init(ctx, ""); err == nil { + defer logging.Close() + } + if err := ensureCheckpointPolicyAllowsCheckpointData(ctx, repo); err != nil { return err } @@ -66,9 +75,15 @@ fails even with --dry-run.`, imp.AgentType()), // it only always-on secret scanning would run on imported history. strategy.EnsureRedactionConfigured() + // Logged so support can tell why an import has no anchor (empty + // sha: nothing resolved) or a stale one (origin tip not fetched). + linkCommitSHA := resolveImportLinkCommitSHA(repo) + logging.Debug(ctx, "import: resolved link commit", "commit_sha", linkCommitSHA) + res, err := agentimport.Run(ctx, repo, imp, agentimport.Options{ RepoRoot: repoRoot, OverridePath: pathFlag, SessionFilter: sessions, Now: time.Now(), DryRun: dryRun, + LinkCommitSHA: linkCommitSHA, }) if err != nil { return fmt.Errorf("import %s: %w", imp.Name(), err) diff --git a/cmd/entire/cli/import_link.go b/cmd/entire/cli/import_link.go new file mode 100644 index 0000000000..98725a3cee --- /dev/null +++ b/cmd/entire/cli/import_link.go @@ -0,0 +1,30 @@ +package cli + +import ( + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +// resolveImportLinkCommitSHA returns the commit SHA imported checkpoints are +// anchored to: the default branch's head at import time. Preference order: +// origin's tip of the default branch (the commit most likely already known to +// the server), then the local branch tip, then HEAD. Best-effort — returns "" +// when nothing resolves (e.g. an empty repo); import proceeds without a link. +// This function is the source of truth for the order; the architecture docs +// describe it but defer here. +func resolveImportLinkCommitSHA(repo *git.Repository) string { + if name := strategy.GetDefaultBranchName(repo); name != "" { + if ref, err := repo.Reference(plumbing.NewRemoteReferenceName("origin", name), true); err == nil { + return ref.Hash().String() + } + if ref, err := repo.Reference(plumbing.NewBranchReferenceName(name), true); err == nil { + return ref.Hash().String() + } + } + if head, err := repo.Head(); err == nil { + return head.Hash().String() + } + return "" +} diff --git a/cmd/entire/cli/import_link_test.go b/cmd/entire/cli/import_link_test.go new file mode 100644 index 0000000000..9e363d4f70 --- /dev/null +++ b/cmd/entire/cli/import_link_test.go @@ -0,0 +1,129 @@ +package cli + +import ( + "testing" + + "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/stretchr/testify/require" +) + +// TestResolveImportLinkCommitSHA_LocalDefaultBranchNoOrigin proves that when +// there is no origin remote, the resolver resolves via the local default +// branch arm (testutil.InitRepo checks out master, so GetDefaultBranchName +// returns "master" and the local-branch lookup succeeds). The true HEAD +// fallback is covered by TestResolveImportLinkCommitSHA_HEADWhenNoDefaultBranch. +func TestResolveImportLinkCommitSHA_LocalDefaultBranchNoOrigin(t *testing.T) { + t.Parallel() + + repoDir := t.TempDir() + testutil.InitRepo(t, repoDir) + testutil.WriteFile(t, repoDir, "f.txt", "init") + testutil.GitAdd(t, repoDir, "f.txt") + testutil.GitCommit(t, repoDir, "init") + + repo, err := git.PlainOpen(repoDir) + require.NoError(t, err) + t.Cleanup(func() { _ = repo.Close() }) + + head, err := repo.Head() + require.NoError(t, err) + + got := resolveImportLinkCommitSHA(repo) + require.Equal(t, head.Hash().String(), got) +} + +// TestResolveImportLinkCommitSHA_PrefersOriginDefaultBranch proves that when +// origin's default branch tip differs from the local branch tip, the +// resolver prefers origin's tip — that's the commit the server already +// knows about. +func TestResolveImportLinkCommitSHA_PrefersOriginDefaultBranch(t *testing.T) { + t.Parallel() + + repoDir := t.TempDir() + testutil.InitRepo(t, repoDir) + testutil.WriteFile(t, repoDir, "f.txt", "one") + testutil.GitAdd(t, repoDir, "f.txt") + testutil.GitCommit(t, repoDir, "first") + firstSHA := testutil.GetHeadHash(t, repoDir) + + testutil.WriteFile(t, repoDir, "f.txt", "two") + testutil.GitAdd(t, repoDir, "f.txt") + testutil.GitCommit(t, repoDir, "second") + secondSHA := testutil.GetHeadHash(t, repoDir) + + repo, err := git.PlainOpen(repoDir) + require.NoError(t, err) + t.Cleanup(func() { _ = repo.Close() }) + + // Manually create refs/remotes/origin/main -> first commit, and + // refs/remotes/origin/HEAD as a symbolic ref pointing at it. + firstHash := plumbing.NewHash(firstSHA) + originMainRef := plumbing.NewHashReference(plumbing.NewRemoteReferenceName("origin", "main"), firstHash) + require.NoError(t, repo.Storer.SetReference(originMainRef)) + originHeadRef := plumbing.NewSymbolicReference( + plumbing.NewRemoteReferenceName("origin", "HEAD"), + plumbing.NewRemoteReferenceName("origin", "main"), + ) + require.NoError(t, repo.Storer.SetReference(originHeadRef)) + + // Also create a local main -> second commit, so origin/main and local + // main genuinely diverge. testutil.InitRepo defaults to `master`, so + // without this the resolver's local-branch arm is never exercised and + // the assertion below can't pin the origin-over-local preference order. + secondHash := plumbing.NewHash(secondSHA) + localMainRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName("main"), secondHash) + require.NoError(t, repo.Storer.SetReference(localMainRef)) + + got := resolveImportLinkCommitSHA(repo) + require.Equal(t, firstSHA, got) +} + +// TestResolveImportLinkCommitSHA_HEADWhenNoDefaultBranch proves the HEAD +// fallback: when the default branch name cannot be resolved at all (no +// remotes, and the checked-out branch is neither main nor master), the +// resolver still returns HEAD's commit instead of "". +func TestResolveImportLinkCommitSHA_HEADWhenNoDefaultBranch(t *testing.T) { + t.Parallel() + + repoDir := t.TempDir() + testutil.InitRepo(t, repoDir) + testutil.WriteFile(t, repoDir, "f.txt", "init") + testutil.GitAdd(t, repoDir, "f.txt") + testutil.GitCommit(t, repoDir, "init") + sha := testutil.GetHeadHash(t, repoDir) + + repo, err := git.PlainOpen(repoDir) + require.NoError(t, err) + t.Cleanup(func() { _ = repo.Close() }) + + // Rename the branch away from main/master so GetDefaultBranchName + // returns "" (no origin, and no local main/master to fall back to). + hash := plumbing.NewHash(sha) + trunkRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName("trunk"), hash) + require.NoError(t, repo.Storer.SetReference(trunkRef)) + require.NoError(t, repo.Storer.SetReference( + plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("trunk")), + )) + require.NoError(t, repo.Storer.RemoveReference(plumbing.NewBranchReferenceName("master"))) + + got := resolveImportLinkCommitSHA(repo) + require.Equal(t, sha, got) +} + +// TestResolveImportLinkCommitSHA_EmptyRepo proves the resolver returns "" and +// does not panic on a repo with no commits. +func TestResolveImportLinkCommitSHA_EmptyRepo(t *testing.T) { + t.Parallel() + + repoDir := t.TempDir() + // git.PlainInit deliberately (not testutil.InitRepo): the repo must stay + // commit-free, so the helper's user/GPG config is irrelevant here. + repo, err := git.PlainInit(repoDir, false) + require.NoError(t, err) + t.Cleanup(func() { _ = repo.Close() }) + + got := resolveImportLinkCommitSHA(repo) + require.Empty(t, got) +} diff --git a/cmd/entire/cli/integration_test/import_claude_test.go b/cmd/entire/cli/integration_test/import_claude_test.go index 4520d0d18a..8ac0a5c19b 100644 --- a/cmd/entire/cli/integration_test/import_claude_test.go +++ b/cmd/entire/cli/integration_test/import_claude_test.go @@ -31,10 +31,30 @@ func TestImportClaudeCode_EndToEnd(t *testing.T) { require.Contains(t, out, "Would import 2", "dry-run should count 2 turns; got: %s", out) require.NotContains(t, env.RunCLI("checkpoint", "list"), "[imported]", "dry-run must not write checkpoints") + // Diverge the feature branch from the default branch before importing, so + // the commit_sha assertion below can tell the default-branch tip apart + // from HEAD (they are identical right after NewFeatureBranchEnv). + env.WriteFile("diverge.txt", "x") + env.GitAdd("diverge.txt") + env.GitCommit("diverge feature branch") + // 2. Real import writes the imported checkpoints (onto the v1 metadata branch). out = env.RunCLI("import", "claude-code") require.Contains(t, out, "Imported 2", "got: %s", out) + // 2b. Every imported checkpoint carries the default branch's tip as its + // commit_sha anchor (NOT the feature branch's HEAD). This pins the + // command-level wiring (import_cmd → resolveImportLinkCommitSHA → + // agentimport.Options): omitempty would silently hide a dropped wire. + defaultTip := gitOutput(t, env.RepoDir, "rev-parse", "master") + importedID := agentimport.DeriveCheckpointID(sessionID, "u1").String() + sessionMD := gitOutput(t, env.RepoDir, "show", "entire/checkpoints/v1:"+SessionMetadataPath(importedID)) + require.Contains(t, sessionMD, `"commit_sha": "`+defaultTip+`"`, + "imported session metadata should anchor to the default branch tip; got: %s", sessionMD) + rootMD := gitOutput(t, env.RepoDir, "show", "entire/checkpoints/v1:"+CheckpointSummaryPath(importedID)) + require.Contains(t, rootMD, `"commit_sha": "`+defaultTip+`"`, + "imported root summary should anchor to the default branch tip; got: %s", rootMD) + // 3. checkpoint list surfaces imported entries labeled [imported], and does // NOT duplicate them as [temporary] (regression: the imports were once // mis-read by the shadow-branch scanner). @@ -44,7 +64,6 @@ func TestImportClaudeCode_EndToEnd(t *testing.T) { // 4. explain resolves an imported checkpoint by ID (regression: explain once // only consulted the committed/shadow paths and missed imports). - importedID := agentimport.DeriveCheckpointID(sessionID, "u1").String() explainOut := env.RunCLI("checkpoint", "explain", importedID) require.Contains(t, explainOut, "first", "explain should show the imported turn's prompt; got: %s", explainOut) diff --git a/docs/architecture/sessions-and-checkpoints.md b/docs/architecture/sessions-and-checkpoints.md index b802d04735..2fff8f390d 100644 --- a/docs/architecture/sessions-and-checkpoints.md +++ b/docs/architecture/sessions-and-checkpoints.md @@ -305,6 +305,21 @@ When condensing multiple concurrent sessions: - `sessions` array in `CheckpointSummary` maps each session to its file paths - `files_touched` is merged from all sessions +Checkpoints written by `entire import ` additionally carry a `commit_sha` +(omitempty) on both the session `Metadata` and the root `CheckpointSummary`, +set to the default branch's head at import time — origin's tip is preferred +(the commit the server already knows about), falling back to the local branch +tip, then HEAD, then empty when nothing resolves. When the transcript itself +records the commit(s) a turn made (Claude Code `gitOperation` records), the +turn's checkpoint instead anchors to the last such commit that resolves and is +reachable from the resolved link anchor (the default-branch head when +resolvable) — see `turnAnchorResolver` (`agentimport/turn_anchor.go`); +otherwise (older transcripts, or a recorded commit that's been +squashed/rebased away) it falls back to the default-branch head as described +above. It is a best-effort anchor for UI display only, not +an attribution signal, and pre-existing imported checkpoints are not +backfilled with it. + ### Checkpoint Policy Repo-wide checkpoint policy lives at `refs/entire/policies/checkpoint`. The ref