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
6 changes: 6 additions & 0 deletions cmd/entire/cli/session/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ type State struct {
// Generated once when first needed, reused across all commits in the session.
PendingCheckpointID string `json:"pending_checkpoint_id,omitempty"`

// PendingPushRemote is the git remote name from a push that happened while
// condensation was deferred (ACTIVE_COMMITTED phase). After condensation
// completes at turn-end, entire/checkpoints/v1 is pushed to this remote.
// Cleared after push or on new prompt start.
PendingPushRemote string `json:"pending_push_remote,omitempty"`

// LastInteractionTime is updated on every hook invocation.
// Used for stale session detection in "entire doctor".
LastInteractionTime *time.Time `json:"last_interaction_time,omitempty"`
Expand Down
5 changes: 5 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -1248,6 +1248,9 @@ func (s *ManualCommitStrategy) InitializeSession(sessionID string, agentType age
if state.PendingCheckpointID != "" {
state.PendingCheckpointID = ""
}
if state.PendingPushRemote != "" {
state.PendingPushRemote = ""
}

// Calculate attribution at prompt start (BEFORE agent makes any changes)
// This captures user edits since the last checkpoint (or base commit for first prompt).
Expand Down Expand Up @@ -1480,6 +1483,8 @@ func (s *ManualCommitStrategy) HandleTurnEnd(state *SessionState, actions []sess

// handleTurnEndCondense performs deferred condensation at turn end.
func (s *ManualCommitStrategy) handleTurnEndCondense(logCtx context.Context, state *SessionState) {
defer s.flushPendingPush(logCtx, state)

repo, err := OpenRepository()
if err != nil {
logging.Warn(logCtx, "turn-end condense: failed to open repo",
Expand Down
64 changes: 63 additions & 1 deletion cmd/entire/cli/strategy/manual_commit_push.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
package strategy

import "github.com/entireio/cli/cmd/entire/cli/paths"
import (
"context"
"log/slog"

"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/session"
)

// PrePush is called by the git pre-push hook before pushing to a remote.
// It pushes the entire/checkpoints/v1 branch alongside the user's push.
Expand All @@ -9,5 +16,60 @@ import "github.com/entireio/cli/cmd/entire/cli/paths"
// - "prompt" (default): ask user with option to enable auto
// - "false"/"off"/"no": never push
func (s *ManualCommitStrategy) PrePush(remote string) error {
s.recordPendingPushRemote(remote)
return pushSessionsBranchCommon(remote, paths.MetadataBranchName)
}

// flushPendingPush pushes the metadata branch if a push was recorded while
// condensation was deferred, then clears PendingPushRemote. Intended to be
// called via defer so that all exit paths in handleTurnEndCondense clear the
// field. The caller is responsible for persisting state after this returns.
func (s *ManualCommitStrategy) flushPendingPush(logCtx context.Context, state *SessionState) {
if state.PendingPushRemote == "" {
return
}
remote := state.PendingPushRemote
state.PendingPushRemote = ""
logging.Info(logCtx, "turn-end: pushing checkpoint data", slog.String("remote", remote))
if pushErr := pushSessionsBranchCommon(remote, paths.MetadataBranchName); pushErr != nil {
logging.Warn(logCtx, "turn-end: failed to push checkpoint data",
slog.String("remote", remote), slog.String("error", pushErr.Error()))
}
}

// recordPendingPushRemote records the push remote on all sessions that have
// deferred condensation (ACTIVE_COMMITTED with PendingCheckpointID). Each
// session's flushPendingPush handles its own state independently at turn-end.
func (s *ManualCommitStrategy) recordPendingPushRemote(remote string) {
logCtx := logging.WithComponent(context.Background(), "checkpoint")

worktreePath, err := GetWorktreePath()
if err != nil {
logging.Debug(logCtx, "recordPendingPushRemote: failed to get worktree path",
slog.String("error", err.Error()))
return
}

sessions, err := s.findSessionsForWorktree(worktreePath)
if err != nil || len(sessions) == 0 {
return
}

for _, state := range sessions {
if state.Phase != session.PhaseActiveCommitted || state.PendingCheckpointID == "" {
continue
}

state.PendingPushRemote = remote
if err := s.saveSessionState(state); err != nil {
logging.Warn(logCtx, "recordPendingPushRemote: failed to save session state",
slog.String("session_id", state.SessionID),
slog.String("error", err.Error()))
continue
}

logging.Info(logCtx, "pre-push: recorded pending push remote for deferred condensation",
slog.String("session_id", state.SessionID),
slog.String("remote", remote))
}
}
249 changes: 249 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_push_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
package strategy

import (
"testing"
"time"

"github.com/entireio/cli/cmd/entire/cli/session"

"github.com/go-git/go-git/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestRecordPendingPushRemote_ActiveCommittedSession verifies that
// recordPendingPushRemote sets PendingPushRemote on ACTIVE_COMMITTED sessions
// that have a PendingCheckpointID.
func TestRecordPendingPushRemote_ActiveCommittedSession(t *testing.T) {
dir := setupGitRepo(t)
t.Chdir(dir)

repo, err := git.PlainOpen(dir)
require.NoError(t, err)

s := &ManualCommitStrategy{}
sessionID := "test-push-active-committed"

setupSessionWithCheckpoint(t, s, repo, dir, sessionID)

state, err := s.loadSessionState(sessionID)
require.NoError(t, err)
state.Phase = session.PhaseActiveCommitted
state.PendingCheckpointID = "a1b2c3d4e5f6"
require.NoError(t, s.saveSessionState(state))

s.recordPendingPushRemote("origin")

state, err = s.loadSessionState(sessionID)
require.NoError(t, err)
assert.Equal(t, "origin", state.PendingPushRemote,
"PendingPushRemote should be set for ACTIVE_COMMITTED session with PendingCheckpointID")
}

// TestRecordPendingPushRemote_IdleSession_Skipped verifies that
// recordPendingPushRemote does not set PendingPushRemote on IDLE sessions.
func TestRecordPendingPushRemote_IdleSession_Skipped(t *testing.T) {
dir := setupGitRepo(t)
t.Chdir(dir)

repo, err := git.PlainOpen(dir)
require.NoError(t, err)

s := &ManualCommitStrategy{}
sessionID := "test-push-idle-skipped"

setupSessionWithCheckpoint(t, s, repo, dir, sessionID)

state, err := s.loadSessionState(sessionID)
require.NoError(t, err)
state.Phase = session.PhaseIdle
state.PendingCheckpointID = "a1b2c3d4e5f6"
require.NoError(t, s.saveSessionState(state))

s.recordPendingPushRemote("origin")

state, err = s.loadSessionState(sessionID)
require.NoError(t, err)
assert.Empty(t, state.PendingPushRemote,
"PendingPushRemote should not be set for IDLE session")
}

// TestRecordPendingPushRemote_ActiveCommitted_NoPendingID_Skipped verifies that
// recordPendingPushRemote does not set PendingPushRemote on ACTIVE_COMMITTED
// sessions that have no PendingCheckpointID.
func TestRecordPendingPushRemote_ActiveCommitted_NoPendingID_Skipped(t *testing.T) {
dir := setupGitRepo(t)
t.Chdir(dir)

repo, err := git.PlainOpen(dir)
require.NoError(t, err)

s := &ManualCommitStrategy{}
sessionID := "test-push-ac-no-pending-id"

setupSessionWithCheckpoint(t, s, repo, dir, sessionID)

state, err := s.loadSessionState(sessionID)
require.NoError(t, err)
state.Phase = session.PhaseActiveCommitted
state.PendingCheckpointID = ""
require.NoError(t, s.saveSessionState(state))

s.recordPendingPushRemote("origin")

state, err = s.loadSessionState(sessionID)
require.NoError(t, err)
assert.Empty(t, state.PendingPushRemote,
"PendingPushRemote should not be set when PendingCheckpointID is empty")
}

// TestRecordPendingPushRemote_NoSessions_Noop verifies that
// recordPendingPushRemote is a no-op when there are no sessions.
func TestRecordPendingPushRemote_NoSessions_Noop(t *testing.T) {
dir := setupGitRepo(t)
t.Chdir(dir)

s := &ManualCommitStrategy{}

// Should not panic or error with no sessions
s.recordPendingPushRemote("origin")
}

// TestHandleTurnEnd_PushAfterCondensation verifies the full flow:
// PostCommit defers condensation (ACTIVE → ACTIVE_COMMITTED),
// recordPendingPushRemote records the remote, and HandleTurnEnd
// condenses and clears PendingPushRemote.
func TestHandleTurnEnd_PushAfterCondensation(t *testing.T) {
dir := setupGitRepo(t)
t.Chdir(dir)

repo, err := git.PlainOpen(dir)
require.NoError(t, err)

s := &ManualCommitStrategy{}
sessionID := "test-turnend-push"

setupSessionWithCheckpoint(t, s, repo, dir, sessionID)

// Simulate agent mid-turn
state, err := s.loadSessionState(sessionID)
require.NoError(t, err)
state.Phase = session.PhaseActive
require.NoError(t, s.saveSessionState(state))

// Agent commits → PostCommit transitions ACTIVE → ACTIVE_COMMITTED
commitWithCheckpointTrailer(t, repo, dir, "d4e5f6a1b2c3")
err = s.PostCommit()
require.NoError(t, err)

state, err = s.loadSessionState(sessionID)
require.NoError(t, err)
require.Equal(t, session.PhaseActiveCommitted, state.Phase)
require.NotEmpty(t, state.PendingCheckpointID)

// Agent pushes → recordPendingPushRemote records remote
s.recordPendingPushRemote("origin")

state, err = s.loadSessionState(sessionID)
require.NoError(t, err)
assert.Equal(t, "origin", state.PendingPushRemote)

// Turn ends → HandleTurnEnd condenses and clears PendingPushRemote
result := session.Transition(state.Phase, session.EventTurnEnd, session.TransitionContext{})
remaining := session.ApplyCommonActions(state, result)

err = s.HandleTurnEnd(state, remaining)
require.NoError(t, err)

// PendingPushRemote should be cleared after turn-end
assert.Empty(t, state.PendingPushRemote,
"PendingPushRemote should be cleared after turn-end push")

// Condensation should have succeeded
assert.Equal(t, 0, state.StepCount,
"StepCount should be reset after condensation")
}

// TestHandleTurnEnd_PushClearedWhenNoNewContent verifies that PendingPushRemote
// is cleared even when handleTurnEndCondense returns early due to no new
// transcript content (the !hasNew path at the early return).
func TestHandleTurnEnd_PushClearedWhenNoNewContent(t *testing.T) {
dir := setupGitRepo(t)
t.Chdir(dir)

repo, err := git.PlainOpen(dir)
require.NoError(t, err)

s := &ManualCommitStrategy{}
sessionID := "test-turnend-push-no-content"

setupSessionWithCheckpoint(t, s, repo, dir, sessionID)

// Simulate agent mid-turn
state, err := s.loadSessionState(sessionID)
require.NoError(t, err)
state.Phase = session.PhaseActive
require.NoError(t, s.saveSessionState(state))

// Agent commits → PostCommit transitions ACTIVE → ACTIVE_COMMITTED
commitWithCheckpointTrailer(t, repo, dir, "d4e5f6a1b2c3")
err = s.PostCommit()
require.NoError(t, err)

state, err = s.loadSessionState(sessionID)
require.NoError(t, err)
require.Equal(t, session.PhaseActiveCommitted, state.Phase)

// Record pending push remote
s.recordPendingPushRemote("origin")
state, err = s.loadSessionState(sessionID)
require.NoError(t, err)
require.Equal(t, "origin", state.PendingPushRemote)

// Set CheckpointTranscriptStart = 2 so sessionHasNewContent returns false
// (transcript has exactly 2 lines from setupSessionWithCheckpoint)
state.CheckpointTranscriptStart = 2
require.NoError(t, s.saveSessionState(state))

// Turn ends → HandleTurnEnd should still clear PendingPushRemote
result := session.Transition(state.Phase, session.EventTurnEnd, session.TransitionContext{})
remaining := session.ApplyCommonActions(state, result)

err = s.HandleTurnEnd(state, remaining)
require.NoError(t, err)

assert.Empty(t, state.PendingPushRemote,
"PendingPushRemote should be cleared even when no new content to condense")
}

// TestInitializeSession_ClearsPendingPushRemote verifies that PendingPushRemote
// is cleared on new prompt start (handles Ctrl-C recovery case).
func TestInitializeSession_ClearsPendingPushRemote(t *testing.T) {
dir := setupGitRepo(t)
t.Chdir(dir)

s := &ManualCommitStrategy{}
sessionID := "test-init-clears-push-remote"

// First call creates the session
err := s.InitializeSession(sessionID, "Claude Code", "", "first prompt")
require.NoError(t, err)

// Simulate stale PendingPushRemote (e.g., from a Ctrl-C before turn-end)
state, err := s.loadSessionState(sessionID)
require.NoError(t, err)
state.PendingPushRemote = "origin"
state.Phase = session.PhaseIdle
now := time.Now()
state.LastInteractionTime = &now
require.NoError(t, s.saveSessionState(state))

// Second call should clear PendingPushRemote
err = s.InitializeSession(sessionID, "Claude Code", "", "second prompt")
require.NoError(t, err)

state, err = s.loadSessionState(sessionID)
require.NoError(t, err)
assert.Empty(t, state.PendingPushRemote,
"PendingPushRemote should be cleared on new prompt start")
}