diff --git a/cmd/entire/cli/hooks_git_cmd.go b/cmd/entire/cli/hooks_git_cmd.go index 0c7ea9001a..fc7340ab86 100644 --- a/cmd/entire/cli/hooks_git_cmd.go +++ b/cmd/entire/cli/hooks_git_cmd.go @@ -219,6 +219,14 @@ func newHooksGitPrepareCommitMsgCmd() *cobra.Command { if g.skipUnsupportedCheckpointPolicy() { return nil } + // When an ACTIVE agent session lives in another git common dir, + // adopt it into this repo before trailer insertion (#1439). + // Skip the same cases PrepareCommitMsg skips (merge/squash/amend + // and rebase/cherry-pick/revert): adopting there would retire a + // live session with no trailer written. + if shouldTryAutoAdoptOnPrepareCommitMsg(g.ctx, source) { + tryAutoAdoptCrossCommonDirSession(g.ctx) + } hookErr := g.strategy.PrepareCommitMsg(g.ctx, commitMsgFile, source) g.logCompleted(hookErr) diff --git a/cmd/entire/cli/internal/flock/flock_test.go b/cmd/entire/cli/internal/flock/flock_test.go new file mode 100644 index 0000000000..da9d0839c2 --- /dev/null +++ b/cmd/entire/cli/internal/flock/flock_test.go @@ -0,0 +1,98 @@ +package flock + +import ( + "context" + "path/filepath" + "testing" + "time" +) + +func TestAcquireContext_Uncontended(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "test.lock") + release, err := AcquireContext(context.Background(), path) + if err != nil { + t.Fatalf("AcquireContext() error = %v, want nil", err) + } + release() +} + +func TestAcquireContext_DeadlineExceededWhenContended(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "test.lock") + holderRelease, err := Acquire(path) + if err != nil { + t.Fatalf("Acquire() error = %v, want nil", err) + } + defer holderRelease() + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + start := time.Now() + _, err = AcquireContext(ctx, path) + elapsed := time.Since(start) + if err == nil { + t.Fatal("AcquireContext() error = nil, want context deadline exceeded") + } + if err != context.DeadlineExceeded { //nolint:errorlint // AcquireContext returns ctx.Err() directly, not wrapped + t.Fatalf("AcquireContext() error = %v, want context.DeadlineExceeded", err) + } + if elapsed > time.Second { + t.Fatalf("AcquireContext() took %v, want bounded by ctx deadline", elapsed) + } +} + +func TestAcquireContext_CanceledWhenContended(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "test.lock") + holderRelease, err := Acquire(path) + if err != nil { + t.Fatalf("Acquire() error = %v, want nil", err) + } + defer holderRelease() + + ctx, cancel := context.WithCancel(context.Background()) + time.AfterFunc(20*time.Millisecond, cancel) + + _, err = AcquireContext(ctx, path) + if err != context.Canceled { //nolint:errorlint // AcquireContext returns ctx.Err() directly, not wrapped + t.Fatalf("AcquireContext() error = %v, want context.Canceled", err) + } +} + +func TestAcquireContext_SucceedsAfterContendedLockReleases(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "test.lock") + holderRelease, err := Acquire(path) + if err != nil { + t.Fatalf("Acquire() error = %v, want nil", err) + } + time.AfterFunc(30*time.Millisecond, holderRelease) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + release, err := AcquireContext(ctx, path) + if err != nil { + t.Fatalf("AcquireContext() error = %v, want nil once contended lock releases", err) + } + release() +} + +func TestAcquireContext_AlreadyDoneContext(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "test.lock") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := AcquireContext(ctx, path) + if err != context.Canceled { //nolint:errorlint // AcquireContext returns ctx.Err() directly, not wrapped + t.Fatalf("AcquireContext() error = %v, want context.Canceled", err) + } +} diff --git a/cmd/entire/cli/internal/flock/flock_unix.go b/cmd/entire/cli/internal/flock/flock_unix.go index 680153f21b..c435663e76 100644 --- a/cmd/entire/cli/internal/flock/flock_unix.go +++ b/cmd/entire/cli/internal/flock/flock_unix.go @@ -7,16 +7,27 @@ package flock import ( + "context" + "errors" "fmt" "os" "syscall" + "time" ) +// acquirePollInterval is how often AcquireContext retries a non-blocking +// flock attempt while waiting for a contended lock to free up. +const acquirePollInterval = 10 * time.Millisecond + // Acquire takes an exclusive advisory lock on path, creating the file if // needed. The returned release closes the file, which drops the flock. // Callers must invoke release exactly once. The lock file persists between // runs — flock state is held by the file descriptor, not by the inode on // disk — so the lockfile contents are immaterial. +// +// Acquire blocks indefinitely on a contended lock and cannot be interrupted +// by context cancellation. Callers that need a bounded wait must use +// AcquireContext instead. func Acquire(path string) (release func(), err error) { f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) //nolint:gosec // caller is responsible for path validation if err != nil { @@ -28,3 +39,39 @@ func Acquire(path string) (release func(), err error) { } return func() { _ = f.Close() }, nil } + +// AcquireContext behaves like Acquire, except the wait for a contended lock +// is bounded by ctx: it repeatedly attempts a non-blocking flock +// (LOCK_EX|LOCK_NB) and polls at acquirePollInterval until either the lock +// is obtained or ctx is done, in which case it returns ctx.Err(). Use this +// instead of Acquire whenever the caller has a deadline that must bound +// lock acquisition — the blocking syscall.Flock(LOCK_EX) used by Acquire +// ignores context cancellation entirely. +func AcquireContext(ctx context.Context, path string) (release func(), err error) { + if err := ctx.Err(); err != nil { + return nil, err //nolint:wrapcheck // canonical context cancellation + } + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) //nolint:gosec // caller is responsible for path validation + if err != nil { + return nil, fmt.Errorf("open flock: %w", err) + } + + ticker := time.NewTicker(acquirePollInterval) + defer ticker.Stop() + for { + flockErr := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) //nolint:gosec // file descriptors are non-negative; standard Go pattern for syscall.Flock + if flockErr == nil { + return func() { _ = f.Close() }, nil + } + if !errors.Is(flockErr, syscall.EWOULDBLOCK) && !errors.Is(flockErr, syscall.EAGAIN) { + _ = f.Close() + return nil, fmt.Errorf("flock: %w", flockErr) + } + select { + case <-ctx.Done(): + _ = f.Close() + return nil, ctx.Err() //nolint:wrapcheck // canonical context cancellation + case <-ticker.C: + } + } +} diff --git a/cmd/entire/cli/internal/flock/flock_windows.go b/cmd/entire/cli/internal/flock/flock_windows.go index 93cc0c4c48..64a7bc6919 100644 --- a/cmd/entire/cli/internal/flock/flock_windows.go +++ b/cmd/entire/cli/internal/flock/flock_windows.go @@ -3,15 +3,26 @@ package flock import ( + "context" + "errors" "fmt" "os" + "time" "golang.org/x/sys/windows" ) +// acquirePollInterval is how often AcquireContext retries a non-blocking +// lock attempt while waiting for a contended lock to free up. +const acquirePollInterval = 10 * time.Millisecond + // Acquire takes an exclusive lock on path via Windows LockFileEx. The // returned release unlocks and closes the file. Callers must invoke release // exactly once. +// +// Acquire blocks indefinitely on a contended lock and cannot be interrupted +// by context cancellation. Callers that need a bounded wait must use +// AcquireContext instead. func Acquire(path string) (release func(), err error) { f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) //nolint:gosec // caller is responsible for path validation if err != nil { @@ -27,3 +38,43 @@ func Acquire(path string) (release func(), err error) { _ = f.Close() }, nil } + +// AcquireContext behaves like Acquire, except the wait for a contended lock +// is bounded by ctx: it repeatedly attempts a non-blocking LockFileEx +// (LOCKFILE_EXCLUSIVE_LOCK|LOCKFILE_FAIL_IMMEDIATELY) and polls at +// acquirePollInterval until either the lock is obtained or ctx is done, in +// which case it returns ctx.Err(). Use this instead of Acquire whenever the +// caller has a deadline that must bound lock acquisition. +func AcquireContext(ctx context.Context, path string) (release func(), err error) { + if err := ctx.Err(); err != nil { + return nil, err //nolint:wrapcheck // canonical context cancellation + } + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) //nolint:gosec // caller is responsible for path validation + if err != nil { + return nil, fmt.Errorf("open flock: %w", err) + } + + ticker := time.NewTicker(acquirePollInterval) + defer ticker.Stop() + flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY) + for { + overlapped := new(windows.Overlapped) + lockErr := windows.LockFileEx(windows.Handle(f.Fd()), flags, 0, 1, 0, overlapped) + if lockErr == nil { + return func() { + _ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, overlapped) + _ = f.Close() + }, nil + } + if !errors.Is(lockErr, windows.ERROR_LOCK_VIOLATION) && !errors.Is(lockErr, windows.ERROR_IO_PENDING) { + _ = f.Close() + return nil, fmt.Errorf("lock flock: %w", lockErr) + } + select { + case <-ctx.Done(): + _ = f.Close() + return nil, ctx.Err() //nolint:wrapcheck // canonical context cancellation + case <-ticker.C: + } + } +} diff --git a/cmd/entire/cli/proclive/proclive.go b/cmd/entire/cli/proclive/proclive.go index d58a481a38..70ae26caf2 100644 --- a/cmd/entire/cli/proclive/proclive.go +++ b/cmd/entire/cli/proclive/proclive.go @@ -87,16 +87,18 @@ const maxAncestorDepth = 12 // (node, bun, python) are deliberately absent — for several agents the runtime // IS the long-lived agent, so treating it as transient would skip the real owner. var transientNames = map[string]bool{ - "entire": true, - "sh": true, - "bash": true, - "zsh": true, - "dash": true, - "fish": true, - "ash": true, - "ksh": true, - "env": true, - "go": true, + "entire": true, + "sh": true, + "bash": true, + "zsh": true, + "dash": true, + "fish": true, + "ash": true, + "ksh": true, + "env": true, + "go": true, + "git": true, // prepare-commit-msg / commit hooks run under git + "git.exe": true, } func isTransient(name string) bool { diff --git a/cmd/entire/cli/proclive/proclive_test.go b/cmd/entire/cli/proclive/proclive_test.go index 16d6812225..2103be8dd3 100644 --- a/cmd/entire/cli/proclive/proclive_test.go +++ b/cmd/entire/cli/proclive/proclive_test.go @@ -42,7 +42,7 @@ func TestCheck_HostMismatchIsUnknown(t *testing.T) { func TestIsTransient(t *testing.T) { t.Parallel() - for _, name := range []string{"entire", "sh", "bash", "ZSH", " dash ", "Fish", "go"} { + for _, name := range []string{"entire", "sh", "bash", "ZSH", " dash ", "Fish", "go", "git", "Git.exe"} { if !isTransient(name) { t.Errorf("isTransient(%q) = false, want true", name) } diff --git a/cmd/entire/cli/session/live_registry.go b/cmd/entire/cli/session/live_registry.go new file mode 100644 index 0000000000..3eee105bf0 --- /dev/null +++ b/cmd/entire/cli/session/live_registry.go @@ -0,0 +1,221 @@ +package session + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/osroot" + "github.com/entireio/cli/cmd/entire/cli/proclive" + "github.com/entireio/cli/cmd/entire/cli/validation" + "github.com/entireio/cli/internal/entireclient/userdirs" +) + +// LiveSessionEntry is a cross-repo pointer to an ACTIVE session so commit hooks +// in a different git common dir can discover a unique adopt candidate without +// scanning the filesystem. +type LiveSessionEntry struct { + SessionID string `json:"session_id"` + CommonDir string `json:"common_dir"` + WorktreePath string `json:"worktree_path"` + AgentType types.AgentType `json:"agent_type,omitempty"` + Phase Phase `json:"phase"` + LastInteractionTime *time.Time `json:"last_interaction_time,omitempty"` + Owner *proclive.Identity `json:"owner,omitempty"` + FilesTouched []string `json:"files_touched,omitempty"` +} + +func liveSessionsDir() string { + return filepath.Join(userdirs.Cache(), "live-sessions") +} + +// ShouldRegisterLive reports whether state should appear in the live-session +// registry. Tombstoned (adopted-away) and ended sessions must not. +func ShouldRegisterLive(state *State) bool { + return state != nil && + state.Phase.IsActive() && + state.EndedAt == nil && + !state.FullyCondensed && + state.AdoptedIntoWorktreePath == "" +} + +// LiveSessionMaxAge is how long a live-session registry entry remains on disk +// before ListLiveSessions sweeps it. Keep aligned with adoptRecentWindow in +// the cli package (cross-common-dir auto-adopt eligibility). +const LiveSessionMaxAge = 12 * time.Hour + +// RegisterLiveSession writes or updates the cross-repo live-session pointer. +// Best-effort: callers should ignore errors so hook paths stay resilient. +func RegisterLiveSession(state *State, commonDir string) error { + if state == nil { + return nil + } + if !ShouldRegisterLive(state) { + return UnregisterLiveSession(state.SessionID) + } + if err := validation.ValidateSessionID(state.SessionID); err != nil { + return fmt.Errorf("invalid session ID: %w", err) + } + commonDir = strings.TrimSpace(commonDir) + if commonDir == "" { + return errors.New("common dir is required") + } + // Persist an absolute common dir. Relative values like ".git" resolve against + // the reader's CWD and falsely match unrelated repos in sameAdoptStore. + if abs, err := filepath.Abs(commonDir); err == nil { + commonDir = abs + } + commonDir = filepath.Clean(commonDir) + + entry := LiveSessionEntry{ + SessionID: state.SessionID, + CommonDir: commonDir, + WorktreePath: state.WorktreePath, + AgentType: state.AgentType, + Phase: state.Phase, + LastInteractionTime: cloneTime(state.LastInteractionTime), + Owner: cloneOwner(state.Owner), + FilesTouched: append([]string(nil), state.FilesTouched...), + } + + dir := liveSessionsDir() + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("create live-sessions dir: %w", err) + } + root, err := os.OpenRoot(dir) + if err != nil { + return fmt.Errorf("open live-sessions dir: %w", err) + } + defer root.Close() + + data, err := jsonutil.MarshalIndentWithNewline(entry, "", " ") + if err != nil { + return fmt.Errorf("marshal live session: %w", err) + } + fileName := state.SessionID + ".json" + tmp, err := os.CreateTemp(dir, fileName+".*.tmp") + if err != nil { + return fmt.Errorf("create live session temp: %w", err) + } + tmpName := tmp.Name() + removeTmp := true + defer func() { + if removeTmp { + _ = os.Remove(tmpName) + } + }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write live session temp: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close live session temp: %w", err) + } + if err := root.Rename(filepath.Base(tmpName), fileName); err != nil { + return fmt.Errorf("rename live session: %w", err) + } + removeTmp = false + return nil +} + +// UnregisterLiveSession removes the cross-repo live-session pointer. +func UnregisterLiveSession(sessionID string) error { + if err := validation.ValidateSessionID(sessionID); err != nil { + return fmt.Errorf("invalid session ID: %w", err) + } + dir := liveSessionsDir() + root, err := os.OpenRoot(dir) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("open live-sessions dir: %w", err) + } + defer root.Close() + _ = osroot.Remove(root, sessionID+".json") //nolint:errcheck // best-effort + return nil +} + +// ListLiveSessions returns all live-session registry entries. +// Entries older than LiveSessionMaxAge (or missing LastInteractionTime) are +// deleted during the scan so crashed sessions do not accumulate forever. +func ListLiveSessions() ([]LiveSessionEntry, error) { + dir := liveSessionsDir() + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read live-sessions dir: %w", err) + } + + root, err := os.OpenRoot(dir) + if err != nil { + return nil, fmt.Errorf("open live-sessions dir: %w", err) + } + defer root.Close() + + out := make([]LiveSessionEntry, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + sessionID := strings.TrimSuffix(entry.Name(), ".json") + if err := validation.ValidateSessionID(sessionID); err != nil { + continue + } + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) //nolint:gosec // path under cache dir from ReadDir + if err != nil { + continue + } + var live LiveSessionEntry + if err := json.Unmarshal(data, &live); err != nil { + _ = osroot.Remove(root, entry.Name()) //nolint:errcheck // sweep corrupt pointer + continue + } + if live.SessionID == "" { + live.SessionID = sessionID + } + if liveSessionExpired(live) { + _ = osroot.Remove(root, entry.Name()) //nolint:errcheck // TTL sweep + continue + } + out = append(out, live) + } + return out, nil +} + +func liveSessionExpired(entry LiveSessionEntry) bool { + if entry.LastInteractionTime == nil { + return true + } + return time.Since(*entry.LastInteractionTime) > LiveSessionMaxAge +} + +func cloneTime(t *time.Time) *time.Time { + if t == nil { + return nil + } + cloned := *t + return &cloned +} + +func cloneOwner(owner *proclive.Identity) *proclive.Identity { + if owner == nil { + return nil + } + cloned := *owner + return &cloned +} + +// CommonDirFromStateDir returns the git common dir that owns a session state +// directory (.../entire-sessions). +func CommonDirFromStateDir(stateDir string) string { + return filepath.Clean(filepath.Dir(stateDir)) +} diff --git a/cmd/entire/cli/session/live_registry_test.go b/cmd/entire/cli/session/live_registry_test.go new file mode 100644 index 0000000000..cdc394f992 --- /dev/null +++ b/cmd/entire/cli/session/live_registry_test.go @@ -0,0 +1,192 @@ +package session + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/proclive" +) + +func TestLiveRegistry_RegisterListUnregister(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + now := time.Now() + state := &State{ + SessionID: "live-reg-001", + AgentType: agent.AgentTypeClaudeCode, + Phase: PhaseActive, + WorktreePath: "/tmp/repo-a", + LastInteractionTime: &now, + FilesTouched: []string{"feature.txt"}, + Owner: &proclive.Identity{PID: 42, Start: "start", Host: "host"}, + } + commonDir := filepath.Join(t.TempDir(), ".git") + if err := RegisterLiveSession(state, commonDir); err != nil { + t.Fatalf("RegisterLiveSession: %v", err) + } + + entries, err := ListLiveSessions() + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("ListLiveSessions len=%d, want 1", len(entries)) + } + if entries[0].SessionID != state.SessionID || entries[0].CommonDir != filepath.Clean(commonDir) { + t.Fatalf("entry = %+v", entries[0]) + } + + if err := UnregisterLiveSession(state.SessionID); err != nil { + t.Fatal(err) + } + entries, err = ListLiveSessions() + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("after unregister len=%d, want 0", len(entries)) + } +} + +func TestLiveRegistry_SaveHooksRegisterAndClearUnregisters(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + dir := filepath.Join(t.TempDir(), "entire-sessions") + store := NewStateStoreWithDir(dir) + now := time.Now() + state := &State{ + SessionID: "live-reg-save-001", + AgentType: agent.AgentTypeClaudeCode, + Phase: PhaseActive, + StartedAt: now, + LastInteractionTime: &now, + BaseCommit: "abc123", + WorktreePath: "/tmp/repo-a", + FilesTouched: []string{"a.txt"}, + } + if err := store.Save(context.Background(), state); err != nil { + t.Fatal(err) + } + entries, err := ListLiveSessions() + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].SessionID != state.SessionID { + t.Fatalf("expected registry entry after Save, got %+v", entries) + } + + if err := store.Clear(context.Background(), state.SessionID); err != nil { + t.Fatal(err) + } + entries, err = ListLiveSessions() + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("expected empty registry after Clear, got %+v", entries) + } +} + +func TestShouldRegisterLive_RejectsTombstone(t *testing.T) { + state := &State{ + SessionID: "x", + Phase: PhaseActive, + AdoptedIntoWorktreePath: "/other", + } + if ShouldRegisterLive(state) { + t.Fatal("tombstoned session must not register") + } +} + +func TestRegisterLiveSession_NilStateNoPanic(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + if err := RegisterLiveSession(nil, "/tmp/git"); err != nil { + t.Fatalf("RegisterLiveSession(nil) = %v, want nil", err) + } +} + +func TestRegisterLiveSession_AbsolutizesRelativeCommonDir(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(repo) + + now := time.Now() + state := &State{ + SessionID: "live-reg-rel-001", + AgentType: agent.AgentTypeClaudeCode, + Phase: PhaseActive, + WorktreePath: repo, + LastInteractionTime: &now, + FilesTouched: []string{"feature.txt"}, + } + if err := RegisterLiveSession(state, ".git"); err != nil { + t.Fatalf("RegisterLiveSession: %v", err) + } + + // Reinterpret from a different CWD — persisted CommonDir must stay absolute. + t.Chdir(t.TempDir()) + entries, err := ListLiveSessions() + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("len=%d, want 1", len(entries)) + } + want, err := filepath.Abs(filepath.Join(repo, ".git")) + if err != nil { + t.Fatal(err) + } + want = filepath.Clean(want) + if entries[0].CommonDir != want { + t.Fatalf("CommonDir = %q, want absolute %q", entries[0].CommonDir, want) + } + if !filepath.IsAbs(entries[0].CommonDir) { + t.Fatalf("CommonDir must be absolute, got %q", entries[0].CommonDir) + } +} + +func TestListLiveSessions_SweepsExpiredEntries(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + stale := time.Now().Add(-LiveSessionMaxAge - time.Hour) + fresh := time.Now() + staleState := &State{ + SessionID: "live-reg-stale-001", + AgentType: agent.AgentTypeClaudeCode, + Phase: PhaseActive, + WorktreePath: "/tmp/repo-stale", + LastInteractionTime: &stale, + FilesTouched: []string{"a.txt"}, + } + freshState := &State{ + SessionID: "live-reg-fresh-001", + AgentType: agent.AgentTypeClaudeCode, + Phase: PhaseActive, + WorktreePath: "/tmp/repo-fresh", + LastInteractionTime: &fresh, + FilesTouched: []string{"b.txt"}, + } + commonDir := filepath.Join(t.TempDir(), ".git") + if err := RegisterLiveSession(staleState, commonDir); err != nil { + t.Fatal(err) + } + if err := RegisterLiveSession(freshState, commonDir); err != nil { + t.Fatal(err) + } + + entries, err := ListLiveSessions() + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].SessionID != freshState.SessionID { + t.Fatalf("expected only fresh entry after TTL sweep, got %+v", entries) + } +} diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index 1eed68ebfb..3cdcb7fdaf 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -672,6 +672,15 @@ func (s *StateStore) Save(ctx context.Context, state *State) error { return fmt.Errorf("failed to rename session state file: %w", err) } removeTmp = false + + // Best-effort cross-repo live pointer so prepare-commit-msg in another + // common dir can auto-adopt a unique ACTIVE session (#1439). + commonDir := CommonDirFromStateDir(s.stateDir) + if ShouldRegisterLive(state) { + _ = RegisterLiveSession(state, commonDir) //nolint:errcheck // hook-path resilient + } else { + _ = UnregisterLiveSession(state.SessionID) //nolint:errcheck // hook-path resilient + } return nil } @@ -701,6 +710,7 @@ func (s *StateStore) Clear(ctx context.Context, sessionID string) error { } } + _ = UnregisterLiveSession(sessionID) //nolint:errcheck // best-effort registry cleanup return nil } @@ -820,12 +830,14 @@ func getGitCommonDir(ctx context.Context) (string, error) { commonDir := strings.TrimSpace(string(output)) - // git rev-parse --git-common-dir returns relative paths from the working directory, - // so we need to make it absolute if it isn't already - if !filepath.IsAbs(commonDir) { - commonDir = filepath.Join(".", commonDir) + // git rev-parse --git-common-dir often returns a CWD-relative path (e.g. ".git"). + // Absolutize before caching/persisting so cross-process consumers (live-session + // registry) don't reinterpret ".git" against a different CWD. + abs, absErr := filepath.Abs(commonDir) + if absErr != nil { + return "", fmt.Errorf("absolutize git common dir %q: %w", commonDir, absErr) } - commonDir = filepath.Clean(commonDir) + commonDir = filepath.Clean(abs) gitCommonDirMu.Lock() gitCommonDirCache = commonDir diff --git a/cmd/entire/cli/session/state_test.go b/cmd/entire/cli/session/state_test.go index cc1fab3a13..f5c91d3d0b 100644 --- a/cmd/entire/cli/session/state_test.go +++ b/cmd/entire/cli/session/state_test.go @@ -546,10 +546,8 @@ func TestGetGitCommonDir_ReturnsValidPath(t *testing.T) { commonDir, err := getGitCommonDir(context.Background()) require.NoError(t, err) - // getGitCommonDir returns a relative path from cwd; resolve it to absolute for comparison - absCommonDir, err := filepath.Abs(commonDir) - require.NoError(t, err) - assert.Equal(t, filepath.Join(dir, ".git"), absCommonDir) + assert.True(t, filepath.IsAbs(commonDir), "getGitCommonDir must return an absolute path, got %q", commonDir) + assert.Equal(t, filepath.Join(dir, ".git"), commonDir) // The path should actually exist info, err := os.Stat(commonDir) @@ -612,19 +610,15 @@ func TestGetGitCommonDir_InvalidatesOnCwdChange(t *testing.T) { t.Chdir(dir1) first, err := getGitCommonDir(context.Background()) require.NoError(t, err) - absFirst, err := filepath.Abs(first) - require.NoError(t, err) - assert.Equal(t, filepath.Join(dir1, ".git"), absFirst) + assert.Equal(t, filepath.Join(dir1, ".git"), first) // Change to dir2 — cache should miss and resolve to dir2's .git t.Chdir(dir2) second, err := getGitCommonDir(context.Background()) require.NoError(t, err) - absSecond, err := filepath.Abs(second) - require.NoError(t, err) - assert.Equal(t, filepath.Join(dir2, ".git"), absSecond) + assert.Equal(t, filepath.Join(dir2, ".git"), second) - assert.NotEqual(t, absFirst, absSecond) + assert.NotEqual(t, first, second) } func TestGetGitCommonDir_ErrorOutsideRepo(t *testing.T) { diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index 92a70bbd92..87e2736e84 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -6,7 +6,9 @@ import ( "errors" "fmt" "io" + "log/slog" "maps" + "os" "os/exec" "path/filepath" "slices" @@ -16,6 +18,8 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/interactive" + "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/strategy" @@ -26,6 +30,10 @@ import ( type adoptOptions struct { FromWorktree string Force bool + // SkipTranscriptValidation allows auto-adopt to proceed when a source + // transcript path is missing or not owned by a registered agent. The + // adopted state clears an invalid transcript path instead of failing. + SkipTranscriptValidation bool } const adoptRecentWindow = 12 * time.Hour @@ -88,8 +96,10 @@ func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOpti if err != nil { return err } - if err := validateAdoptSourceTranscript(sourceState, sourceWorktree); err != nil { - return err + if !opts.SkipTranscriptValidation { + if err := validateAdoptSourceTranscript(sourceState, sourceWorktree); err != nil { + return err + } } var adopted *session.State @@ -154,14 +164,19 @@ func adoptFromExternalSessionStore( return fmt.Errorf("session %s belongs to %s, not %s", sessionID, adoptSessionWorktreeLabel(sourceState), sourceWorktree) } - if err := validateAdoptSourceTranscript(sourceState, sourceWorktree); err != nil { - return err + if !opts.SkipTranscriptValidation { + if err := validateAdoptSourceTranscript(sourceState, sourceWorktree); err != nil { + return err + } } next, touched, err := buildAdoptedSessionState(ctx, sourceState) if err != nil { return err } + if opts.SkipTranscriptValidation { + clearInvalidAdoptTranscript(ctx, next, sourceWorktree) + } existing, err := targetStore.Load(ctx, next.SessionID) if err != nil { return fmt.Errorf("load current session state: %w", err) @@ -277,6 +292,42 @@ func validateAdoptSourceTranscript(source *session.State, sourceWorktree string) return nil } +// clearInvalidAdoptTranscript wipes a transcript pointer that would fail +// validateAdoptSourceTranscript. Used by auto-adopt (SkipTranscriptValidation) +// so a missing/unowned path does not block adoption; surfaces a warning so the +// user can see continuity was degraded until the next agent turn re-resolves it. +func clearInvalidAdoptTranscript(ctx context.Context, state *session.State, sourceWorktree string) { + if state == nil || strings.TrimSpace(state.TranscriptPath) == "" { + return + } + if err := validateAdoptSourceTranscript(state, sourceWorktree); err != nil { + logCtx := logging.WithComponent(ctx, "session") + logging.Warn(logCtx, "adopt: clearing invalid transcript path", + slog.String("session_id", state.SessionID), + slog.String("error", err.Error()), + ) + writeAdoptUserWarning(fmt.Sprintf( + "[entire] Warning: adopted session %s lost its transcript pointer (%v); it will be re-resolved on the next agent turn.\n", + shortSessionID(state.SessionID), err, + )) + state.TranscriptPath = "" + } +} + +// writeAdoptUserWarning surfaces an intentional hook-path warning. prepare-commit-msg +// redirects stderr to /dev/null (so logging fallbacks stay quiet); prefer /dev/tty +// like askConfirmTTY. Falls back to stderr for CLI adopt / tests. +func writeAdoptUserWarning(msg string) { + if !interactive.UnderTest() { + if tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0); err == nil { + _, _ = fmt.Fprint(tty, msg) + _ = tty.Close() + return + } + } + fmt.Fprint(os.Stderr, msg) +} + func stateStoreForWorktree(ctx context.Context, worktreePath string) (*session.StateStore, string, string, error) { absWorktree, err := filepath.Abs(worktreePath) if err != nil { diff --git a/cmd/entire/cli/session_auto_adopt.go b/cmd/entire/cli/session_auto_adopt.go new file mode 100644 index 0000000000..26277daa13 --- /dev/null +++ b/cmd/entire/cli/session_auto_adopt.go @@ -0,0 +1,502 @@ +package cli + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/proclive" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +// Caps how many worktrees prepare-commit-msg will resolve via git when hunting +// auto-adopt candidates (registry entries or sibling dirs). +const ( + maxSiblingAutoAdoptScan = 32 + maxRegistryAutoAdoptScan = 32 +) + +// Time bounds for prepare-commit-msg auto-adopt. Git rev-parse calls use +// CommandContext; without a deadline a hung/stale worktree path (registry or +// sibling) could block `git commit` indefinitely. +const ( + autoAdoptDiscoveryTimeout = 2 * time.Second + autoAdoptGitResolveTimeout = 500 * time.Millisecond + autoAdoptStagedFilesTimeout = 1 * time.Second + // autoAdoptAdoptTimeout bounds adoptFromExternalSessionStore, which takes + // a cross-process session-state flock (strategy.WithSessionStateLocks). + // Without a deadline, a lock held by another process (or a hung + // concurrent hook) could block git commit indefinitely — the same + // failure mode the timeouts above already guard against for discovery. + autoAdoptAdoptTimeout = 2 * time.Second +) + +// prepareCommitMsgSourceAmend is git's prepare-commit-msg source for `git commit --amend`. +const prepareCommitMsgSourceAmend = "commit" + +// shouldTryAutoAdoptOnPrepareCommitMsg reports whether prepare-commit-msg should +// attempt cross-common-dir auto-adopt. Matches ManualCommitStrategy.PrepareCommitMsg +// skip conditions so we never retire a live session when no trailer would be written. +// Amend (source "commit"): handleAmendCommitMsg only restores an existing trailer / +// HEAD LastCheckpointID match and will not write a trailer for a freshly adopted session. +func shouldTryAutoAdoptOnPrepareCommitMsg(ctx context.Context, source string) bool { + switch source { + case "merge", "squash", prepareCommitMsgSourceAmend: + return false + } + return !strategy.IsGitSequenceOperation(ctx) +} + +// tryAutoAdoptCrossCommonDirSession adopts a unique ACTIVE session from another +// git common dir into the current worktree when it is safe to do so. +// +// Discovery order: +// 1. Live-session registry (written on ACTIVE StateStore.Save), limited to +// worktrees that share the target's parent directory (same proximity as #2) +// 2. Immediate sibling repos under the parent directory (seeded / microservices) +// +// A candidate is accepted only when exactly one match remains after filtering +// for recent ACTIVE sessions that (a) share the committing process owner and +// (b) have FilesTouched overlapping the staged commit paths on a +// non-boilerplate relative path (README.md / go.mod / package.json / … alone +// never count). Registry entries also require sibling proximity. Idle sessions +// are never auto-adopted. Ambiguity skips. +// +// Best-effort: never returns an error to the git hook caller. +func tryAutoAdoptCrossCommonDirSession(ctx context.Context) { + logCtx := logging.WithComponent(ctx, "session") + + if !targetIsEntireEnabled(ctx) { + return + } + + targetWorktree, err := paths.WorktreeRoot(ctx) + if err != nil || targetWorktree == "" { + return + } + targetCtx, targetCancel := context.WithTimeout(ctx, autoAdoptGitResolveTimeout) + targetStore, _, targetCommonDir, err := stateStoreForWorktree(targetCtx, targetWorktree) + targetCancel() + if err != nil { + return + } + if hasLocalActiveSession(ctx, targetStore) { + return + } + + stagedCtx, stagedCancel := context.WithTimeout(ctx, autoAdoptStagedFilesTimeout) + staged, err := stagedFilesForAutoAdopt(stagedCtx, targetWorktree) + stagedCancel() + if err != nil { + staged = nil + } + // Owner + staged overlap are both required for every candidate. Bail before + // any registry/sibling I/O when either is missing (ordinary human commits). + if len(staged) == 0 { + return + } + owner, hasOwner := proclive.ResolveOwner() + if !hasOwner { + return + } + + regCtx, regCancel := context.WithTimeout(ctx, autoAdoptDiscoveryTimeout) + candidates := collectRegistryAutoAdoptCandidates(regCtx, targetWorktree, targetCommonDir, staged, owner, hasOwner) + regCancel() + if len(candidates) == 0 { + scanCtx, scanCancel := context.WithTimeout(ctx, autoAdoptDiscoveryTimeout) + candidates = collectSiblingAutoAdoptCandidates(scanCtx, targetWorktree, targetCommonDir, staged, owner, hasOwner) + scanCancel() + } + if len(candidates) != 1 { + if len(candidates) > 1 { + logging.Debug(logCtx, "auto-adopt: skipped ambiguous cross-common-dir sessions", + slog.Int("candidates", len(candidates)), + ) + } + return + } + + source := candidates[0] + existing, err := targetStore.Load(ctx, source.SessionID) + if err != nil || existing != nil { + return + } + + logging.Info(logCtx, "auto-adopt: adopting cross-common-dir session for commit", + slog.String("session_id", source.SessionID), + slog.String("from_worktree", source.WorktreePath), + slog.String("to_worktree", targetWorktree), + ) + + adoptCtx, adoptCancel := context.WithTimeout(ctx, autoAdoptAdoptTimeout) + _, _, adoptErr := adoptFromExternalSessionStore( + adoptCtx, + source.Store, + source.WorktreePath, + source.CommonDir, + targetStore, + targetCommonDir, + source.SessionID, + adoptOptions{Force: true, SkipTranscriptValidation: true}, + ) + adoptCancel() + if adoptErr != nil { + logging.Debug(logCtx, "auto-adopt: adopt failed", + slog.String("session_id", source.SessionID), + slog.String("error", adoptErr.Error()), + ) + } +} + +type autoAdoptCandidate struct { + SessionID string + WorktreePath string + CommonDir string + Store *session.StateStore + OwnerMatch bool + OverlapMatch bool +} + +func hasLocalActiveSession(ctx context.Context, store *session.StateStore) bool { + states, err := store.List(ctx) + if err != nil { + return true // fail closed: do not steal when local store is unreadable + } + for _, state := range states { + if isAdoptableSourceSession(state) { + return true + } + } + return false +} + +func collectRegistryAutoAdoptCandidates( + ctx context.Context, + targetWorktree, targetCommonDir string, + staged []string, + owner proclive.Identity, + hasOwner bool, +) []autoAdoptCandidate { + entries, err := session.ListLiveSessions() + if err != nil || len(entries) == 0 { + return nil + } + + var out []autoAdoptCandidate + resolved := 0 + for _, entry := range entries { + if ctx.Err() != nil || resolved >= maxRegistryAutoAdoptScan { + break + } + if sameAdoptStore(entry.CommonDir, targetCommonDir) { + continue + } + if !isRecentLiveEntry(entry) { + continue + } + // Same parent-dir proximity as the sibling scan. Without this, a shared + // owner (tmux/IDE) + common relative filename can steal across unrelated repos. + if !autoAdoptSiblingProximity(entry.WorktreePath, targetWorktree) { + continue + } + resolved++ + resolveCtx, resolveCancel := context.WithTimeout(ctx, autoAdoptGitResolveTimeout) + cand, ok := candidateFromSource(resolveCtx, entry.WorktreePath, entry.SessionID, staged, owner, hasOwner) + resolveCancel() + if ok { + out = append(out, cand) + } + } + return out +} + +func collectSiblingAutoAdoptCandidates( + ctx context.Context, + targetWorktree, targetCommonDir string, + staged []string, + owner proclive.Identity, + hasOwner bool, +) []autoAdoptCandidate { + parent := filepath.Dir(targetWorktree) + if parent == "" || parent == targetWorktree { + return nil + } + entries, err := os.ReadDir(parent) + if err != nil { + return nil + } + + var out []autoAdoptCandidate + scanned := 0 + for _, entry := range entries { + if ctx.Err() != nil || scanned >= maxSiblingAutoAdoptScan { + break + } + if !entry.IsDir() { + continue + } + sibling := filepath.Join(parent, entry.Name()) + if sameAdoptPath(sibling, targetWorktree) { + continue + } + // Cheap pre-filters before shelling out to git rev-parse: + // 1. .git entry (skip node_modules / build outputs) + // 2. .entire/ (skip git repos that don't use Entire — no sessions to adopt) + if !siblingLooksLikeGitWorktree(sibling) || !siblingLooksLikeEntireRepo(sibling) { + continue + } + scanned++ + + resolveCtx, resolveCancel := context.WithTimeout(ctx, autoAdoptGitResolveTimeout) + store, worktree, commonDir, err := stateStoreForWorktree(resolveCtx, sibling) + resolveCancel() + if err != nil || sameAdoptStore(commonDir, targetCommonDir) { + continue + } + states, err := store.List(ctx) + if err != nil { + continue + } + for _, state := range states { + if !isRecentAdoptCandidate(state) { + continue + } + cand, ok := candidateFromLoaded(store, worktree, commonDir, state, staged, owner, hasOwner) + if ok { + out = append(out, cand) + } + } + } + return out +} + +func candidateFromSource( + ctx context.Context, + sourceWorktree, sessionID string, + staged []string, + owner proclive.Identity, + hasOwner bool, +) (autoAdoptCandidate, bool) { + store, worktree, commonDir, err := stateStoreForWorktree(ctx, sourceWorktree) + if err != nil { + return autoAdoptCandidate{}, false + } + state, err := store.Load(ctx, sessionID) + if err != nil || state == nil { + return autoAdoptCandidate{}, false + } + return candidateFromLoaded(store, worktree, commonDir, state, staged, owner, hasOwner) +} + +func candidateFromLoaded( + store *session.StateStore, + worktree, commonDir string, + state *session.State, + staged []string, + owner proclive.Identity, + hasOwner bool, +) (autoAdoptCandidate, bool) { + if !isRecentAdoptCandidate(state) { + return autoAdoptCandidate{}, false + } + // Auto-adopt is ACTIVE-only (matches live-registry prefilter and the feature doc). + // Idle is adoptable manually but must not be silently relocated on a commit hook. + if state.Phase != session.PhaseActive { + return autoAdoptCandidate{}, false + } + // Reject stale WorktreePath mismatches. Overriding worktree with + // state.WorktreePath would make sessionBelongsToSourceWorktree a tautology + // (comparing the recorded path against itself) and miss the ownership check. + if state.WorktreePath != "" && !sameAdoptPath(state.WorktreePath, worktree) { + return autoAdoptCandidate{}, false + } + // Both owner match and distinctive FilesTouched overlap are required. + // Boilerplate-only overlap (README.md, go.mod, …) is ignored even when + // the owner matches — those names collide across unrelated siblings. + overlapMatch := filesTouchedOverlap(state.FilesTouched, staged) + if !overlapMatch { + return autoAdoptCandidate{}, false + } + ownerMatch := hasOwner && ownerMatches(state.Owner, owner) + if !ownerMatch { + return autoAdoptCandidate{}, false + } + return autoAdoptCandidate{ + SessionID: state.SessionID, + WorktreePath: worktree, + CommonDir: commonDir, + Store: store, + OwnerMatch: ownerMatch, + OverlapMatch: overlapMatch, + }, true +} + +func isRecentLiveEntry(entry session.LiveSessionEntry) bool { + if entry.Phase != session.PhaseActive { + return false + } + if entry.LastInteractionTime == nil { + return false + } + // LiveSessionMaxAge matches adoptRecentWindow; registry TTL sweep uses the same bound. + return time.Since(*entry.LastInteractionTime) <= session.LiveSessionMaxAge +} + +// siblingLooksLikeGitWorktree reports whether dir has a .git entry (directory, +// gitfile, or symlink) so we can skip non-repos before spawning git. +func siblingLooksLikeGitWorktree(dir string) bool { + _, err := os.Lstat(filepath.Join(dir, ".git")) + return err == nil +} + +// siblingLooksLikeEntireRepo reports whether dir has a .entire/ directory +// (written by `entire enable`). Non-Entire siblings have nothing to adopt. +func siblingLooksLikeEntireRepo(dir string) bool { + _, err := os.Lstat(filepath.Join(dir, ".entire")) + return err == nil +} + +// autoAdoptSiblingProximity reports whether source and target worktrees share a +// parent directory (the same bound used by collectSiblingAutoAdoptCandidates). +func autoAdoptSiblingProximity(sourceWorktree, targetWorktree string) bool { + if sourceWorktree == "" || targetWorktree == "" { + return false + } + srcParent := filepath.Dir(sourceWorktree) + tgtParent := filepath.Dir(targetWorktree) + if srcParent == "" || tgtParent == "" || srcParent == sourceWorktree || tgtParent == targetWorktree { + return false + } + return sameAdoptPath(srcParent, tgtParent) +} + +func ownerMatches(recorded *proclive.Identity, current proclive.Identity) bool { + if recorded == nil || recorded.PID == 0 || current.PID == 0 { + return false + } + if recorded.PID != current.PID || recorded.Start != current.Start { + return false + } + // Boot mismatch means a reboot; Start is only unique within a single boot + // (same guard as proclive.Check). + if recorded.Boot != "" && current.Boot != "" && recorded.Boot != current.Boot { + return false + } + if recorded.Host != "" && current.Host != "" && recorded.Host != current.Host { + return false + } + return true +} + +// autoAdoptBoilerplateBasenames are relative-path basenames that commonly exist +// in every sibling repo. A match on these alone is not evidence the agent was +// working on the commit's real change set. +var autoAdoptBoilerplateBasenames = map[string]struct{}{ + "readme": {}, + "readme.md": {}, + "readme.rst": {}, + "readme.txt": {}, + "license": {}, + "license.md": {}, + "copying": {}, + "changelog.md": {}, + "contributing.md": {}, + "codeowners": {}, + ".gitignore": {}, + ".gitattributes": {}, + ".editorconfig": {}, + ".env": {}, + ".env.example": {}, + ".nvmrc": {}, + ".node-version": {}, + "go.mod": {}, + "go.sum": {}, + "package.json": {}, + "package-lock.json": {}, + "yarn.lock": {}, + "pnpm-lock.yaml": {}, + "cargo.toml": {}, + "cargo.lock": {}, + "gemfile": {}, + "gemfile.lock": {}, + "makefile": {}, + "dockerfile": {}, + "docker-compose.yml": {}, + "docker-compose.yaml": {}, + "tsconfig.json": {}, + "jsconfig.json": {}, + "pyproject.toml": {}, + "requirements.txt": {}, + "setup.py": {}, + "setup.cfg": {}, + "pipfile": {}, + "pipfile.lock": {}, + "composer.json": {}, + "composer.lock": {}, +} + +func filesTouchedOverlap(touched, staged []string) bool { + if len(touched) == 0 || len(staged) == 0 { + return false + } + stagedSet := make(map[string]struct{}, len(staged)) + for _, f := range staged { + stagedSet[filepath.ToSlash(filepath.Clean(f))] = struct{}{} + } + for _, f := range touched { + key := filepath.ToSlash(filepath.Clean(f)) + if _, ok := stagedSet[key]; !ok { + continue + } + if isAutoAdoptBoilerplatePath(key) { + continue + } + return true + } + return false +} + +func isAutoAdoptBoilerplatePath(rel string) bool { + base := strings.ToLower(filepath.Base(rel)) + _, ok := autoAdoptBoilerplateBasenames[base] + return ok +} + +func stagedFilesForAutoAdopt(ctx context.Context, repoRoot string) ([]string, error) { + cmd := exec.CommandContext(ctx, "git", "diff", "--cached", "--name-only") + cmd.Dir = repoRoot + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git diff --cached --name-only: %w", err) + } + trimmed := strings.TrimSpace(string(output)) + trimmed = strings.ReplaceAll(trimmed, "\r\n", "\n") + if trimmed == "" { + return nil, nil + } + var staged []string + for _, line := range strings.Split(trimmed, "\n") { + if line != "" { + staged = append(staged, filepath.ToSlash(line)) + } + } + return staged, nil +} + +func targetIsEntireEnabled(ctx context.Context) bool { + stngs, err := settings.Load(ctx) + if err != nil || stngs == nil { + return false + } + return stngs.Enabled +} diff --git a/cmd/entire/cli/session_auto_adopt_test.go b/cmd/entire/cli/session_auto_adopt_test.go new file mode 100644 index 0000000000..8110a336a6 --- /dev/null +++ b/cmd/entire/cli/session_auto_adopt_test.go @@ -0,0 +1,614 @@ +package cli + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/proclive" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +func TestAutoAdopt_PrepareCommitMsg_CrossCommonDirSibling(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + requireResolveOwner(t) + + base := t.TempDir() + sourceRepo := setupAdoptRepoAt(t, filepath.Join(base, "repo-a")) + targetRepo := setupAdoptRepoAt(t, filepath.Join(base, "repo-b")) + + sessionID := "test-auto-adopt-sibling-001" + // Distinctive path + matching owner: intentional multi-repo adopt. + seedAutoAdoptSourceSession(t, sourceRepo, sessionID, []string{"services/billing/handler.go"}, true) + + testutil.WriteFile(t, targetRepo, "services/billing/handler.go", "agent change\n") + testutil.GitAdd(t, targetRepo, "services/billing/handler.go") + t.Chdir(targetRepo) + + tryAutoAdoptCrossCommonDirSession(context.Background()) + + targetStore := session.NewStateStoreWithDir(filepath.Join(targetRepo, ".git", session.SessionStateDirName)) + adopted, err := targetStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if adopted == nil { + t.Fatal("expected auto-adopted session state in target repo") + } + if adopted.WorktreePath != targetRepo { + t.Fatalf("WorktreePath = %q, want %q", adopted.WorktreePath, targetRepo) + } + + commitMsgFile := filepath.Join(targetRepo, "COMMIT_EDITMSG") + if err := os.WriteFile(commitMsgFile, []byte("commit in B\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := strategy.NewManualCommitStrategy().PrepareCommitMsg(context.Background(), commitMsgFile, ""); err != nil { + t.Fatalf("PrepareCommitMsg: %v", err) + } + content, err := os.ReadFile(commitMsgFile) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "Entire-Checkpoint:") { + t.Fatalf("commit message = %q, want Entire-Checkpoint trailer after auto-adopt", string(content)) + } +} + +func TestAutoAdopt_SkipsUnrelatedSiblingSameRelativePath(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + base := t.TempDir() + sourceRepo := setupAdoptRepoAt(t, filepath.Join(base, "repo-a")) + targetRepo := setupAdoptRepoAt(t, filepath.Join(base, "repo-b")) + + // Same relative filename as the steal case (README.md) but no owner match. + seedAutoAdoptSourceSession(t, sourceRepo, "test-auto-adopt-steal-readme", []string{"README.md"}, false) + + testutil.WriteFile(t, targetRepo, "README.md", "unrelated human edit\n") + testutil.GitAdd(t, targetRepo, "README.md") + t.Chdir(targetRepo) + + tryAutoAdoptCrossCommonDirSession(context.Background()) + + targetStore := session.NewStateStoreWithDir(filepath.Join(targetRepo, ".git", session.SessionStateDirName)) + adopted, err := targetStore.Load(context.Background(), "test-auto-adopt-steal-readme") + if err != nil { + t.Fatal(err) + } + if adopted != nil { + t.Fatal("same relative path without owner match must not steal sibling session") + } +} + +func TestAutoAdopt_SkipsBoilerplateOverlapEvenWithOwner(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + requireResolveOwner(t) + + base := t.TempDir() + sourceRepo := setupAdoptRepoAt(t, filepath.Join(base, "repo-a")) + targetRepo := setupAdoptRepoAt(t, filepath.Join(base, "repo-b")) + + // Owner matches + README.md overlap must NOT auto-adopt (boilerplate). + seedAutoAdoptSourceSession(t, sourceRepo, "test-auto-adopt-boilerplate", []string{"README.md", "go.mod"}, true) + + testutil.WriteFile(t, targetRepo, "README.md", "unrelated human edit\n") + testutil.GitAdd(t, targetRepo, "README.md") + t.Chdir(targetRepo) + + tryAutoAdoptCrossCommonDirSession(context.Background()) + + targetStore := session.NewStateStoreWithDir(filepath.Join(targetRepo, ".git", session.SessionStateDirName)) + adopted, err := targetStore.Load(context.Background(), "test-auto-adopt-boilerplate") + if err != nil { + t.Fatal(err) + } + if adopted != nil { + t.Fatal("boilerplate-only FilesTouched overlap must not auto-adopt even with owner match") + } +} + +func TestFilesTouchedOverlap_IgnoresBoilerplate(t *testing.T) { + t.Parallel() + + if filesTouchedOverlap([]string{"README.md"}, []string{"README.md"}) { + t.Fatal("README.md alone must not count as overlap") + } + if filesTouchedOverlap([]string{"go.mod", "package.json"}, []string{"go.mod"}) { + t.Fatal("go.mod alone must not count as overlap") + } + if !filesTouchedOverlap([]string{"README.md", "services/billing/handler.go"}, []string{"services/billing/handler.go"}) { + t.Fatal("distinctive path overlap must count even alongside boilerplate") + } + if !filesTouchedOverlap([]string{"internal/foo.go"}, []string{"internal/foo.go"}) { + t.Fatal("non-boilerplate path must count") + } +} + +func TestAutoAdopt_PrepareCommitMsg_ViaLiveRegistry(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + requireResolveOwner(t) + + // Sibling dirs under one parent: registry discover + proximity both apply. + base := t.TempDir() + sourceRepo := setupAdoptRepoAt(t, filepath.Join(base, "repo-a")) + targetRepo := setupAdoptRepoAt(t, filepath.Join(base, "repo-b")) + + sessionID := "test-auto-adopt-registry-001" + seedAutoAdoptSourceSession(t, sourceRepo, sessionID, []string{"feature.txt"}, true) + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + testutil.GitAdd(t, targetRepo, "feature.txt") + t.Chdir(targetRepo) + + tryAutoAdoptCrossCommonDirSession(context.Background()) + + targetStore := session.NewStateStoreWithDir(filepath.Join(targetRepo, ".git", session.SessionStateDirName)) + adopted, err := targetStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if adopted == nil { + t.Fatal("expected registry-based auto-adopt into target repo") + } + + commitMsgFile := filepath.Join(targetRepo, "COMMIT_EDITMSG") + if err := os.WriteFile(commitMsgFile, []byte("commit in B\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := strategy.NewManualCommitStrategy().PrepareCommitMsg(context.Background(), commitMsgFile, ""); err != nil { + t.Fatalf("PrepareCommitMsg: %v", err) + } + content, err := os.ReadFile(commitMsgFile) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "Entire-Checkpoint:") { + t.Fatalf("commit message = %q, want Entire-Checkpoint trailer", string(content)) + } +} + +func TestAutoAdopt_SkipsDistantRegistryEntry(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + requireResolveOwner(t) + + // Nest under distinct parents — bare t.TempDir() siblings share a parent on macOS. + sourceRepo := setupAdoptRepoAt(t, filepath.Join(t.TempDir(), "src-nest", "repo")) + targetRepo := setupAdoptRepoAt(t, filepath.Join(t.TempDir(), "dst-nest", "repo")) + + sessionID := "test-auto-adopt-distant-registry" + seedAutoAdoptSourceSession(t, sourceRepo, sessionID, []string{"README.md"}, true) + + testutil.WriteFile(t, targetRepo, "README.md", "unrelated human edit\n") + testutil.GitAdd(t, targetRepo, "README.md") + t.Chdir(targetRepo) + + tryAutoAdoptCrossCommonDirSession(context.Background()) + + targetStore := session.NewStateStoreWithDir(filepath.Join(targetRepo, ".git", session.SessionStateDirName)) + adopted, err := targetStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if adopted != nil { + t.Fatal("distant registry entry must not auto-adopt even with owner+overlap") + } +} + +func TestAutoAdopt_SkipsIdleSibling(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + requireResolveOwner(t) + + base := t.TempDir() + sourceRepo := setupAdoptRepoAt(t, filepath.Join(base, "repo-a")) + targetRepo := setupAdoptRepoAt(t, filepath.Join(base, "repo-b")) + + sessionID := "test-auto-adopt-idle-sibling" + seedAutoAdoptSourceSession(t, sourceRepo, sessionID, []string{"feature.txt"}, true) + + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + state, err := sourceStore.Load(context.Background(), sessionID) + if err != nil || state == nil { + t.Fatalf("load source: %v", err) + } + state.Phase = session.PhaseIdle + if err := sourceStore.Save(context.Background(), state); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + testutil.GitAdd(t, targetRepo, "feature.txt") + t.Chdir(targetRepo) + + tryAutoAdoptCrossCommonDirSession(context.Background()) + + targetStore := session.NewStateStoreWithDir(filepath.Join(targetRepo, ".git", session.SessionStateDirName)) + adopted, err := targetStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if adopted != nil { + t.Fatal("Idle sibling must not be auto-adopted") + } +} + +func TestAutoAdoptSiblingProximity(t *testing.T) { + t.Parallel() + base := t.TempDir() + a := filepath.Join(base, "a") + b := filepath.Join(base, "b") + if !autoAdoptSiblingProximity(a, b) { + t.Fatal("siblings under same parent should match") + } + if autoAdoptSiblingProximity(a, filepath.Join(t.TempDir(), "other")) { + t.Fatal("distinct parents must not match") + } +} + +func TestOwnerMatches_RejectsBootMismatch(t *testing.T) { + t.Parallel() + + recorded := &proclive.Identity{PID: 42, Start: "tick", Boot: "boot-a", Host: "host"} + current := proclive.Identity{PID: 42, Start: "tick", Boot: "boot-b", Host: "host"} + if ownerMatches(recorded, current) { + t.Fatal("Boot mismatch must reject owner match (post-reboot PID reuse)") + } + current.Boot = "boot-a" + if !ownerMatches(recorded, current) { + t.Fatal("matching Boot should allow owner match") + } + // Empty Boot on either side is best-effort (legacy / unknown); don't fail closed. + if !ownerMatches(recorded, proclive.Identity{PID: 42, Start: "tick", Host: "host"}) { + t.Fatal("empty current Boot should not reject when PID+Start match") + } +} + +func TestSiblingLooksLikeGitWorktree(t *testing.T) { + t.Parallel() + + base := t.TempDir() + plain := filepath.Join(base, "plain") + if err := os.Mkdir(plain, 0o755); err != nil { + t.Fatal(err) + } + if siblingLooksLikeGitWorktree(plain) { + t.Fatal("dir without .git must not look like a worktree") + } + + withDir := filepath.Join(base, "with-dir") + if err := os.MkdirAll(filepath.Join(withDir, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if !siblingLooksLikeGitWorktree(withDir) { + t.Fatal(".git directory must look like a worktree") + } + + withFile := filepath.Join(base, "with-file") + if err := os.Mkdir(withFile, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(withFile, ".git"), []byte("gitdir: ../with-dir/.git\n"), 0o644); err != nil { + t.Fatal(err) + } + if !siblingLooksLikeGitWorktree(withFile) { + t.Fatal(".git gitfile must look like a worktree") + } +} + +func TestSiblingLooksLikeEntireRepo(t *testing.T) { + t.Parallel() + + base := t.TempDir() + plain := filepath.Join(base, "plain") + if err := os.MkdirAll(filepath.Join(plain, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if siblingLooksLikeEntireRepo(plain) { + t.Fatal("git repo without .entire must not look Entire-enabled") + } + if err := os.Mkdir(filepath.Join(plain, ".entire"), 0o755); err != nil { + t.Fatal(err) + } + if !siblingLooksLikeEntireRepo(plain) { + t.Fatal(".entire directory must look Entire-enabled") + } +} + +func TestAutoAdopt_SkipsWhenAmbiguous(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + requireResolveOwner(t) + + base := t.TempDir() + sourceA := setupAdoptRepoAt(t, filepath.Join(base, "repo-a")) + sourceC := setupAdoptRepoAt(t, filepath.Join(base, "repo-c")) + targetRepo := setupAdoptRepoAt(t, filepath.Join(base, "repo-b")) + + seedAutoAdoptSourceSession(t, sourceA, "test-auto-adopt-ambig-a", []string{"feature.txt"}, true) + seedAutoAdoptSourceSession(t, sourceC, "test-auto-adopt-ambig-c", []string{"feature.txt"}, true) + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + testutil.GitAdd(t, targetRepo, "feature.txt") + t.Chdir(targetRepo) + + tryAutoAdoptCrossCommonDirSession(context.Background()) + + targetStore := session.NewStateStoreWithDir(filepath.Join(targetRepo, ".git", session.SessionStateDirName)) + states, err := targetStore.List(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(states) != 0 { + t.Fatalf("ambiguous sources must not auto-adopt; got %d states", len(states)) + } +} + +func TestAutoAdopt_SkipsWithoutFilesTouchedOverlap(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + requireResolveOwner(t) + + base := t.TempDir() + sourceRepo := setupAdoptRepoAt(t, filepath.Join(base, "repo-a")) + targetRepo := setupAdoptRepoAt(t, filepath.Join(base, "repo-b")) + + seedAutoAdoptSourceSession(t, sourceRepo, "test-auto-adopt-no-overlap", []string{"other.txt"}, true) + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + testutil.GitAdd(t, targetRepo, "feature.txt") + t.Chdir(targetRepo) + + tryAutoAdoptCrossCommonDirSession(context.Background()) + + targetStore := session.NewStateStoreWithDir(filepath.Join(targetRepo, ".git", session.SessionStateDirName)) + adopted, err := targetStore.Load(context.Background(), "test-auto-adopt-no-overlap") + if err != nil { + t.Fatal(err) + } + if adopted != nil { + t.Fatal("must not auto-adopt without FilesTouched overlap") + } +} + +func TestAutoAdopt_SkipsWhenLocalActiveSessionExists(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + requireResolveOwner(t) + + base := t.TempDir() + sourceRepo := setupAdoptRepoAt(t, filepath.Join(base, "repo-a")) + targetRepo := setupAdoptRepoAt(t, filepath.Join(base, "repo-b")) + + seedAutoAdoptSourceSession(t, sourceRepo, "test-auto-adopt-remote", []string{"feature.txt"}, true) + + localID := "test-auto-adopt-local" + lastInteraction := time.Now().Add(-1 * time.Minute) + targetStore := session.NewStateStoreWithDir(filepath.Join(targetRepo, ".git", session.SessionStateDirName)) + if err := targetStore.Save(context.Background(), &session.State{ + SessionID: localID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, targetRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, targetRepo), + WorktreePath: targetRepo, + FilesTouched: []string{"feature.txt"}, + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + testutil.GitAdd(t, targetRepo, "feature.txt") + t.Chdir(targetRepo) + + tryAutoAdoptCrossCommonDirSession(context.Background()) + + remote, err := targetStore.Load(context.Background(), "test-auto-adopt-remote") + if err != nil { + t.Fatal(err) + } + if remote != nil { + t.Fatal("must not auto-adopt when target already has an active session") + } +} + +func setupAdoptRepoAt(t *testing.T, repoDir string) string { + t.Helper() + if err := os.MkdirAll(repoDir, 0o750); err != nil { + t.Fatal(err) + } + testutil.InitRepo(t, repoDir) + testutil.WriteFile(t, repoDir, "init.txt", "init\n") + testutil.GitAdd(t, repoDir, "init.txt") + testutil.GitCommit(t, repoDir, "init") + enableEntire(t, repoDir) + realRepoDir, err := filepath.EvalSymlinks(repoDir) + if err != nil { + t.Fatal(err) + } + return realRepoDir +} + +func seedAutoAdoptSourceSession(t *testing.T, sourceRepo, sessionID string, filesTouched []string, withOwner bool) { + t.Helper() + + transcriptPath := claudeAdoptTranscriptPath(t, sourceRepo, sessionID) + if err := os.MkdirAll(filepath.Dir(transcriptPath), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(transcriptPath, []byte(`{"type":"user","message":{"role":"user","content":"cross-repo work"}}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + lastInteraction := time.Now().Add(-1 * time.Minute) + var owner *proclive.Identity + if withOwner { + id, ok := proclive.ResolveOwner() + if !ok { + t.Fatal("withOwner requested but ResolveOwner failed") + } + owner = &id + } + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + TranscriptPath: transcriptPath, + LastPrompt: "cross-repo work", + FilesTouched: filesTouched, + StepCount: 1, + Owner: owner, + }); err != nil { + t.Fatal(err) + } +} + +func requireResolveOwner(t *testing.T) { + t.Helper() + if _, ok := proclive.ResolveOwner(); !ok { + t.Skip("ResolveOwner unavailable on this platform") + } +} + +func TestAutoAdopt_SkipsOwnerMatchWithoutOverlap(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + requireResolveOwner(t) + + base := t.TempDir() + sourceRepo := setupAdoptRepoAt(t, filepath.Join(base, "repo-a")) + targetRepo := setupAdoptRepoAt(t, filepath.Join(base, "repo-b")) + + // Owner matches but FilesTouched does not overlap staged feature.txt. + seedAutoAdoptSourceSession(t, sourceRepo, "test-auto-adopt-owner-only", []string{"other.txt"}, true) + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + testutil.GitAdd(t, targetRepo, "feature.txt") + t.Chdir(targetRepo) + + tryAutoAdoptCrossCommonDirSession(context.Background()) + + targetStore := session.NewStateStoreWithDir(filepath.Join(targetRepo, ".git", session.SessionStateDirName)) + adopted, err := targetStore.Load(context.Background(), "test-auto-adopt-owner-only") + if err != nil { + t.Fatal(err) + } + if adopted != nil { + t.Fatal("owner match without FilesTouched overlap must not auto-adopt") + } +} + +// shouldTryAutoAdoptOnPrepareCommitMsg uses t.Chdir(), so no t.Parallel(). +func TestShouldTryAutoAdoptOnPrepareCommitMsg(t *testing.T) { + repo := setupAdoptRepoAt(t, filepath.Join(t.TempDir(), "repo")) + t.Chdir(repo) + + if !shouldTryAutoAdoptOnPrepareCommitMsg(context.Background(), "") { + t.Fatal("empty source in clean repo should allow auto-adopt") + } + if !shouldTryAutoAdoptOnPrepareCommitMsg(context.Background(), "message") { + t.Fatal("message source in clean repo should allow auto-adopt") + } + if shouldTryAutoAdoptOnPrepareCommitMsg(context.Background(), "merge") { + t.Fatal("merge source must skip auto-adopt") + } + if shouldTryAutoAdoptOnPrepareCommitMsg(context.Background(), "squash") { + t.Fatal("squash source must skip auto-adopt") + } + if shouldTryAutoAdoptOnPrepareCommitMsg(context.Background(), prepareCommitMsgSourceAmend) { + t.Fatal("amend (source=commit) must skip auto-adopt") + } + + if err := os.MkdirAll(filepath.Join(repo, ".git", "rebase-merge"), 0o755); err != nil { + t.Fatal(err) + } + if shouldTryAutoAdoptOnPrepareCommitMsg(context.Background(), "") { + t.Fatal("rebase sequence must skip auto-adopt even for empty source") + } + if shouldTryAutoAdoptOnPrepareCommitMsg(context.Background(), "message") { + t.Fatal("rebase sequence must skip auto-adopt for message source") + } +} + +func TestCandidateFromLoaded_RejectsWorktreePathMismatch(t *testing.T) { + t.Parallel() + + now := time.Now() + state := &session.State{ + SessionID: "mismatch", + Phase: session.PhaseActive, + LastInteractionTime: &now, + WorktreePath: "/other/worktree", + FilesTouched: []string{"feature.txt"}, + } + _, ok := candidateFromLoaded(nil, "/scanned/worktree", "/common", state, []string{"feature.txt"}, proclive.Identity{}, false) + if ok { + t.Fatal("stale WorktreePath mismatch must reject candidate") + } +} + +func TestCandidateFromLoaded_RejectsIdle(t *testing.T) { + t.Parallel() + + now := time.Now() + owner := proclive.Identity{PID: 1, Start: "s"} + state := &session.State{ + SessionID: "idle", + Phase: session.PhaseIdle, + LastInteractionTime: &now, + WorktreePath: "/scanned/worktree", + FilesTouched: []string{"feature.txt"}, + Owner: &owner, + } + _, ok := candidateFromLoaded(nil, "/scanned/worktree", "/common", state, []string{"feature.txt"}, owner, true) + if ok { + t.Fatal("Idle session must not be an auto-adopt candidate") + } +} + +func TestClearInvalidAdoptTranscript_WarnsAndClears(t *testing.T) { + // Redirects os.Stderr; not parallel-safe. + oldStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stderr = w + t.Cleanup(func() { + os.Stderr = oldStderr + _ = w.Close() + _ = r.Close() + }) + + state := &session.State{ + SessionID: "test-clear-invalid-transcript", + AgentType: agent.AgentTypeClaudeCode, + TranscriptPath: "/tmp/not-an-agent-transcript.jsonl", + } + clearInvalidAdoptTranscript(context.Background(), state, t.TempDir()) + _ = w.Close() + os.Stderr = oldStderr + + var buf strings.Builder + if _, err := io.Copy(&buf, r); err != nil { + t.Fatal(err) + } + _ = r.Close() + + if state.TranscriptPath != "" { + t.Fatalf("TranscriptPath = %q, want cleared", state.TranscriptPath) + } + if !strings.Contains(buf.String(), "lost its transcript pointer") { + t.Fatalf("stderr = %q, want transcript-pointer warning", buf.String()) + } +} diff --git a/cmd/entire/cli/strategy/hooks.go b/cmd/entire/cli/strategy/hooks.go index 98e183711f..7b0fc17a10 100644 --- a/cmd/entire/cli/strategy/hooks.go +++ b/cmd/entire/cli/strategy/hooks.go @@ -175,6 +175,9 @@ func isGitHookInstalledInHooksDir(hooksDir string) bool { // buildHookSpecs returns the hook specifications for all managed hooks. func buildHookSpecs(cmdPrefix string) []hookSpec { + // Swallow stderr: logging may fall back to os.Stderr when .entire/logs is + // unwritable. Intentional user warnings from this hook write to /dev/tty + // (see writeAdoptUserWarning) so they still surface. prepareCommitMsgCmd := gitHookCommand(cmdPrefix, `prepare-commit-msg "$1" "$2" 2>/dev/null || true`, false) commitMsgCmd := gitHookCommand(cmdPrefix, `commit-msg "$1" || true`, true) postCommitCmd := gitHookCommand(cmdPrefix, `post-commit 2>/dev/null || true`, false) diff --git a/cmd/entire/cli/strategy/hooks_test.go b/cmd/entire/cli/strategy/hooks_test.go index e818fb62ba..a4b762a432 100644 --- a/cmd/entire/cli/strategy/hooks_test.go +++ b/cmd/entire/cli/strategy/hooks_test.go @@ -425,13 +425,13 @@ func initHooksWorktreeRepo(t *testing.T) (string, string) { return mainRepo, worktreeDir } -// isGitSequenceOperation tests use t.Chdir() so cannot call t.Parallel(). +// IsGitSequenceOperation tests use t.Chdir() so cannot call t.Parallel(). func TestIsGitSequenceOperation_NoOperation(t *testing.T) { initHooksTestRepo(t) - if isGitSequenceOperation(context.Background()) { - t.Error("isGitSequenceOperation(context.Background()) = true, want false for clean repo") + if IsGitSequenceOperation(context.Background()) { + t.Error("IsGitSequenceOperation(context.Background()) = true, want false for clean repo") } } @@ -442,8 +442,8 @@ func TestIsGitSequenceOperation_RebaseMerge(t *testing.T) { t.Fatalf("failed to create rebase-merge dir: %v", err) } - if !isGitSequenceOperation(context.Background()) { - t.Error("isGitSequenceOperation(context.Background()) = false, want true during rebase-merge") + if !IsGitSequenceOperation(context.Background()) { + t.Error("IsGitSequenceOperation(context.Background()) = false, want true during rebase-merge") } } @@ -454,8 +454,8 @@ func TestIsGitSequenceOperation_RebaseApply(t *testing.T) { t.Fatalf("failed to create rebase-apply dir: %v", err) } - if !isGitSequenceOperation(context.Background()) { - t.Error("isGitSequenceOperation(context.Background()) = false, want true during rebase-apply") + if !IsGitSequenceOperation(context.Background()) { + t.Error("IsGitSequenceOperation(context.Background()) = false, want true during rebase-apply") } } @@ -466,8 +466,8 @@ func TestIsGitSequenceOperation_CherryPick(t *testing.T) { t.Fatalf("failed to create CHERRY_PICK_HEAD: %v", err) } - if !isGitSequenceOperation(context.Background()) { - t.Error("isGitSequenceOperation(context.Background()) = false, want true during cherry-pick") + if !IsGitSequenceOperation(context.Background()) { + t.Error("IsGitSequenceOperation(context.Background()) = false, want true during cherry-pick") } } @@ -478,8 +478,8 @@ func TestIsGitSequenceOperation_Revert(t *testing.T) { t.Fatalf("failed to create REVERT_HEAD: %v", err) } - if !isGitSequenceOperation(context.Background()) { - t.Error("isGitSequenceOperation(context.Background()) = false, want true during revert") + if !IsGitSequenceOperation(context.Background()) { + t.Error("IsGitSequenceOperation(context.Background()) = false, want true during revert") } } @@ -549,8 +549,8 @@ func TestIsGitSequenceOperation_Worktree(t *testing.T) { t.Chdir(worktreeDir) // Should not detect sequence operation in clean worktree - if isGitSequenceOperation(context.Background()) { - t.Error("isGitSequenceOperation(context.Background()) = true in clean worktree, want false") + if IsGitSequenceOperation(context.Background()) { + t.Error("IsGitSequenceOperation(context.Background()) = true in clean worktree, want false") } // Get the worktree's git dir and simulate rebase state there @@ -568,8 +568,8 @@ func TestIsGitSequenceOperation_Worktree(t *testing.T) { } // Now should detect sequence operation - if !isGitSequenceOperation(context.Background()) { - t.Error("isGitSequenceOperation(context.Background()) = false in worktree during rebase, want true") + if !IsGitSequenceOperation(context.Background()) { + t.Error("IsGitSequenceOperation(context.Background()) = false in worktree during rebase, want true") } } @@ -683,6 +683,15 @@ func TestGitHookCommand_LocalDevDelegatesToScript(t *testing.T) { } } +func TestBuildHookSpecs_PrepareCommitMsgSwallowsStderr(t *testing.T) { + t.Parallel() + + hook := findHookSpec(t, buildHookSpecs("entire"), "prepare-commit-msg") + if !strings.Contains(hook.content, `prepare-commit-msg "$1" "$2" 2>/dev/null || true`) { + t.Fatalf("prepare-commit-msg should swallow stderr (logging fallbacks) and exit code, got:\n%s", hook.content) + } +} + func TestEntireDevScript_FallsBackToBinaryWhenBuildFails(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index fbe29dbef6..f21c88b0d3 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -306,7 +306,7 @@ func stripCheckpointTrailer(message string) string { return strings.Join(result, "\n") } -// isGitSequenceOperation checks if git is currently in the middle of a rebase, +// IsGitSequenceOperation checks if git is currently in the middle of a rebase, // cherry-pick, or revert operation. During these operations, commits are being // replayed and should not be linked to agent sessions. // @@ -314,7 +314,7 @@ func stripCheckpointTrailer(message string) string { // - rebase: .git/rebase-merge/ or .git/rebase-apply/ directories // - cherry-pick: .git/CHERRY_PICK_HEAD file // - revert: .git/REVERT_HEAD file -func isGitSequenceOperation(ctx context.Context) bool { +func IsGitSequenceOperation(ctx context.Context) bool { // Get git directory (handles worktrees and relative paths correctly) gitDir, err := GetGitDir(ctx) if err != nil { @@ -359,7 +359,7 @@ func (s *ManualCommitStrategy) PrepareCommitMsg(ctx context.Context, commitMsgFi // Skip during rebase, cherry-pick, or revert operations // These are replaying existing commits and should not be linked to agent sessions - if isGitSequenceOperation(ctx) { + if IsGitSequenceOperation(ctx) { logging.Debug(logCtx, "prepare-commit-msg: skipped during git sequence operation", slog.String("strategy", "manual-commit"), slog.String("source", source), @@ -936,7 +936,7 @@ func (s *ManualCommitStrategy) PostCommit(ctx context.Context) error { } // Build transition context - isRebase := isGitSequenceOperation(ctx) + isRebase := IsGitSequenceOperation(ctx) transitionCtx := session.TransitionContext{ IsRebaseInProgress: isRebase, } diff --git a/cmd/entire/cli/strategy/session_state.go b/cmd/entire/cli/strategy/session_state.go index cc8603db57..43ae094a66 100644 --- a/cmd/entire/cli/strategy/session_state.go +++ b/cmd/entire/cli/strategy/session_state.go @@ -609,11 +609,7 @@ func WithSessionStateLocks(ctx context.Context, sessionID string, commonDirs []s } } for _, lockPath := range lockPaths { - if err := ctx.Err(); err != nil { - releaseAll() - return fmt.Errorf("session state lock canceled: %w", err) - } - release, err := flock.Acquire(lockPath) + release, err := flock.AcquireContext(ctx, lockPath) if err != nil { releaseAll() return fmt.Errorf("acquire session state lock: %w", err)