Skip to content
Draft
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
90 changes: 90 additions & 0 deletions cmd/entire/cli/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,17 @@ import (
"github.com/entireio/cli/cmd/entire/cli/settings"
"github.com/entireio/cli/cmd/entire/cli/strategy"
"github.com/entireio/cli/cmd/entire/cli/stringutil"
"github.com/entireio/cli/cmd/entire/cli/trailers"

"github.com/go-git/go-git/v6"
"github.com/spf13/cobra"
)

type headLinkage struct {
commitHash string
checkpointID string
}

func newStatusCmd() *cobra.Command {
var detailed bool

Expand Down Expand Up @@ -244,6 +251,12 @@ func writeActiveSessions(ctx context.Context, w io.Writer, sty statusStyles) {
return
}

repoRoot, head, headErr := currentHeadLinkage(ctx)
divergenceWarnings := make(map[string]string)
if headErr == nil && repoRoot != "" && head.commitHash != "" {
divergenceWarnings = reconcileActiveSessionHeadDivergence(ctx, store, active, repoRoot, head)
}

// Group by worktree path
groups := make(map[string]*worktreeGroup)
for _, s := range active {
Expand Down Expand Up @@ -342,6 +355,9 @@ func writeActiveSessions(ctx context.Context, w io.Writer, sty statusStyles) {
} else {
fmt.Fprintln(w, sty.render(sty.dim, statsLine))
}
if warning := divergenceWarnings[st.SessionID]; warning != "" {
fmt.Fprintf(w, "%s %s\n", sty.render(sty.yellow, "!"), sty.render(sty.yellow, warning))
}
fmt.Fprintln(w)
}
}
Expand Down Expand Up @@ -427,3 +443,77 @@ func resolveWorktreeBranchGit(ctx context.Context, worktreePath string) string {
}
return detachedHEADDisplay
}

func currentHeadLinkage(ctx context.Context) (string, headLinkage, error) {
repoRoot, err := paths.WorktreeRoot(ctx)
if err != nil {
return "", headLinkage{}, fmt.Errorf("resolve worktree root: %w", err)
}

repo, err := git.PlainOpen(repoRoot)
if err != nil {
return "", headLinkage{}, fmt.Errorf("open repo: %w", err)
}

headRef, err := repo.Head()
if err != nil {
return "", headLinkage{}, fmt.Errorf("resolve HEAD: %w", err)
}

commit, err := repo.CommitObject(headRef.Hash())
if err != nil {
return "", headLinkage{}, fmt.Errorf("load HEAD commit: %w", err)
}

head := headLinkage{commitHash: headRef.Hash().String()}
if checkpointIDs := trailers.ParseAllCheckpoints(commit.Message); len(checkpointIDs) > 0 {
head.checkpointID = checkpointIDs[0].String()
}
Comment on lines +468 to +471
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

currentHeadLinkage only records the first Entire-Checkpoint trailer (checkpointIDs[0]). trailers.ParseAllCheckpoints explicitly supports multiple checkpoint trailers (e.g., squash merges) while preserving order, so HEAD may legitimately link to several checkpoints. With the current implementation, a session whose LastCheckpointID matches a later trailer will incorrectly fail to reconcile and will show a divergence warning. Consider storing all parsed checkpoint IDs (e.g., as a []string or a map[string]struct{} in headLinkage) and treating HEAD as matching if LastCheckpointID equals any of them; update the warning text accordingly (e.g., mention the matched/available checkpoint IDs).

Copilot uses AI. Check for mistakes.
Comment on lines +468 to +471
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

The new reset-reconciliation logic is covered for the single-trailer case, but there's no test covering HEAD commits that contain multiple Entire-Checkpoint trailers (which ParseAllCheckpoints supports for squash merges). Adding a status test where HEAD has multiple checkpoint trailers would prevent regressions (especially if reconciliation should succeed when LastCheckpointID matches any trailer).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only first checkpoint ID checked, missing valid matches

Low Severity

currentHeadLinkage calls trailers.ParseAllCheckpoints but only stores the first checkpoint ID in headLinkage.checkpointID. In reconcileActiveSessionHeadDivergence, each session's LastCheckpointID is compared against only this first ID. If HEAD has multiple Entire-Checkpoint trailers (e.g. from a squash merge used as a reset target), sessions whose LastCheckpointID matches a non-first trailer will receive a false divergence warning instead of being auto-reconciled.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f692674. Configure here.


return repoRoot, head, nil
}

func reconcileActiveSessionHeadDivergence(
ctx context.Context,
store *session.StateStore,
active []*session.State,
repoRoot string,
head headLinkage,
) map[string]string {
warnings := make(map[string]string)
normalizedRepoRoot := normalizeWorktreePath(repoRoot)

for _, st := range active {
if normalizeWorktreePath(st.WorktreePath) != normalizedRepoRoot || st.BaseCommit == "" || st.BaseCommit == head.commitHash {
continue
}

if !st.LastCheckpointID.IsEmpty() && head.checkpointID != "" && st.LastCheckpointID.String() == head.checkpointID {
st.BaseCommit = head.commitHash
st.AttributionBaseCommit = head.commitHash
if err := store.Save(ctx, st); err != nil {
warnings[st.SessionID] = "tracking diverged from current HEAD; failed to refresh local linkage state"
}
continue
}

if head.checkpointID != "" {
warnings[st.SessionID] = "tracking diverged from current HEAD; HEAD links to checkpoint " + head.checkpointID
continue
}

warnings[st.SessionID] = "tracking diverged from current HEAD after git history movement"
}

return warnings
}

func normalizeWorktreePath(path string) string {
if path == "" {
return ""
}
if resolved, err := filepath.EvalSymlinks(path); err == nil {
return filepath.Clean(resolved)
}
return filepath.Clean(path)
}
124 changes: 124 additions & 0 deletions cmd/entire/cli/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
Expand All @@ -13,6 +14,7 @@ import (
"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/agent/types"
"github.com/entireio/cli/cmd/entire/cli/session"
"github.com/entireio/cli/cmd/entire/cli/testutil"

"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing/object"
Expand Down Expand Up @@ -584,6 +586,128 @@ func TestWriteActiveSessions_EndedSessionsExcluded(t *testing.T) {
}
}

func TestWriteActiveSessions_ReconcilesKnownCheckpointAfterReset(t *testing.T) {
setupTestRepo(t)

repoDir, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() error = %v", err)
}

testutil.WriteFile(t, repoDir, "tracked.txt", "checkpoint")
testutil.GitAdd(t, repoDir, "tracked.txt")
testutil.GitCommit(t, repoDir, "checkpoint commit\n\nEntire-Checkpoint: abc123def456")
checkpointCommit := testutil.GetHeadHash(t, repoDir)

testutil.WriteFile(t, repoDir, "tracked.txt", "checkpoint\nfollow-up")
testutil.GitAdd(t, repoDir, "tracked.txt")
testutil.GitCommit(t, repoDir, "follow-up commit")
followUpCommit := testutil.GetHeadHash(t, repoDir)

cmd := exec.CommandContext(context.Background(), "git", "reset", "--hard", checkpointCommit)
cmd.Dir = repoDir
cmd.Env = testutil.GitIsolatedEnv()
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git reset --hard failed: %v\nOutput: %s", err, output)
}

store, err := session.NewStateStore(context.Background())
if err != nil {
t.Fatalf("NewStateStore() error = %v", err)
}

now := time.Now()
state := &session.State{
SessionID: "reset-reconcile-session",
WorktreePath: repoDir,
StartedAt: now.Add(-10 * time.Minute),
BaseCommit: followUpCommit,
AttributionBaseCommit: followUpCommit,
LastCheckpointID: "abc123def456",
}
if err := store.Save(context.Background(), state); err != nil {
t.Fatalf("Save() error = %v", err)
}

var buf bytes.Buffer
sty := newStatusStyles(&buf)
writeActiveSessions(context.Background(), &buf, sty)

reloaded, err := store.Load(context.Background(), state.SessionID)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if reloaded.BaseCommit != checkpointCommit {
t.Fatalf("BaseCommit = %q, want %q", reloaded.BaseCommit, checkpointCommit)
}
if reloaded.AttributionBaseCommit != checkpointCommit {
t.Fatalf("AttributionBaseCommit = %q, want %q", reloaded.AttributionBaseCommit, checkpointCommit)
}

if strings.Contains(buf.String(), "tracking diverged from current HEAD") {
t.Fatalf("expected no divergence warning after safe reset reconciliation, got: %s", buf.String())
}
}

func TestWriteActiveSessions_WarnsWhenResetDivergesWithoutKnownCheckpoint(t *testing.T) {
setupTestRepo(t)

repoDir, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() error = %v", err)
}

testutil.WriteFile(t, repoDir, "tracked.txt", "base")
testutil.GitAdd(t, repoDir, "tracked.txt")
testutil.GitCommit(t, repoDir, "base commit")
baseCommit := testutil.GetHeadHash(t, repoDir)

testutil.WriteFile(t, repoDir, "tracked.txt", "base\nfollow-up")
testutil.GitAdd(t, repoDir, "tracked.txt")
testutil.GitCommit(t, repoDir, "follow-up commit")
followUpCommit := testutil.GetHeadHash(t, repoDir)

cmd := exec.CommandContext(context.Background(), "git", "reset", "--hard", baseCommit)
cmd.Dir = repoDir
cmd.Env = testutil.GitIsolatedEnv()
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git reset --hard failed: %v\nOutput: %s", err, output)
}

store, err := session.NewStateStore(context.Background())
if err != nil {
t.Fatalf("NewStateStore() error = %v", err)
}

now := time.Now()
state := &session.State{
SessionID: "reset-warning-session",
WorktreePath: repoDir,
StartedAt: now.Add(-10 * time.Minute),
BaseCommit: followUpCommit,
AttributionBaseCommit: followUpCommit,
LastCheckpointID: "abc123def456",
}
if err := store.Save(context.Background(), state); err != nil {
t.Fatalf("Save() error = %v", err)
}

var buf bytes.Buffer
sty := newStatusStyles(&buf)
writeActiveSessions(context.Background(), &buf, sty)

reloaded, err := store.Load(context.Background(), state.SessionID)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if reloaded.BaseCommit != followUpCommit {
t.Fatalf("BaseCommit = %q, want unchanged %q", reloaded.BaseCommit, followUpCommit)
}
if !strings.Contains(buf.String(), "tracking diverged from current HEAD after git history movement") {
t.Fatalf("expected divergence warning, got: %s", buf.String())
}
}

func TestFormatTokenCount(t *testing.T) {
t.Parallel()

Expand Down
Loading