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
18 changes: 11 additions & 7 deletions cmd/entire/cli/strategy/manual_commit_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,27 +327,29 @@ func (s *ManualCommitStrategy) findSessionsForWorktree(ctx context.Context, work
if err != nil {
return nil, err
}
matches, _ := s.findSessionsForWorktreeFromStates(ctx, allStates, worktreePath)
matches, _, _ := s.findSessionsForWorktreeFromStates(ctx, allStates, worktreePath)
return matches, nil
}

// findSessionsForWorktreeFromStates is findSessionsForWorktree over an
// already-loaded state list. The second result reports a multi-worktree
// ambiguity decline — candidates existed but spanned several worktrees, so
// nothing was linked; findSessionsForCommitLinking surfaces it to the user
// only when identity matching cannot rescue the commit either.
func (s *ManualCommitStrategy) findSessionsForWorktreeFromStates(ctx context.Context, allStates []*SessionState, worktreePath string) ([]*SessionState, bool) {
// only when identity matching cannot rescue the commit either. The third
// reports that no session is homed in worktreePath, so the matches (if any)
// are sessions of other worktrees from the fallback.
func (s *ManualCommitStrategy) findSessionsForWorktreeFromStates(ctx context.Context, allStates []*SessionState, worktreePath string) (matches []*SessionState, ambiguous, fromFallback bool) {
exact, notHome := matchWorktreeHomes(ctx, allStates, worktreePath)
if len(exact) > 0 {
return exact, false
return exact, false, false
}

worktreeCommonDir, err := gitCommonDirForWorktree(ctx, worktreePath)
if err != nil {
logging.Debug(logging.WithComponent(ctx, "checkpoint"),
"session matching: cannot resolve common dir for fallback matching",
slog.String("error", err.Error()))
return nil, false
return nil, false, true
}

var parentWorktreeMatches []*SessionState
Expand Down Expand Up @@ -377,10 +379,12 @@ func (s *ManualCommitStrategy) findSessionsForWorktreeFromStates(ctx context.Con
commonDirMatches = append(commonDirMatches, state)
}

candidates := commonDirMatches
if len(parentWorktreeMatches) > 0 {
return resolveWorktreeCandidates(ctx, worktreePath, parentWorktreeMatches)
candidates = parentWorktreeMatches
}
return resolveWorktreeCandidates(ctx, worktreePath, commonDirMatches)
matches, ambiguous = resolveWorktreeCandidates(ctx, worktreePath, candidates)
return matches, ambiguous, true
}

// recentSessionWindow bounds the liveness filter below: a session that
Expand Down
20 changes: 16 additions & 4 deletions cmd/entire/cli/strategy/session_identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ import (
// content, so every session with pending content here belongs in it —
// concurrent sessions interleave by design) and the identity-matched session
// when the committing process's ancestry names one that worktree matching
// missed. There is no precedence between the two — an identity hit must
// never suppress worktree matches, or concurrent same-worktree sessions
// would drop out of the commit. The identity union is what makes an
// missed. An identity hit never suppresses sessions homed in this worktree,
// or concurrent same-worktree sessions would drop out of the commit. It does
// replace the fallback's matches when no session is homed here: those belong
// to other worktrees, and a session there that edited this worktree through a
// shell command mid-turn (no record of it until turn end) is dropped too.
// Without an identified committer (Windows, no recorded owner) the fallback's
// matches still link. The identity union is what makes an
// agent-made commit immune to worktree bookkeeping drift: an agent
// committing in a sibling worktree still links to its own session
// (guest-linked), where path matching alone found nothing or declined as
Expand All @@ -35,8 +39,16 @@ func (s *ManualCommitStrategy) findSessionsForCommitLinking(ctx context.Context,
// rescue this; report it to the caller (hooks log and skip).
return nil, err
}
sessions, ambiguous := s.findSessionsForWorktreeFromStates(ctx, allStates, worktreePath)
sessions, ambiguous, fromFallback := s.findSessionsForWorktreeFromStates(ctx, allStates, worktreePath)
guest := s.findSessionByCommitAncestry(ctx, allStates)
if guest != nil && fromFallback {
// No session is homed here and ancestry names the committer: the
// fallback's sessions belong to other worktrees, and one there that
// is mid-turn would otherwise be condensed into this commit by the
// shortcuts that skip content checks. A session that recorded edits
// here still joins below, held to them.
sessions = nil
}
if guest != nil && !linkingSetContains(sessions, guest.SessionID) {
sessions = append(sessions, guest)
}
Expand Down
53 changes: 53 additions & 0 deletions cmd/entire/cli/strategy/session_identity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -531,3 +531,56 @@ func TestIsNearerOwner_Contract(t *testing.T) {
})
}
}

// ENT-2463 through the commit hooks: the committer, started in the main
// checkout, commits from a sibling worktree where no session is homed, while
// another main-checkout session is mid-turn with pending work. Only the
// committer is stamped and condensed; the other session keeps its pending
// work for its own commit.
//
// Not parallel: uses t.Chdir().
func TestCommitHooks_IdentifiedCommitterLeavesMainCheckoutSessionsPending(t *testing.T) {
ctx := context.Background()
homeDir, err := filepath.EvalSymlinks(setupGitRepo(t))
require.NoError(t, err, "the recorded home must match git's worktree list")
t.Chdir(homeDir)
homeRepo, err := gitrepo.OpenPath(homeDir)
require.NoError(t, err)
t.Cleanup(func() { _ = homeRepo.Close() })

s := NewManualCommitStrategy()
setupSessionWithCheckpoint(t, s, homeRepo, homeDir, "sess-unrelated")
unrelated, err := s.loadSessionState(ctx, "sess-unrelated")
require.NoError(t, err)
unrelated.WorktreePath = homeDir
unrelated.Phase = session.PhaseActive
require.NoError(t, s.saveSessionState(ctx, unrelated))
stepsBefore := unrelated.StepCount
require.Positive(t, stepsBefore, "sanity: the unrelated session has pending work")

setupSessionWithCheckpointAndFile(t, s, homeDir, "sess-committer", "notes.txt")
committer, err := s.loadSessionState(ctx, "sess-committer")
require.NoError(t, err)
committer.Owner = selfAncestorOwner(t)
committer.WorktreePath = homeDir
committer.Phase = session.PhaseActive
require.NoError(t, s.saveSessionState(ctx, committer))

guestDir := addSiblingWorktree(t, homeDir, "committer-wt")
t.Chdir(guestDir)
testutil.WriteFile(t, guestDir, "guest.txt", "committer's shell edit")
testutil.GitAdd(t, guestDir, "guest.txt")
msg := prepareMessage(t, "committer's change\n")
cps := checkpointIDs(msg)
require.Len(t, cps, 1, "the committer's commit is stamped: %q", msg)
testutil.GitCommit(t, guestDir, msg)
require.NoError(t, NewManualCommitStrategy().PostCommit(ctx))

unrelated, err = s.loadSessionState(ctx, "sess-unrelated")
require.NoError(t, err)
assert.Equal(t, stepsBefore, unrelated.StepCount, "the main checkout's mid-turn session keeps its pending work")
assert.NotEqual(t, cps[0], unrelated.LastCheckpointID.String(), "the main checkout's session is not condensed into the commit")
committer, err = s.loadSessionState(ctx, "sess-committer")
require.NoError(t, err)
assert.Equal(t, cps[0], committer.LastCheckpointID.String(), "the stamped checkpoint is the committer's")
}
2 changes: 1 addition & 1 deletion cmd/entire/cli/strategy/worktree_home_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ func TestFindSessionsForWorktree_FindsAMovedHome(t *testing.T) {

chdirWorktree(t, moved)
states := mustListStates(t.Context(), t, s)
matches, ambiguous := s.findSessionsForWorktreeFromStates(t.Context(), states, moved)
matches, ambiguous, _ := s.findSessionsForWorktreeFromStates(t.Context(), states, moved)
require.False(t, ambiguous)
require.Len(t, matches, 1, "the moved home's commit found no session")
assert.Equal(t, moved, matches[0].WorktreePath, "the moved home isn't seen as the session's home")
Expand Down
119 changes: 119 additions & 0 deletions cmd/entire/cli/strategy/worktree_work_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package strategy

import (
"context"
"maps"
"os"
"path/filepath"
"slices"
"testing"
"time"

Expand Down Expand Up @@ -316,3 +318,120 @@ func TestStoredSessionRoot_InWorktreeViewReadsTheHomesCopy(t *testing.T) {
leave()
assert.Empty(t, state.ViewHomePath)
}

// linkingIDs returns the IDs findSessionsForCommitLinking links to a commit
// made in root.
func linkingIDs(t *testing.T, root string) []string {
t.Helper()
sessions := linkingSessions(t, root)
ids := make([]string, 0, len(sessions))
for id := range sessions {
ids = append(ids, id)
}
return ids
}

// linkingSessions is linkingIDs keyed to the linked states.
func linkingSessions(t *testing.T, root string) map[string]*SessionState {
t.Helper()
t.Chdir(root)
clearSessionMatchCaches()
sessions, err := (&ManualCommitStrategy{}).findSessionsForCommitLinking(context.Background(), root)
require.NoError(t, err)
byID := make(map[string]*SessionState, len(sessions))
for _, st := range sessions {
byID[st.SessionID] = st
}
return byID
}

// ENT-2463: an agent started in the main checkout moved into a linked
// worktree (Claude Code's EnterWorktree) and committed there. No session is
// homed in that worktree, so the fallback matched every session of the main
// checkout, and each one mid-turn was condensed into the commit. Once process
// ancestry identifies the committer, a session from another worktree joins
// only through its record of this worktree.
func TestCommitLinking_IdentifiedCommitterKeepsOtherWorktreesSessionsOut(t *testing.T) {
for _, layout := range []struct{ name, linked string }{
{"sibling worktree", ""},
{"worktree nested under the main checkout", ".worktrees/nested"},
} {
t.Run(layout.name, func(t *testing.T) {
ctx := context.Background()
s := &ManualCommitStrategy{}
var mainDir, linkedDir string
if layout.linked == "" {
mainDir, linkedDir = twoWorktrees(t)
} else {
testutil.IsolateGitConfigEnv(t)
mainDir = setupSessionMatchRepo(t)
linkedDir = filepath.Join(mainDir, layout.linked)
createSessionMatchWorktree(t, mainDir, linkedDir, "nested")
t.Cleanup(func() { removeSessionMatchWorktree(mainDir, linkedDir) })
}
saveSessionMatchState(ctx, t, s, mainDir, &SessionState{SessionID: "committer", WorktreePath: mainDir, Owner: selfAncestorOwner(t)})
saveSessionMatchState(ctx, t, s, mainDir, &SessionState{SessionID: "unrelated", WorktreePath: mainDir})
testutil.WriteFile(t, linkedDir, "a.go", "package a\n")
saveSessionMatchState(ctx, t, s, mainDir, &SessionState{
SessionID: "edited-here",
WorktreePath: mainDir,
StepCount: 1,
OtherWorktrees: map[string]*session.WorktreeWork{
linkedKey(t, linkedDir): {Path: linkedDir, FilesTouched: []string{"a.go"}, TouchedFileHashes: hashTouchedFiles(ctx, linkedDir, []string{"a.go"}), Incarnation: mark(t, linkedDir)},
},
})

linked := linkingSessions(t, linkedDir)
assert.ElementsMatch(t, []string{"committer", "edited-here"}, slices.Collect(maps.Keys(linked)),
"an identified committer keeps the main checkout's other sessions off its commit unless they recorded edits here")
if st := linked["edited-here"]; assert.NotNil(t, st) {
assert.True(t, st.LinkedByWorktreeEntry, "a session that joins through its record of this worktree is held to it")
}
})
}
}

// Without an identified committer (no recorded agent process, or one that is
// not an ancestor of the commit) the fallback still links the main checkout's
// sessions: nothing else names the commit's session.
func TestCommitLinking_FallbackWithoutIdentityKeepsMainCheckoutSessions(t *testing.T) {
mainDir, linkedDir := twoWorktrees(t)
ctx := context.Background()
s := &ManualCommitStrategy{}
saveSessionMatchState(ctx, t, s, mainDir, &SessionState{SessionID: "first", WorktreePath: mainDir})
saveSessionMatchState(ctx, t, s, mainDir, &SessionState{SessionID: "second", WorktreePath: mainDir})

assert.ElementsMatch(t, []string{"first", "second"}, linkingIDs(t, linkedDir))
}

// A session homed in the committing worktree stays linked alongside an
// identified committer from elsewhere: the identity match adds to the
// worktree's own sessions, never replaces them.
func TestCommitLinking_IdentifiedCommitterKeepsTheWorktreesOwnSessions(t *testing.T) {
mainDir, linkedDir := twoWorktrees(t)
ctx := context.Background()
s := &ManualCommitStrategy{}
saveSessionMatchState(ctx, t, s, mainDir, &SessionState{SessionID: "committer", WorktreePath: mainDir, Owner: selfAncestorOwner(t)})
saveSessionMatchState(ctx, t, s, linkedDir, &SessionState{SessionID: "homed-here", WorktreePath: linkedDir})

assert.ElementsMatch(t, []string{"committer", "homed-here"}, linkingIDs(t, linkedDir))
}

// The committer is homed in a third worktree, so the fallback sees sessions of
// two worktrees and keeps the recently interacting one: the main checkout's,
// while the committer sat in a long tool call. Identity names the committer,
// and the main checkout's session stays off its commit.
func TestCommitLinking_IdentifiedCommitterFromThirdWorktree(t *testing.T) {
mainDir, linkedDir := twoWorktrees(t)
thirdDir := filepath.Join(resolvedTempDir(t), "third")
createSessionMatchWorktree(t, mainDir, thirdDir, "third")
t.Cleanup(func() { removeSessionMatchWorktree(mainDir, thirdDir) })
ctx := context.Background()
s := &ManualCommitStrategy{}
now := time.Now()
earlier := now.Add(-2 * recentSessionWindow)
saveSessionMatchState(ctx, t, s, thirdDir, &SessionState{SessionID: "committer", WorktreePath: thirdDir, Owner: selfAncestorOwner(t), LastInteractionTime: &earlier})
saveSessionMatchState(ctx, t, s, mainDir, &SessionState{SessionID: "main-session", WorktreePath: mainDir, LastInteractionTime: &now})

assert.ElementsMatch(t, []string{"committer"}, linkingIDs(t, linkedDir))
}
Loading
Loading