diff --git a/CLAUDE.md b/CLAUDE.md index a1c8600036..7c562fa7c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -385,7 +385,7 @@ The manual-commit strategy (`manual_commit*.go`) does not modify the active bran - `manual_commit_rewind.go` - Rewind implementation: file restoration from checkpoint trees - `manual_commit_git.go` - Git operations: checkpoint commits, tree building - `manual_commit_logs.go` - Session log retrieval and session listing -- `manual_commit_hooks.go` - Git hook handlers (prepare-commit-msg, post-commit, post-rewrite, pre-push) +- `manual_commit_hooks.go` - Git hook handlers (prepare-commit-msg, post-commit, post-rewrite, pre-push); sequence operations like revert/cherry-pick only reuse an existing `LastCheckpointID` and never mint a fresh checkpoint ID - `manual_commit_reset.go` - Shadow branch reset/cleanup functionality - `session_state.go` - Package-level session state functions (`LoadSessionState`, `SaveSessionState`, `ListSessionStates`, `FindMostRecentSession`) - `hooks.go` - Git hook installation @@ -495,7 +495,7 @@ The strategy uses a **12-hex-char random checkpoint ID** (e.g., `a3b2c4d5e6f7`) 1. **Generated once per checkpoint**: When condensing session metadata to the metadata branch 2. **Added to user commits** via `Entire-Checkpoint` trailer: - - **Manual-commit**: Added via `prepare-commit-msg` hook (user can remove it before committing) + - **Manual-commit**: Added via `prepare-commit-msg` hook (user can remove it before committing). During revert/cherry-pick, the hook only reuses an existing `LastCheckpointID`; it does not create a new checkpoint ID because sequence-operation commits are not condensed immediately. 3. **Used for directory sharding** on `entire/checkpoints/v1` branch: - Path format: `//` @@ -546,6 +546,7 @@ entire/checkpoints/v1 commit: - `Entire-Checkpoint: ` - 12-hex-char ID linking to metadata on `entire/checkpoints/v1` - Added via `prepare-commit-msg` hook; user can remove it before committing to skip linking + - During revert/cherry-pick, only an existing `LastCheckpointID` is reused; no fresh checkpoint ID is generated for the sequence-operation commit **On shadow branch commits (`entire/-`):** diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index 9812b132d0..69b2ae7c4b 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -304,6 +304,10 @@ type WriteCommittedOptions struct { // - the checkpoint predates the summarization feature Summary *Summary + // Linkage contains content-based signals for re-linking after history rewrites. + // Written to the root-level CheckpointSummary, not per-session metadata. + Linkage *LinkageMetadata + // CompactTranscript is the Entire Transcript Format (transcript.jsonl) bytes. // Written to v2 /main ref alongside metadata. May be nil if compaction // was not performed (unknown agent, compaction error, empty transcript). @@ -453,6 +457,24 @@ type SessionFilePaths struct { Prompt string `json:"prompt"` } +// LinkageMetadata contains Git-native signals for limited fallback re-linking +// after git history rewrites (rebase, reword, amend, filter-branch). +// Stored at the checkpoint level (root metadata.json), not per-session. +// +// These signals are intended for limited fallback re-linking when a commit +// arrives without an Entire-Checkpoint trailer: +// 1. TreeHash match - covers: reword, amend (msg-only), filter-branch (msg-only) +// 2. PatchID match - covers: clean rebase, cherry-pick to other branch +type LinkageMetadata struct { + // TreeHash is the git tree hash of the commit (full repo snapshot). + // Survives rewrites that don't change code (reword, msg-only amend). + TreeHash string `json:"tree_hash,omitempty"` + + // PatchID is the git patch-id of the commit's diff (parent->HEAD). + // Survives clean rebases, but changes if conflict resolution changes the patch. + PatchID string `json:"patch_id,omitempty"` +} + // CheckpointSummary is the root-level metadata.json for a checkpoint. // It contains aggregated statistics from all sessions and a map of session IDs // to their file paths. Session-specific data (including initial_attribution) @@ -481,6 +503,7 @@ type CheckpointSummary struct { Sessions []SessionFilePaths `json:"sessions"` TokenUsage *agent.TokenUsage `json:"token_usage,omitempty"` CombinedAttribution *InitialAttribution `json:"combined_attribution,omitempty"` + Linkage *LinkageMetadata `json:"linkage,omitempty"` } // SessionMetrics contains hook-provided session metrics from agents that report diff --git a/cmd/entire/cli/checkpoint/checkpoint_test.go b/cmd/entire/cli/checkpoint/checkpoint_test.go index 74236d9cc5..d94786f362 100644 --- a/cmd/entire/cli/checkpoint/checkpoint_test.go +++ b/cmd/entire/cli/checkpoint/checkpoint_test.go @@ -3792,6 +3792,79 @@ func TestWriteCommitted_SubagentTranscript_JSONLFallback(t *testing.T) { } } +func TestWriteCommitted_IncludesLinkage(t *testing.T) { + t.Parallel() + repo, _ := setupBranchTestRepo(t) + store := NewGitStore(repo) + checkpointID := id.MustCheckpointID("a1b2c3d4e5f6") + + linkage := &LinkageMetadata{ + TreeHash: "abc123def456abc123def456abc123def456abc1", + PatchID: "def456abc123def456abc123def456abc123def4", + } + + err := store.WriteCommitted(context.Background(), WriteCommittedOptions{ + CheckpointID: checkpointID, + SessionID: "linkage-test-session", + Strategy: "manual-commit", + Agent: agent.AgentTypeClaudeCode, + Linkage: linkage, + Transcript: redact.AlreadyRedacted([]byte(`{"type":"human","message":{"content":"test"}}` + "\n")), + FilesTouched: []string{"file.go"}, + CheckpointsCount: 1, + AuthorName: "Test Author", + AuthorEmail: "test@example.com", + }) + if err != nil { + t.Fatalf("WriteCommitted() error = %v", err) + } + + // Read back the CheckpointSummary + summary, err := store.ReadCommitted(context.Background(), checkpointID) + if err != nil { + t.Fatalf("ReadCommitted() error = %v", err) + } + if summary.Linkage == nil { + t.Fatal("Linkage should be present in CheckpointSummary") + } + if summary.Linkage.TreeHash != linkage.TreeHash { + t.Errorf("TreeHash = %q, want %q", summary.Linkage.TreeHash, linkage.TreeHash) + } + if summary.Linkage.PatchID != linkage.PatchID { + t.Errorf("PatchID = %q, want %q", summary.Linkage.PatchID, linkage.PatchID) + } +} + +func TestWriteCommitted_NilLinkageOmitted(t *testing.T) { + t.Parallel() + repo, _ := setupBranchTestRepo(t) + store := NewGitStore(repo) + checkpointID := id.MustCheckpointID("a0b1c2d3e4f5") + + err := store.WriteCommitted(context.Background(), WriteCommittedOptions{ + CheckpointID: checkpointID, + SessionID: "no-linkage-session", + Strategy: "manual-commit", + Agent: agent.AgentTypeClaudeCode, + Transcript: redact.AlreadyRedacted([]byte(`{"type":"human","message":{"content":"test"}}` + "\n")), + FilesTouched: []string{"file.go"}, + CheckpointsCount: 1, + AuthorName: "Test Author", + AuthorEmail: "test@example.com", + }) + if err != nil { + t.Fatalf("WriteCommitted() error = %v", err) + } + + summary, err := store.ReadCommitted(context.Background(), checkpointID) + if err != nil { + t.Fatalf("ReadCommitted() error = %v", err) + } + if summary.Linkage != nil { + t.Errorf("Linkage should be nil when not provided, got %+v", summary.Linkage) + } +} + func TestWriteTemporaryTask_SubagentTranscript_RedactsSecrets(t *testing.T) { // Cannot use t.Parallel() because t.Chdir is required for paths.WorktreeRoot() tempDir := t.TempDir() diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 84d744fe61..8b30b1f4b6 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -437,6 +437,7 @@ func (s *GitStore) writeCheckpointSummary(opts WriteCommittedOptions, basePath s Sessions: sessions, TokenUsage: tokenUsage, CombinedAttribution: combinedAttribution, + Linkage: opts.Linkage, } metadataJSON, err := jsonutil.MarshalIndentWithNewline(summary, "", " ") diff --git a/cmd/entire/cli/gitops/diff.go b/cmd/entire/cli/gitops/diff.go index 34d8e4c3ad..163c571c0a 100644 --- a/cmd/entire/cli/gitops/diff.go +++ b/cmd/entire/cli/gitops/diff.go @@ -125,3 +125,52 @@ func extractStatus(statusLine string) byte { } return statusField[0] } + +// ComputePatchID computes the git patch-id for the diff between two commits. +// Patch IDs are content hashes of the diff itself, independent of commit metadata +// and parent position. This means the same code change produces the same patch ID +// even after rebase (which changes the parent/base but not the diff content). +// +// For initial commits (parentHash is empty), uses --root mode. +// Returns a 40-char hex SHA1 string, or empty string for empty diffs. +func ComputePatchID(ctx context.Context, repoDir, parentHash, commitHash string) (string, error) { + var diffCmd *exec.Cmd + if parentHash == "" { + diffCmd = exec.CommandContext(ctx, "git", "diff-tree", "--root", "-p", commitHash) + } else { + diffCmd = exec.CommandContext(ctx, "git", "diff-tree", "-p", parentHash, commitHash) + } + diffCmd.Dir = repoDir + + var diffOut, diffErr bytes.Buffer + diffCmd.Stdout = &diffOut + diffCmd.Stderr = &diffErr + + if err := diffCmd.Run(); err != nil { + return "", fmt.Errorf("git diff-tree failed: %w: %s", err, strings.TrimSpace(diffErr.String())) + } + + if diffOut.Len() == 0 { + return "", nil + } + + patchIDCmd := exec.CommandContext(ctx, "git", "patch-id", "--stable") + patchIDCmd.Dir = repoDir + patchIDCmd.Stdin = &diffOut + + var patchOut, patchErr bytes.Buffer + patchIDCmd.Stdout = &patchOut + patchIDCmd.Stderr = &patchErr + + if err := patchIDCmd.Run(); err != nil { + return "", fmt.Errorf("git patch-id failed: %w: %s", err, strings.TrimSpace(patchErr.String())) + } + + output := strings.TrimSpace(patchOut.String()) + if output == "" { + return "", nil + } + // git patch-id outputs " "; we want the first field. + patchID, _, _ := strings.Cut(output, " ") + return patchID, nil +} diff --git a/cmd/entire/cli/gitops/diff_test.go b/cmd/entire/cli/gitops/diff_test.go index 33d4106622..30ce5281e0 100644 --- a/cmd/entire/cli/gitops/diff_test.go +++ b/cmd/entire/cli/gitops/diff_test.go @@ -7,6 +7,8 @@ import ( "path/filepath" "sort" "testing" + + "github.com/entireio/cli/cmd/entire/cli/testutil" ) // initTestRepo creates a temp git repo and returns its path. @@ -31,6 +33,8 @@ func initTestRepo(t *testing.T) string { } run("init", "-b", "main") + run("config", "user.name", "Test") + run("config", "user.email", "test@test.com") run("config", "commit.gpgsign", "false") return dir @@ -405,3 +409,91 @@ func TestExtractStatus(t *testing.T) { }) } } + +func TestComputePatchID(t *testing.T) { + t.Parallel() + dir := initTestRepo(t) + + writeFile(t, dir, "file.txt", "initial") + gitAdd(t, dir, "file.txt") + gitCommit(t, dir, "initial") + + writeFile(t, dir, "file.txt", "modified") + gitAdd(t, dir, "file.txt") + gitCommit(t, dir, "modify file") + + head := testutil.GitRevParse(t, dir, "HEAD") + parent := testutil.GitRevParse(t, dir, "HEAD~1") + + patchID, err := ComputePatchID(context.Background(), dir, parent, head) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if patchID == "" { + t.Fatal("expected non-empty patch ID") + } + if len(patchID) != 40 { + t.Fatalf("expected 40-char hex, got %d chars: %s", len(patchID), patchID) + } +} + +func TestComputePatchID_StableAcrossRebase(t *testing.T) { + t.Parallel() + dir := initTestRepo(t) + + writeFile(t, dir, "base.txt", "base") + gitAdd(t, dir, "base.txt") + gitCommit(t, dir, "base") + + testutil.GitCheckoutNewBranch(t, dir, "feature") + writeFile(t, dir, "feature.txt", "feature work") + gitAdd(t, dir, "feature.txt") + gitCommit(t, dir, "add feature") + + featureHead := testutil.GitRevParse(t, dir, "HEAD") + featureParent := testutil.GitRevParse(t, dir, "HEAD~1") + + patchIDBefore, err := ComputePatchID(context.Background(), dir, featureParent, featureHead) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + testutil.GitCheckout(t, dir, "main") + writeFile(t, dir, "other.txt", "other work") + gitAdd(t, dir, "other.txt") + gitCommit(t, dir, "unrelated work on main") + + testutil.GitCheckout(t, dir, "feature") + testutil.GitRebase(t, dir, "main") + + rebasedHead := testutil.GitRevParse(t, dir, "HEAD") + rebasedParent := testutil.GitRevParse(t, dir, "HEAD~1") + + patchIDAfter, err := ComputePatchID(context.Background(), dir, rebasedParent, rebasedHead) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if patchIDBefore != patchIDAfter { + t.Errorf("patch ID should survive clean rebase: before=%s, after=%s", patchIDBefore, patchIDAfter) + } +} + +func TestComputePatchID_InitialCommit(t *testing.T) { + t.Parallel() + dir := initTestRepo(t) + + writeFile(t, dir, "file.txt", "initial") + gitAdd(t, dir, "file.txt") + gitCommit(t, dir, "initial") + + head := testutil.GitRevParse(t, dir, "HEAD") + + patchID, err := ComputePatchID(context.Background(), dir, "", head) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if patchID == "" { + t.Fatal("expected non-empty patch ID for initial commit") + } +} diff --git a/cmd/entire/cli/session/phase.go b/cmd/entire/cli/session/phase.go index 2f7c931ae6..12c30db022 100644 --- a/cmd/entire/cli/session/phase.go +++ b/cmd/entire/cli/session/phase.go @@ -119,8 +119,8 @@ func (a Action) String() string { // TransitionContext provides read-only context for transitions that need // to inspect session state without mutating it. type TransitionContext struct { - HasFilesTouched bool // len(FilesTouched) > 0 - IsRebaseInProgress bool // .git/rebase-merge/ or .git/rebase-apply/ exists + HasFilesTouched bool // len(FilesTouched) > 0 + IsSequenceOperationInProgress bool // rebase/cherry-pick/revert is in progress } // TransitionResult holds the outcome of a state machine transition. @@ -163,7 +163,7 @@ func transitionFromIdle(event Event, ctx TransitionContext) TransitionResult { // Turn end while idle is a no-op (no active turn to end). return TransitionResult{NewPhase: PhaseIdle} case EventGitCommit: - if ctx.IsRebaseInProgress { + if ctx.IsSequenceOperationInProgress { return TransitionResult{NewPhase: PhaseIdle} } return TransitionResult{ @@ -203,7 +203,7 @@ func transitionFromActive(event Event, ctx TransitionContext) TransitionResult { Actions: []Action{ActionUpdateLastInteraction}, } case EventGitCommit: - if ctx.IsRebaseInProgress { + if ctx.IsSequenceOperationInProgress { return TransitionResult{NewPhase: PhaseActive} } return TransitionResult{ @@ -243,7 +243,7 @@ func transitionFromEnded(event Event, ctx TransitionContext) TransitionResult { // Turn end while ended is a no-op. return TransitionResult{NewPhase: PhaseEnded} case EventGitCommit: - if ctx.IsRebaseInProgress { + if ctx.IsSequenceOperationInProgress { return TransitionResult{NewPhase: PhaseEnded} } if ctx.HasFilesTouched { @@ -388,12 +388,12 @@ func MermaidDiagram() string { variants = []contextVariant{ {"[files]", TransitionContext{HasFilesTouched: true}}, {"[no files]", TransitionContext{HasFilesTouched: false}}, - {"[rebase]", TransitionContext{IsRebaseInProgress: true}}, + {"[rebase]", TransitionContext{IsSequenceOperationInProgress: true}}, } case event == EventGitCommit: variants = []contextVariant{ {"", TransitionContext{}}, - {"[rebase]", TransitionContext{IsRebaseInProgress: true}}, + {"[rebase]", TransitionContext{IsSequenceOperationInProgress: true}}, } default: variants = []contextVariant{ diff --git a/cmd/entire/cli/session/phase_test.go b/cmd/entire/cli/session/phase_test.go index 95cb001bd9..39250ec2f4 100644 --- a/cmd/entire/cli/session/phase_test.go +++ b/cmd/entire/cli/session/phase_test.go @@ -146,7 +146,7 @@ func TestTransitionFromIdle(t *testing.T) { name: "GitCommit_rebase_skips_everything", current: PhaseIdle, event: EventGitCommit, - ctx: TransitionContext{IsRebaseInProgress: true}, + ctx: TransitionContext{IsSequenceOperationInProgress: true}, wantPhase: PhaseIdle, wantActions: nil, }, @@ -202,7 +202,7 @@ func TestTransitionFromActive(t *testing.T) { name: "GitCommit_rebase_skips_everything", current: PhaseActive, event: EventGitCommit, - ctx: TransitionContext{IsRebaseInProgress: true}, + ctx: TransitionContext{IsSequenceOperationInProgress: true}, wantPhase: PhaseActive, wantActions: nil, }, @@ -252,7 +252,7 @@ func TestTransitionFromEnded(t *testing.T) { name: "GitCommit_rebase_skips_everything", current: PhaseEnded, event: EventGitCommit, - ctx: TransitionContext{IsRebaseInProgress: true}, + ctx: TransitionContext{IsSequenceOperationInProgress: true}, wantPhase: PhaseEnded, wantActions: nil, }, @@ -331,7 +331,7 @@ func TestTransitionBackwardCompat(t *testing.T) { func TestTransition_rebase_GitCommit_is_noop_for_all_phases(t *testing.T) { t.Parallel() - rebaseCtx := TransitionContext{IsRebaseInProgress: true} + rebaseCtx := TransitionContext{IsSequenceOperationInProgress: true} for _, phase := range allPhases { result := Transition(phase, EventGitCommit, rebaseCtx) diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 6943ce42d1..200edae6f5 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -94,13 +94,14 @@ func (s *ManualCommitStrategy) getCheckpointLog(ctx context.Context, checkpointI // condenseOpts provides pre-resolved git objects to avoid redundant reads. type condenseOpts struct { - shadowRef *plumbing.Reference // Pre-resolved shadow branch ref (nil = resolve from repo) - headTree *object.Tree // Pre-resolved HEAD tree (passed through to calculateSessionAttributions) - parentTree *object.Tree // Pre-resolved parent tree (nil for initial commits, for consistent non-agent line counting) - repoDir string // Repository worktree path for git CLI commands - parentCommitHash string // HEAD's first parent hash for per-commit non-agent file detection - headCommitHash string // HEAD commit hash (passed through for attribution) - allAgentFiles map[string]struct{} // Union of all sessions' FilesTouched for cross-session exclusion (nil = single-session) + shadowRef *plumbing.Reference // Pre-resolved shadow branch ref (nil = resolve from repo) + headTree *object.Tree // Pre-resolved HEAD tree (passed through to calculateSessionAttributions) + parentTree *object.Tree // Pre-resolved parent tree (nil for initial commits, for consistent non-agent line counting) + repoDir string // Repository worktree path for git CLI commands + parentCommitHash string // HEAD's first parent hash for per-commit non-agent file detection + headCommitHash string // HEAD commit hash (passed through for attribution) + allAgentFiles map[string]struct{} // Union of all sessions' FilesTouched for cross-session exclusion (nil = single-session) + linkage *cpkg.LinkageMetadata // Content-based signals for re-linking after history rewrites } var redactSessionJSONLBytes = redact.JSONLBytes @@ -233,6 +234,7 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re AuthorEmail: authorEmail, Agent: state.AgentType, Model: state.ModelName, + Linkage: o.linkage, TurnID: state.TurnID, TranscriptIdentifierAtStart: state.TranscriptIdentifierAtStart, CheckpointTranscriptStart: state.CheckpointTranscriptStart, diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 5285c7a25f..e0f28352f1 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -352,37 +352,86 @@ func stripCheckpointTrailer(message string) string { return strings.Join(result, "\n") } -// isGitSequenceOperation checks if git is currently in the middle of a rebase, +type gitSequenceOperation int + +const ( + gitSequenceOperationNone gitSequenceOperation = iota + gitSequenceOperationRebase + gitSequenceOperationCherryPick + gitSequenceOperationRevert +) + +// getGitSequenceOperation checks if git is currently in the middle of a rebase, // cherry-pick, or revert operation. During these operations, commits are being -// replayed and should not be linked to agent sessions. -// -// Detects: -// - rebase: .git/rebase-merge/ or .git/rebase-apply/ directories -// - cherry-pick: .git/CHERRY_PICK_HEAD file -// - revert: .git/REVERT_HEAD file -func isGitSequenceOperation(ctx context.Context) bool { +// replayed and should not generally be linked to agent sessions. +func getGitSequenceOperation(ctx context.Context) gitSequenceOperation { // Get git directory (handles worktrees and relative paths correctly) gitDir, err := GetGitDir(ctx) if err != nil { - return false // Can't determine, assume not in sequence operation + return gitSequenceOperationNone // Can't determine, assume not in sequence operation } // Check for rebase state directories if _, err := os.Stat(filepath.Join(gitDir, "rebase-merge")); err == nil { - return true + return gitSequenceOperationRebase } if _, err := os.Stat(filepath.Join(gitDir, "rebase-apply")); err == nil { - return true + return gitSequenceOperationRebase } // Check for cherry-pick and revert state files if _, err := os.Stat(filepath.Join(gitDir, "CHERRY_PICK_HEAD")); err == nil { - return true + return gitSequenceOperationCherryPick } if _, err := os.Stat(filepath.Join(gitDir, "REVERT_HEAD")); err == nil { + return gitSequenceOperationRevert + } + + return gitSequenceOperationNone +} + +func isGitSequenceOperation(ctx context.Context) bool { + return getGitSequenceOperation(ctx) != gitSequenceOperationNone +} + +func (s *ManualCommitStrategy) shouldSkipPrepareCommitSequenceOp( + ctx context.Context, + logCtx context.Context, + sequenceOp gitSequenceOperation, + source string, +) bool { + if sequenceOp == gitSequenceOperationNone { + return false + } + + // Rebase is always skipped here. Replayed commits are not new checkpoints, + // and adding a trailer during rebase can stamp rewritten commits with an + // unrelated checkpoint ID from the current session. + if sequenceOp == gitSequenceOperationRebase { + logging.Debug(logCtx, "prepare-commit-msg: skipped during git rebase", + slog.String("strategy", "manual-commit"), + slog.String("source", source), + ) return true } + // Revert/cherry-pick are skipped unless an agent session is ACTIVE. When an + // agent runs git revert/cherry-pick as part of its work, we only reuse an + // existing checkpoint ID. PostCommit still skips condensation while the + // sequence operation is in progress, so minting a new checkpoint ID here + // would leave a dangling trailer on the commit. + if !s.hasActiveSessionInWorktree(ctx) { + logging.Debug(logCtx, "prepare-commit-msg: skipped during git sequence operation (no active session)", + slog.String("strategy", "manual-commit"), + slog.String("source", source), + ) + return true + } + + logging.Debug(logCtx, "prepare-commit-msg: sequence operation with active session, proceeding", + slog.String("strategy", "manual-commit"), + slog.String("source", source), + ) return false } @@ -402,14 +451,8 @@ func isGitSequenceOperation(ctx context.Context) bool { func (s *ManualCommitStrategy) PrepareCommitMsg(ctx context.Context, commitMsgFile string, source string) error { logCtx := logging.WithComponent(ctx, "checkpoint") - - // Skip during rebase, cherry-pick, or revert operations - // These are replaying existing commits and should not be linked to agent sessions - if isGitSequenceOperation(ctx) { - logging.Debug(logCtx, "prepare-commit-msg: skipped during git sequence operation", - slog.String("strategy", "manual-commit"), - slog.String("source", source), - ) + sequenceOp := getGitSequenceOperation(ctx) + if s.shouldSkipPrepareCommitSequenceOp(ctx, logCtx, sequenceOp, source) { return nil } @@ -506,16 +549,8 @@ func (s *ManualCommitStrategy) PrepareCommitMsg(ctx context.Context, commitMsgFi } readCommitMessageSpan.End() - // Generate a fresh checkpoint ID and resolve session metadata - _, resolveMetadataSpan := perf.Start(ctx, "resolve_session_metadata") - checkpointID, err := id.Generate() - if err != nil { - resolveMetadataSpan.RecordError(err) - resolveMetadataSpan.End() - return fmt.Errorf("failed to generate checkpoint ID: %w", err) - } - // Determine agent type and last prompt from session + _, resolveMetadataSpan := perf.Start(ctx, "resolve_session_metadata") var agentType types.AgentType var lastPrompt string if len(sessionsWithContent) > 0 { @@ -534,6 +569,17 @@ func (s *ManualCommitStrategy) PrepareCommitMsg(ctx context.Context, commitMsgFi if stngs, loadErr := settings.Load(ctx); loadErr == nil { commitLinking = stngs.GetCommitLinking() } + + checkpointID, shouldSkip, err := resolveCheckpointIDForPrepareCommit(logCtx, sequenceOp, source, sessionsWithContent) + if err != nil { + resolveMetadataSpan.RecordError(err) + resolveMetadataSpan.End() + return fmt.Errorf("failed to resolve checkpoint ID: %w", err) + } + if shouldSkip { + resolveMetadataSpan.End() + return nil + } resolveMetadataSpan.End() // Add trailer differently based on commit source @@ -598,6 +644,52 @@ func (s *ManualCommitStrategy) PrepareCommitMsg(ctx context.Context, commitMsgFi return nil } +// singleReusableCheckpointID returns a reusable checkpoint ID only when exactly +// one session is eligible. Two sessions carrying the same LastCheckpointID are +// still treated as ambiguous because the commit cannot be attributed to one +// active session deterministically. +func singleReusableCheckpointID(states []*SessionState) (id.CheckpointID, bool) { + var checkpointID id.CheckpointID + eligibleCount := 0 + for _, state := range states { + if state.LastCheckpointID.IsEmpty() { + continue + } + eligibleCount++ + checkpointID = state.LastCheckpointID + } + if eligibleCount != 1 { + return "", false + } + return checkpointID, true +} + +func resolveCheckpointIDForPrepareCommit( + logCtx context.Context, + sequenceOp gitSequenceOperation, + source string, + sessions []*SessionState, +) (id.CheckpointID, bool, error) { + if sequenceOp == gitSequenceOperationNone { + checkpointID, err := id.Generate() + if err != nil { + return "", false, fmt.Errorf("generate checkpoint ID: %w", err) + } + return checkpointID, false, nil + } + + checkpointID, ok := singleReusableCheckpointID(sessions) + if !ok { + logging.Debug(logCtx, "prepare-commit-msg: skipped sequence operation without a unique reusable checkpoint ID", + slog.String("strategy", "manual-commit"), + slog.String("source", source), + ) + return "", true, nil + } + + return checkpointID, false, nil +} + // handleAmendCommitMsg handles the prepare-commit-msg hook for amend operations // (source="commit"). It preserves existing trailers or restores from LastCheckpointID. func (s *ManualCommitStrategy) handleAmendCommitMsg(ctx context.Context, commitMsgFile string) error { @@ -710,14 +802,15 @@ type postCommitActionHandler struct { filesTouchedBefore []string sessionsWithCommittedFiles int // number of processable sessions that have tracked files - // Cached git objects — resolved once per PostCommit invocation to avoid - // redundant reads across filesOverlapWithContent, filesWithRemainingAgentChanges, + // Cached git objects — resolved once per session handler to avoid redundant + // reads across filesOverlapWithContent, filesWithRemainingAgentChanges, // CondenseSession, and calculateSessionAttributions. - headTree *object.Tree // HEAD commit tree (shared across all sessions) - parentTree *object.Tree // HEAD's first parent tree (shared, nil for initial commits) - shadowRef *plumbing.Reference // Per-session shadow branch ref (nil if branch doesn't exist) - shadowTree *object.Tree // Per-session shadow commit tree (nil if branch doesn't exist) - allAgentFiles map[string]struct{} // Union of all sessions' FilesTouched for cross-session attribution + headTree *object.Tree // HEAD commit tree (shared across all sessions) + parentTree *object.Tree // HEAD's first parent tree (shared, nil for initial commits) + shadowRef *plumbing.Reference // Per-session shadow branch ref (nil if branch doesn't exist) + shadowTree *object.Tree // Per-session shadow commit tree (nil if branch doesn't exist) + allAgentFiles map[string]struct{} // Union of all sessions' FilesTouched for cross-session attribution + baseLinkage *checkpoint.LinkageMetadata // Commit-level linkage signals (computed once, shared across sessions) // Output: set by handler methods, read by caller after TransitionAndLog. condensed bool @@ -731,6 +824,36 @@ func (h *postCommitActionHandler) parentCommitHash() string { return "" } +// computeBaseLinkage computes commit-level linkage signals (tree hash, patch ID). +// These are identical across sessions since they depend on the commit, not the +// session. They are cached on the per-session handler to avoid duplicate work +// within one session's PostCommit flow. +func (h *postCommitActionHandler) computeBaseLinkage(ctx context.Context) { + logCtx := logging.WithComponent(ctx, "checkpoint") + h.baseLinkage = &checkpoint.LinkageMetadata{ + TreeHash: h.commit.TreeHash.String(), + } + + // Compute patch ID (diff content hash — survives rebase) + patchID, err := gitops.ComputePatchID(ctx, h.repoDir, h.parentCommitHash(), h.newHead) + if err != nil { + logging.Warn(logCtx, "failed to compute patch ID for linkage", + slog.String("commit", h.newHead), + slog.String("error", err.Error()), + ) + } else { + h.baseLinkage.PatchID = patchID + } +} + +// linkageForCommit returns the cached commit-level linkage metadata. +func (h *postCommitActionHandler) linkageForCommit(ctx context.Context) *checkpoint.LinkageMetadata { + if h.baseLinkage == nil { + h.computeBaseLinkage(ctx) + } + return h.baseLinkage +} + func (h *postCommitActionHandler) HandleCondense(state *session.State) error { logCtx := logging.WithComponent(h.ctx, "checkpoint") shouldCondense := h.shouldCondenseWithOverlapCheck(state.Phase.IsActive(), state.LastInteractionTime) @@ -752,6 +875,7 @@ func (h *postCommitActionHandler) HandleCondense(state *session.State) error { parentCommitHash: h.parentCommitHash(), headCommitHash: h.newHead, allAgentFiles: h.allAgentFiles, + linkage: h.linkageForCommit(h.ctx), }) } else { h.s.updateBaseCommitIfChanged(h.ctx, state, h.newHead) @@ -781,6 +905,7 @@ func (h *postCommitActionHandler) HandleCondenseIfFilesTouched(state *session.St parentCommitHash: h.parentCommitHash(), headCommitHash: h.newHead, allAgentFiles: h.allAgentFiles, + linkage: h.linkageForCommit(h.ctx), }) } else { h.s.updateBaseCommitIfChanged(h.ctx, state, h.newHead) @@ -974,12 +1099,12 @@ func (s *ManualCommitStrategy) PostCommit(ctx context.Context) error { //nolint: } // Build transition context - isRebase := isGitSequenceOperation(ctx) + isSequenceOperation := isGitSequenceOperation(ctx) transitionCtx := session.TransitionContext{ - IsRebaseInProgress: isRebase, + IsSequenceOperationInProgress: isSequenceOperation, } - if isRebase { + if isSequenceOperation { logging.Debug(logCtx, "post-commit: rebase/sequence in progress, skipping phase transitions", slog.String("strategy", "manual-commit"), ) diff --git a/cmd/entire/cli/strategy/manual_commit_session.go b/cmd/entire/cli/strategy/manual_commit_session.go index f446529110..ebc84376be 100644 --- a/cmd/entire/cli/strategy/manual_commit_session.go +++ b/cmd/entire/cli/strategy/manual_commit_session.go @@ -3,11 +3,13 @@ package strategy import ( "context" "fmt" + "log/slog" "time" "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/versioninfo" @@ -148,6 +150,32 @@ func (s *ManualCommitStrategy) findSessionsForWorktree(ctx context.Context, work return matching, nil } +// hasActiveSessionInWorktree returns true if any session in the current worktree +// is in ACTIVE phase. Used to distinguish agent-initiated git operations (revert, +// cherry-pick) from user-initiated ones. Agent-initiated operations should be +// checkpointed; user-initiated ones should be skipped. +func (s *ManualCommitStrategy) hasActiveSessionInWorktree(ctx context.Context) bool { + logCtx := logging.WithComponent(ctx, "checkpoint") + worktreePath, err := paths.WorktreeRoot(ctx) + if err != nil { + logging.Debug(logCtx, "hasActiveSessionInWorktree: failed to get worktree root", + slog.String("error", err.Error())) + return false + } + sessions, err := s.findSessionsForWorktree(ctx, worktreePath) + if err != nil { + logging.Debug(logCtx, "hasActiveSessionInWorktree: failed to find sessions", + slog.String("error", err.Error())) + return false + } + for _, state := range sessions { + if state.Phase.IsActive() { + return true + } + } + return false +} + type rewritePair struct { OldSHA string NewSHA string diff --git a/cmd/entire/cli/strategy/manual_commit_test.go b/cmd/entire/cli/strategy/manual_commit_test.go index 3d3dcdd570..ecc8a4c844 100644 --- a/cmd/entire/cli/strategy/manual_commit_test.go +++ b/cmd/entire/cli/strategy/manual_commit_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "os" "path/filepath" "strings" @@ -16,16 +17,20 @@ import ( "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/testutil" "github.com/entireio/cli/cmd/entire/cli/trailers" "github.com/entireio/cli/redact" "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/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const testTrailerCheckpointID id.CheckpointID = "a1b2c3d4e5f6" +const testRevertCommitMessage = "Revert \"add feature\"\n" +const testCherryPickCommitMessage = "Cherry-pick \"add feature\"\n" // testTranscriptPromptResponse is a minimal transcript used across strategy tests. const testTranscriptPromptResponse = "{\"type\":\"human\",\"message\":{\"content\":\"test prompt\"}}\n{\"type\":\"assistant\",\"message\":{\"content\":\"test response\"}}\n" @@ -866,6 +871,308 @@ func TestShadowStrategy_PrepareCommitMsg_SkipsSessionWhenContentCheckFails(t *te require.Equal(t, originalMsg, string(content)) } +// TestShadowStrategy_PrepareCommitMsg_AgentRevertReusesLastCheckpointID verifies +// that an agent-driven revert/cherry-pick only reuses an existing checkpoint ID. +// Sequence-operation commits are not condensed in PostCommit, so generating a +// fresh trailer here would leave a dangling checkpoint link. +func TestShadowStrategy_PrepareCommitMsg_AgentRevertReusesLastCheckpointID(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + t.Setenv("ENTIRE_TEST_TTY", "1") + + s := &ManualCommitStrategy{} + + // Create an ACTIVE session (agent is running) + err := s.InitializeSession(context.Background(), "agent-revert-session", agent.AgentTypeClaudeCode, "", "revert the change", "") + require.NoError(t, err) + + // Save a checkpoint so there's content + metaDir := filepath.Join(".entire", "metadata", "agent-revert-session") + require.NoError(t, os.MkdirAll(filepath.Join(dir, metaDir), 0o755)) + transcript := `{"type":"human","message":{"content":"revert the change"}}` + "\n" + + `{"type":"assistant","message":{"content":"I'll revert that"}}` + "\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, metaDir, "full.jsonl"), []byte(transcript), 0o644)) + + err = s.SaveStep(context.Background(), StepContext{ + SessionID: "agent-revert-session", + MetadataDir: metaDir, + ModifiedFiles: []string{"test.txt"}, + NewFiles: []string{}, + AgentType: agent.AgentTypeClaudeCode, + }) + require.NoError(t, err) + + state, err := s.loadSessionState(context.Background(), "agent-revert-session") + require.NoError(t, err) + state.LastCheckpointID = testTrailerCheckpointID + require.NoError(t, s.saveSessionState(context.Background(), state)) + + // Simulate REVERT_HEAD existing (git revert in progress) + gitDir, err := GetGitDir(context.Background()) + require.NoError(t, err) + revertHeadPath := filepath.Join(gitDir, "REVERT_HEAD") + require.NoError(t, os.WriteFile(revertHeadPath, []byte("fake-revert-head"), 0o644)) + defer os.Remove(revertHeadPath) + + // PrepareCommitMsg should reuse the existing checkpoint trailer. + commitMsgFile := filepath.Join(t.TempDir(), "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(commitMsgFile, []byte(testRevertCommitMessage), 0o644)) + + err = s.PrepareCommitMsg(context.Background(), commitMsgFile, "") + require.NoError(t, err) + + content, err := os.ReadFile(commitMsgFile) + require.NoError(t, err) + + cpID, found := trailers.ParseCheckpoint(string(content)) + assert.True(t, found, "agent-initiated revert should reuse an existing checkpoint trailer") + assert.Equal(t, testTrailerCheckpointID, cpID) +} + +func TestShadowStrategy_PrepareCommitMsg_AgentCherryPickReusesLastCheckpointID(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + t.Setenv("ENTIRE_TEST_TTY", "1") + + s := &ManualCommitStrategy{} + + err := s.InitializeSession(context.Background(), "agent-cherry-pick-session", agent.AgentTypeClaudeCode, "", "cherry-pick the change", "") + require.NoError(t, err) + + metaDir := filepath.Join(".entire", "metadata", "agent-cherry-pick-session") + require.NoError(t, os.MkdirAll(filepath.Join(dir, metaDir), 0o755)) + transcript := `{"type":"human","message":{"content":"cherry-pick the change"}}` + "\n" + + `{"type":"assistant","message":{"content":"I'll cherry-pick that"}}` + "\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, metaDir, "full.jsonl"), []byte(transcript), 0o644)) + + err = s.SaveStep(context.Background(), StepContext{ + SessionID: "agent-cherry-pick-session", + MetadataDir: metaDir, + ModifiedFiles: []string{"test.txt"}, + NewFiles: []string{}, + AgentType: agent.AgentTypeClaudeCode, + }) + require.NoError(t, err) + + state, err := s.loadSessionState(context.Background(), "agent-cherry-pick-session") + require.NoError(t, err) + state.LastCheckpointID = testTrailerCheckpointID + require.NoError(t, s.saveSessionState(context.Background(), state)) + + gitDir, err := GetGitDir(context.Background()) + require.NoError(t, err) + cherryPickHeadPath := filepath.Join(gitDir, "CHERRY_PICK_HEAD") + require.NoError(t, os.WriteFile(cherryPickHeadPath, []byte("fake-cherry-pick-head"), 0o644)) + defer os.Remove(cherryPickHeadPath) + + commitMsgFile := filepath.Join(t.TempDir(), "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(commitMsgFile, []byte(testCherryPickCommitMessage), 0o644)) + + err = s.PrepareCommitMsg(context.Background(), commitMsgFile, "") + require.NoError(t, err) + + content, err := os.ReadFile(commitMsgFile) + require.NoError(t, err) + + cpID, found := trailers.ParseCheckpoint(string(content)) + assert.True(t, found, "agent-initiated cherry-pick should reuse an existing checkpoint trailer") + assert.Equal(t, testTrailerCheckpointID, cpID) +} + +// TestShadowStrategy_PrepareCommitMsg_AgentRevertWithoutCheckpointIDSkipped verifies +// that sequence-operation commits do not mint a fresh checkpoint ID when the +// session has never condensed. That would create a dangling trailer because +// PostCommit still skips condensation during the sequence operation. +func TestShadowStrategy_PrepareCommitMsg_AgentRevertWithoutCheckpointIDSkipped(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + t.Setenv("ENTIRE_TEST_TTY", "1") + + s := &ManualCommitStrategy{} + + err := s.InitializeSession(context.Background(), "agent-revert-no-checkpoint", agent.AgentTypeClaudeCode, "", "revert the change", "") + require.NoError(t, err) + + metaDir := filepath.Join(".entire", "metadata", "agent-revert-no-checkpoint") + require.NoError(t, os.MkdirAll(filepath.Join(dir, metaDir), 0o755)) + transcript := `{"type":"human","message":{"content":"revert the change"}}` + "\n" + + `{"type":"assistant","message":{"content":"I'll revert that"}}` + "\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, metaDir, "full.jsonl"), []byte(transcript), 0o644)) + + err = s.SaveStep(context.Background(), StepContext{ + SessionID: "agent-revert-no-checkpoint", + MetadataDir: metaDir, + ModifiedFiles: []string{"test.txt"}, + AgentType: agent.AgentTypeClaudeCode, + }) + require.NoError(t, err) + + gitDir, err := GetGitDir(context.Background()) + require.NoError(t, err) + revertHeadPath := filepath.Join(gitDir, "REVERT_HEAD") + require.NoError(t, os.WriteFile(revertHeadPath, []byte("fake-revert-head"), 0o644)) + defer os.Remove(revertHeadPath) + + commitMsgFile := filepath.Join(t.TempDir(), "COMMIT_EDITMSG") + originalMsg := testRevertCommitMessage + require.NoError(t, os.WriteFile(commitMsgFile, []byte(originalMsg), 0o644)) + + err = s.PrepareCommitMsg(context.Background(), commitMsgFile, "") + require.NoError(t, err) + + content, err := os.ReadFile(commitMsgFile) + require.NoError(t, err) + + _, found := trailers.ParseCheckpoint(string(content)) + assert.False(t, found, "sequence operations without LastCheckpointID should not get a new trailer") + assert.Equal(t, originalMsg, string(content)) +} + +func TestShadowStrategy_PrepareCommitMsg_AgentRebaseSkipped(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + t.Setenv("ENTIRE_TEST_TTY", "1") + + s := &ManualCommitStrategy{} + + err := s.InitializeSession(context.Background(), "agent-rebase-session", agent.AgentTypeClaudeCode, "", "rebase the branch", "") + require.NoError(t, err) + + metaDir := filepath.Join(".entire", "metadata", "agent-rebase-session") + require.NoError(t, os.MkdirAll(filepath.Join(dir, metaDir), 0o755)) + transcript := `{"type":"human","message":{"content":"rebase the branch"}}` + "\n" + + `{"type":"assistant","message":{"content":"I'll rebase that"}}` + "\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, metaDir, "full.jsonl"), []byte(transcript), 0o644)) + + err = s.SaveStep(context.Background(), StepContext{ + SessionID: "agent-rebase-session", + MetadataDir: metaDir, + ModifiedFiles: []string{"test.txt"}, + AgentType: agent.AgentTypeClaudeCode, + }) + require.NoError(t, err) + + state, err := s.loadSessionState(context.Background(), "agent-rebase-session") + require.NoError(t, err) + state.LastCheckpointID = testTrailerCheckpointID + require.NoError(t, s.saveSessionState(context.Background(), state)) + + gitDir, err := GetGitDir(context.Background()) + require.NoError(t, err) + rebaseDir := filepath.Join(gitDir, "rebase-merge") + require.NoError(t, os.MkdirAll(rebaseDir, 0o755)) + defer os.RemoveAll(rebaseDir) + + commitMsgFile := filepath.Join(t.TempDir(), "COMMIT_EDITMSG") + originalMsg := "Reword commit during rebase\n" + require.NoError(t, os.WriteFile(commitMsgFile, []byte(originalMsg), 0o644)) + + err = s.PrepareCommitMsg(context.Background(), commitMsgFile, "") + require.NoError(t, err) + + content, err := os.ReadFile(commitMsgFile) + require.NoError(t, err) + + _, found := trailers.ParseCheckpoint(string(content)) + assert.False(t, found, "rebase commits should not reuse a checkpoint trailer") + assert.Equal(t, originalMsg, string(content)) +} + +func TestShadowStrategy_PrepareCommitMsg_AgentRevertAmbiguousCheckpointSkipped(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + t.Setenv("ENTIRE_TEST_TTY", "1") + + s := &ManualCommitStrategy{} + + sessionIDs := []string{"agent-revert-session-a", "agent-revert-session-b"} + checkpointIDs := []id.CheckpointID{"a1b2c3d4e5f6", "f6e5d4c3b2a1"} + for i, sessionID := range sessionIDs { + err := s.InitializeSession(context.Background(), sessionID, agent.AgentTypeClaudeCode, "", "revert the change", "") + require.NoError(t, err) + + metaDir := filepath.Join(".entire", "metadata", sessionID) + require.NoError(t, os.MkdirAll(filepath.Join(dir, metaDir), 0o755)) + transcript := `{"type":"human","message":{"content":"revert the change"}}` + "\n" + + `{"type":"assistant","message":{"content":"I'll revert that"}}` + "\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, metaDir, "full.jsonl"), []byte(transcript), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, fmt.Sprintf("test-%d.txt", i)), []byte(fmt.Sprintf("content-%d", i)), 0o644)) + + err = s.SaveStep(context.Background(), StepContext{ + SessionID: sessionID, + MetadataDir: metaDir, + ModifiedFiles: []string{fmt.Sprintf("test-%d.txt", i)}, + AgentType: agent.AgentTypeClaudeCode, + }) + require.NoError(t, err) + + state, err := s.loadSessionState(context.Background(), sessionID) + require.NoError(t, err) + state.LastCheckpointID = checkpointIDs[i] + require.NoError(t, s.saveSessionState(context.Background(), state)) + } + + gitDir, err := GetGitDir(context.Background()) + require.NoError(t, err) + revertHeadPath := filepath.Join(gitDir, "REVERT_HEAD") + require.NoError(t, os.WriteFile(revertHeadPath, []byte("fake-revert-head"), 0o644)) + defer os.Remove(revertHeadPath) + + commitMsgFile := filepath.Join(t.TempDir(), "COMMIT_EDITMSG") + originalMsg := testRevertCommitMessage + require.NoError(t, os.WriteFile(commitMsgFile, []byte(originalMsg), 0o644)) + + err = s.PrepareCommitMsg(context.Background(), commitMsgFile, "") + require.NoError(t, err) + + content, err := os.ReadFile(commitMsgFile) + require.NoError(t, err) + + _, found := trailers.ParseCheckpoint(string(content)) + assert.False(t, found, "ambiguous sequence operations should not reuse a checkpoint trailer") + assert.Equal(t, originalMsg, string(content)) +} + +// TestShadowStrategy_PrepareCommitMsg_UserRevertSkipped verifies that when a user +// runs git revert manually (no ACTIVE session), the commit does NOT get a trailer. +func TestShadowStrategy_PrepareCommitMsg_UserRevertSkipped(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + t.Setenv("ENTIRE_TEST_TTY", "1") + + s := &ManualCommitStrategy{} + + // Create an IDLE session (agent finished, user is now doing manual work) + err := s.InitializeSession(context.Background(), "idle-session-revert", agent.AgentTypeClaudeCode, "", "done", "") + require.NoError(t, err) + + state, err := s.loadSessionState(context.Background(), "idle-session-revert") + require.NoError(t, err) + require.NoError(t, TransitionAndLog(context.Background(), state, session.EventTurnEnd, session.TransitionContext{}, session.NoOpActionHandler{})) + require.NoError(t, s.saveSessionState(context.Background(), state)) + + // Simulate REVERT_HEAD existing + gitDir, err := GetGitDir(context.Background()) + require.NoError(t, err) + revertHeadPath := filepath.Join(gitDir, "REVERT_HEAD") + require.NoError(t, os.WriteFile(revertHeadPath, []byte("fake-revert-head"), 0o644)) + defer os.Remove(revertHeadPath) + + // PrepareCommitMsg should skip (no ACTIVE session = user doing the revert) + commitMsgFile := filepath.Join(t.TempDir(), "COMMIT_EDITMSG") + originalMsg := testRevertCommitMessage + require.NoError(t, os.WriteFile(commitMsgFile, []byte(originalMsg), 0o644)) + + err = s.PrepareCommitMsg(context.Background(), commitMsgFile, "") + require.NoError(t, err) + + content, err := os.ReadFile(commitMsgFile) + require.NoError(t, err) + + _, found := trailers.ParseCheckpoint(string(content)) + assert.False(t, found, "user-initiated revert (no active session) should not get a trailer") +} + func TestAddCheckpointTrailer_NoComment(t *testing.T) { // Test that addCheckpointTrailer adds trailer without any comment lines message := "Test commit message\n" //nolint:goconst // already present in codebase @@ -4718,6 +5025,54 @@ func TestMarshalPromptAttributionsIncludingPending_OnlyPending(t *testing.T) { require.Equal(t, 7, result[0].UserLinesAdded) } +// TestShadowStrategy_PostCommit_LinkagePopulated verifies the linkage pipeline: +// PostCommit computes tree_hash and patch_id, passes them through condensation, +// and the committed checkpoint stores both fields. +func TestShadowStrategy_PostCommit_LinkagePopulated(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + s := &ManualCommitStrategy{} + sessionID := "linkage-pipeline-session" + + // Initialize session and save a checkpoint so the shadow branch has content. + // setupGitRepo creates one initial commit; commitWithCheckpointTrailer will + // create a second, giving us a parent for patch-id computation. + setupSessionWithCheckpoint(t, s, repo, dir, sessionID) + + // Create a commit WITH the Entire-Checkpoint trailer on the main branch. + // The checkpoint ID here will be used by PostCommit for condensation. + checkpointIDStr := "f1e2d3c4b5a6" + commitWithCheckpointTrailer(t, repo, dir, checkpointIDStr) + + // Trigger PostCommit — this should condense with linkage signals + err = s.PostCommit(context.Background()) + require.NoError(t, err) + + // Re-open the repo to pick up any ref changes from condensation + repo, err = git.PlainOpen(dir) + require.NoError(t, err) + + // Read back the committed checkpoint from the metadata branch + store := checkpoint.NewGitStore(repo) + cpID := id.MustCheckpointID(checkpointIDStr) + summary, err := store.ReadCommitted(context.Background(), cpID) + require.NoError(t, err) + require.NotNil(t, summary, "checkpoint should exist on metadata branch after PostCommit") + + // Verify linkage is populated with the supported fallback signals. + require.NotNil(t, summary.Linkage, "Linkage should be populated after condensation") + assert.NotEmpty(t, summary.Linkage.TreeHash, "TreeHash should be set") + assert.NotEmpty(t, summary.Linkage.PatchID, "PatchID should be set") + + // Verify hash format and lengths. + assert.Len(t, summary.Linkage.TreeHash, 40, "TreeHash should be 40-char hex (git tree hash)") + assert.Len(t, summary.Linkage.PatchID, 40, "PatchID should be 40-char hex (git patch-id)") +} + func TestCommittedFilesExcludingMetadata_AllMetadata(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/testutil/testutil.go b/cmd/entire/cli/testutil/testutil.go index 474e6f1372..e199d0d57a 100644 --- a/cmd/entire/cli/testutil/testutil.go +++ b/cmd/entire/cli/testutil/testutil.go @@ -325,3 +325,38 @@ func GitIsolatedEnv() []string { "GIT_CONFIG_SYSTEM="+gitEmptyConfigPath(), // Isolate from system git config ) } + +// GitRevParse returns the full commit hash for a given ref (e.g., "HEAD", "HEAD~1"). +func GitRevParse(t *testing.T, repoDir, ref string) string { + t.Helper() + //nolint:noctx // test code, no context needed for git rev-parse + cmd := exec.Command("git", "rev-parse", ref) + cmd.Dir = repoDir + out, err := cmd.Output() + if err != nil { + t.Fatalf("git rev-parse %s failed: %v", ref, err) + } + return strings.TrimSpace(string(out)) +} + +// GitCheckout checks out an existing branch. +func GitCheckout(t *testing.T, repoDir, branchName string) { + t.Helper() + //nolint:noctx // test code, no context needed for git checkout + cmd := exec.Command("git", "checkout", branchName) + cmd.Dir = repoDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git checkout %s failed: %v\n%s", branchName, err, string(out)) + } +} + +// GitRebase rebases the current branch onto the given base. +func GitRebase(t *testing.T, repoDir, onto string) { + t.Helper() + //nolint:noctx // test code, no context needed for git rebase + cmd := exec.Command("git", "rebase", onto) + cmd.Dir = repoDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git rebase %s failed: %v\n%s", onto, err, string(out)) + } +}