Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cmd/entire/cli/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions cmd/entire/cli/checkpoint/checkpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions cmd/entire/cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_condensation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 14 additions & 4 deletions cmd/entire/cli/strategy/manual_commit_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent revert trailer added but condensation skipped in PostCommit

Medium Severity

PrepareCommitMsg now adds a checkpoint trailer during agent-initiated revert/cherry-pick (active session + sequence operation), but the PostCommit handler still sets IsRebaseInProgress: true for all sequence operations via isGitSequenceOperation(ctx). Since REVERT_HEAD/CHERRY_PICK_HEAD remain present during the post-commit hook, the state machine receives IsRebaseInProgress: true and emits no ActionCondense, so no checkpoint data is ever written. The commit ends up with a dangling Entire-Checkpoint trailer pointing to a nonexistent checkpoint ID.

Additional Locations (1)
Fix in Cursor Fix in Web

slog.String("strategy", "manual-commit"),
slog.String("source", source),
)
return nil
}

// Skip for merge and squash sources
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
93 changes: 93 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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))
Comment on lines +883 to +887
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this test, metaDir is built with filepath.Join and then passed as StepContext.MetadataDir. MetadataDir is a repo-relative path used for git tree entries/commit trailers, so it needs forward slashes regardless of OS. Using filepath.Join will produce backslashes on Windows, which can break shadow-branch metadata paths and make the test (and any code paths it exercises) behave differently across platforms. Consider keeping MetadataDir as a slash-separated string (e.g., ".entire/metadata/") and deriving an absolute filesystem path separately (e.g., filepath.FromSlash + filepath.Join).

Suggested change
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))
metaDir := ".entire/metadata/agent-revert-session"
metaDirFS := filepath.Join(dir, filepath.FromSlash(metaDir))
require.NoError(t, os.MkdirAll(metaDirFS, 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(metaDirFS, "full.jsonl"), []byte(transcript), 0o644))

Copilot uses AI. Check for mistakes.

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
Expand Down
Loading