diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index b9db5476e..bfb863df7 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -297,6 +297,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). @@ -445,6 +449,23 @@ 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. +// +// The web uses a fallback chain 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 rebase (same diff replayed on different base). + 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) @@ -473,6 +494,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 711bdce54..8c6ddaf99 100644 --- a/cmd/entire/cli/checkpoint/checkpoint_test.go +++ b/cmd/entire/cli/checkpoint/checkpoint_test.go @@ -3624,6 +3624,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: []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: []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 f2b6633f7..f429a7ab3 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -434,6 +434,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 34d8e4c3a..163c571c0 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 33d410662..70ef5fc2d 100644 --- a/cmd/entire/cli/gitops/diff_test.go +++ b/cmd/entire/cli/gitops/diff_test.go @@ -6,6 +6,7 @@ import ( "os/exec" "path/filepath" "sort" + "strings" "testing" ) @@ -31,6 +32,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 @@ -84,6 +87,44 @@ func gitCommit(t *testing.T, dir, msg string) { } } +func revParse(t *testing.T, dir, ref string) string { + t.Helper() + cmd := exec.CommandContext(context.Background(), "git", "rev-parse", ref) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + t.Fatalf("git rev-parse %s failed: %v", ref, err) + } + return strings.TrimSpace(string(out)) +} + +func gitCheckoutBranch(t *testing.T, dir, branchName string) { + t.Helper() + cmd := exec.CommandContext(context.Background(), "git", "checkout", "-b", branchName) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git checkout -b %s failed: %v\n%s", branchName, err, out) + } +} + +func gitCheckout(t *testing.T, dir, ref string) { + t.Helper() + cmd := exec.CommandContext(context.Background(), "git", "checkout", ref) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git checkout %s failed: %v\n%s", ref, err, out) + } +} + +func gitRebase(t *testing.T, dir, onto string) { + t.Helper() + cmd := exec.CommandContext(context.Background(), "git", "rebase", onto) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git rebase %s failed: %v\n%s", onto, err, out) + } +} + func TestDiffTreeFiles_NormalCommit(t *testing.T) { t.Parallel() dir := initTestRepo(t) @@ -405,3 +446,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 := revParse(t, dir, "HEAD") + parent := revParse(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") + + gitCheckoutBranch(t, dir, "feature") + writeFile(t, dir, "feature.txt", "feature work") + gitAdd(t, dir, "feature.txt") + gitCommit(t, dir, "add feature") + + featureHead := revParse(t, dir, "HEAD") + featureParent := revParse(t, dir, "HEAD~1") + + patchIDBefore, err := ComputePatchID(context.Background(), dir, featureParent, featureHead) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + gitCheckout(t, dir, "main") + writeFile(t, dir, "other.txt", "other work") + gitAdd(t, dir, "other.txt") + gitCommit(t, dir, "unrelated work on main") + + gitCheckout(t, dir, "feature") + gitRebase(t, dir, "main") + + rebasedHead := revParse(t, dir, "HEAD") + rebasedParent := revParse(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 := revParse(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/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index b84d6458c..a0ae0d3c3 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -90,13 +90,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 } // CondenseSession condenses a session's shadow branch to permanent storage. @@ -229,6 +230,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 486aa8fa0..7f75b14c1 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -309,14 +309,28 @@ 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 + // Skip during rebase, cherry-pick, or revert operations — UNLESS an agent + // session is ACTIVE. When an agent runs git revert/cherry-pick as part of + // its work, the commit should be checkpointed. When the user does it + // manually (no active session), skip as before. + // + // Note: The trailer is added here, but condensation is deferred. PostCommit's + // state machine skips ActionCondense when IsRebaseInProgress=true (sequence + // operation files like REVERT_HEAD still exist during post-commit). The + // checkpoint data is preserved on the shadow branch and will be condensed + // on the next normal commit or when the session ends (TurnEnd/Stop). if isGitSequenceOperation(ctx) { - logging.Debug(logCtx, "prepare-commit-msg: skipped during git sequence operation", + 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 nil + } + logging.Debug(logCtx, "prepare-commit-msg: sequence operation with active session, proceeding", slog.String("strategy", "manual-commit"), slog.String("source", source), ) - return nil } // Skip for merge and squash sources @@ -616,14 +630,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 @@ -637,6 +652,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) @@ -658,6 +703,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) @@ -687,6 +733,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) diff --git a/cmd/entire/cli/strategy/manual_commit_session.go b/cmd/entire/cli/strategy/manual_commit_session.go index ad4d852fd..cecc4ffee 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 +} + // findSessionsForCommit finds all sessions where base_commit matches the given SHA. func (s *ManualCommitStrategy) findSessionsForCommit(ctx context.Context, baseCommitSHA string) ([]*SessionState, error) { allStates, err := s.listAllSessionStates(ctx) diff --git a/cmd/entire/cli/strategy/manual_commit_test.go b/cmd/entire/cli/strategy/manual_commit_test.go index ae5c08450..370a3f96d 100644 --- a/cmd/entire/cli/strategy/manual_commit_test.go +++ b/cmd/entire/cli/strategy/manual_commit_test.go @@ -15,10 +15,12 @@ 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/trailers" "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" ) @@ -863,6 +865,97 @@ func TestShadowStrategy_PrepareCommitMsg_SkipsSessionWhenContentCheckFails(t *te require.Equal(t, originalMsg, string(content)) } +// TestShadowStrategy_PrepareCommitMsg_AgentRevertGetsTrailer verifies that when an +// agent runs git revert (REVERT_HEAD exists) and the session is ACTIVE, the commit +// gets a checkpoint trailer. The agent's work should be checkpointed. +func TestShadowStrategy_PrepareCommitMsg_AgentRevertGetsTrailer(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) + + // 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 add a trailer (active session = agent doing the revert) + commitMsgFile := filepath.Join(t.TempDir(), "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(commitMsgFile, []byte("Revert \"add feature\"\n"), 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.True(t, found, "agent-initiated revert should get a checkpoint trailer") +} + +// 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 := "Revert \"add feature\"\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, "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 @@ -4193,6 +4286,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 474e6f137..e199d0d57 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)) + } +}