diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index 75ab1ed66..34993dc35 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -262,6 +262,11 @@ type WriteCommittedOptions struct { // Model is the LLM model used during the session (e.g., "claude-sonnet-4-20250514") Model string + // TreeHash is the git tree hash of the commit this checkpoint is linked to. + // Used as a fallback to re-link checkpoints when the Entire-Checkpoint trailer + // is stripped by git history rewrites (rebase, filter-branch, amend). + TreeHash string + // TurnID correlates checkpoints from the same agent turn. TurnID string @@ -383,6 +388,11 @@ type CommittedMetadata struct { // Model is the LLM model used during the session (e.g., "claude-sonnet-4-20250514") Model string `json:"model,omitempty"` + // TreeHash is the git tree hash of the commit this checkpoint is linked to. + // Enables fallback re-linking when the Entire-Checkpoint trailer is stripped + // by git history rewrites (rebase, filter-branch, amend). + TreeHash string `json:"tree_hash,omitempty"` + // TurnID correlates checkpoints from the same agent turn. // When a turn's work spans multiple commits, each gets its own checkpoint // but they share the same TurnID for future aggregation/deduplication. diff --git a/cmd/entire/cli/checkpoint/checkpoint_test.go b/cmd/entire/cli/checkpoint/checkpoint_test.go index 06c19e9f8..51111e902 100644 --- a/cmd/entire/cli/checkpoint/checkpoint_test.go +++ b/cmd/entire/cli/checkpoint/checkpoint_test.go @@ -1335,6 +1335,41 @@ func TestListCommitted_MultiSessionInfo(t *testing.T) { } } +// TestWriteCommitted_IncludesTreeHash verifies that tree_hash is stored in +// checkpoint metadata and can be read back. +func TestWriteCommitted_IncludesTreeHash(t *testing.T) { + t.Parallel() + repo, _ := setupBranchTestRepo(t) + store := NewGitStore(repo) + checkpointID := id.MustCheckpointID("aabb11223344") + treeHash := "abc123def456abc123def456abc123def456abc1" + + err := store.WriteCommitted(context.Background(), WriteCommittedOptions{ + CheckpointID: checkpointID, + SessionID: "tree-hash-session", + Strategy: "manual-commit", + Agent: agent.AgentTypeClaudeCode, + TreeHash: treeHash, + Transcript: []byte(`{"type":"human","message":{"content":"test"}}`), + FilesTouched: []string{"file.go"}, + CheckpointsCount: 1, + AuthorName: "Test Author", + AuthorEmail: "test@example.com", + }) + if err != nil { + t.Fatalf("WriteCommitted() error = %v", err) + } + + // Read back session metadata (session index 0) + sessionContent, err := store.ReadSessionContent(context.Background(), checkpointID, 0) + if err != nil { + t.Fatalf("ReadSessionContent() error = %v", err) + } + if sessionContent.Metadata.TreeHash != treeHash { + t.Errorf("TreeHash = %q, want %q", sessionContent.Metadata.TreeHash, treeHash) + } +} + // TestWriteCommitted_SessionWithNoPrompts verifies that a session can be // written without prompts and still be read correctly. func TestWriteCommitted_SessionWithNoPrompts(t *testing.T) { diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 60aa4a243..89cd5a4ca 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -374,6 +374,7 @@ func (s *GitStore) writeSessionToSubdirectory(ctx context.Context, opts WriteCom FilesTouched: opts.FilesTouched, Agent: opts.Agent, Model: opts.Model, + TreeHash: opts.TreeHash, TurnID: opts.TurnID, IsTask: opts.IsTask, ToolUseID: opts.ToolUseID, diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 1f1df3f60..8c0ab68a9 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -96,6 +96,7 @@ type condenseOpts struct { 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) + treeHash string // Tree hash of the commit being condensed (for fallback linkage after history rewrites) } // CondenseSession condenses a session's shadow branch to permanent storage. @@ -230,6 +231,7 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re AuthorEmail: authorEmail, Agent: state.AgentType, Model: state.ModelName, + TreeHash: o.treeHash, 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 c307bee2f..8cb18b413 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -309,14 +309,22 @@ 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. 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 @@ -655,6 +663,7 @@ func (h *postCommitActionHandler) HandleCondense(state *session.State) error { repoDir: h.repoDir, parentCommitHash: h.parentCommitHash(), headCommitHash: h.newHead, + treeHash: h.commit.TreeHash.String(), }) } else { h.s.updateBaseCommitIfChanged(h.ctx, state, h.newHead) @@ -683,6 +692,7 @@ func (h *postCommitActionHandler) HandleCondenseIfFilesTouched(state *session.St repoDir: h.repoDir, parentCommitHash: h.parentCommitHash(), headCommitHash: h.newHead, + treeHash: h.commit.TreeHash.String(), }) } 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 687bf26c9..c7f4278e7 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