diff --git a/CHANGELOG.md b/CHANGELOG.md index db5fd1629..17d014d2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- `entire stop` command to manually mark one or more active sessions as ended, preventing future checkpoint leakage - Sparse metadata fetch with on-demand blob resolution for reduced memory and network cost ([#680](https://github.com/entireio/cli/pull/680), [#721](https://github.com/entireio/cli/pull/721)) - `entire trace` command for diagnosing slow performance hooks and lifecycle events ([#652](https://github.com/entireio/cli/pull/652)) - Opt-in PII redaction with typed tokens ([#397](https://github.com/entireio/cli/pull/397)) diff --git a/README.md b/README.md index a31a170b6..c9760519d 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,7 @@ go test -tags=integration ./cmd/entire/cli/integration_test -run TestLogin | `entire resume` | Switch to a branch, restore latest checkpointed session metadata, and show command(s) to continue | | `entire rewind` | Rewind to a previous checkpoint | | `entire status` | Show current session info | +| `entire stop` | Mark one or more active sessions as ended | | `entire version` | Show Entire CLI version | ### `entire enable` Flags diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index 445fbf820..404aff0f9 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -83,6 +83,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newResumeCmd()) cmd.AddCommand(newCleanCmd()) cmd.AddCommand(newResetCmd()) + cmd.AddCommand(newStopCmd()) cmd.AddCommand(newSetupCmd()) cmd.AddCommand(newEnableCmd()) cmd.AddCommand(newDisableCmd()) diff --git a/cmd/entire/cli/stop.go b/cmd/entire/cli/stop.go new file mode 100644 index 000000000..1df98ecee --- /dev/null +++ b/cmd/entire/cli/stop.go @@ -0,0 +1,318 @@ +package cli + +import ( + "context" + "errors" + "fmt" + + "github.com/charmbracelet/huh" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/spf13/cobra" +) + +func newStopCmd() *cobra.Command { + var sessionFlag string + var allFlag bool + var forceFlag bool + + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop one or more active sessions", + Long: `Mark one or more active sessions as ended. + +Fires EventSessionStop through the state machine with a no-op action handler, +so no condensation or checkpoint-writing occurs. To flush pending work, commit first. + +Examples: + entire stop No sessions: exits. One session: confirm and stop. Multiple: show selector + entire stop --session Stop a specific session by ID + entire stop --all Stop all active sessions in current worktree + entire stop --force Skip confirmation prompt`, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + if allFlag && sessionFlag != "" { + return errors.New("--all and --session are mutually exclusive") + } + + // Check if in git repository + if _, err := paths.WorktreeRoot(ctx); err != nil { + cmd.SilenceUsage = true + fmt.Fprintln(cmd.ErrOrStderr(), "Not a git repository.") + return NewSilentError(errors.New("not a git repository")) + } + + return runStop(ctx, cmd, sessionFlag, allFlag, forceFlag) + }, + } + + cmd.Flags().StringVar(&sessionFlag, "session", "", "Stop a specific session by ID (not scoped to current worktree)") + cmd.Flags().BoolVar(&allFlag, "all", false, "Stop all active sessions in current worktree") + cmd.Flags().BoolVarP(&forceFlag, "force", "f", false, "Skip confirmation prompt") + + return cmd +} + +// runStop is the main logic for the stop command. +func runStop(ctx context.Context, cmd *cobra.Command, sessionID string, all, force bool) error { + // --session path: stop a specific session by explicit ID (no worktree scoping). + // Explicit ID is already a deliberate action — no confirmation needed. + if sessionID != "" { + return runStopSession(ctx, cmd, sessionID, true) + } + + // List all session states + states, err := strategy.ListSessionStates(ctx) + if err != nil { + return fmt.Errorf("failed to list sessions: %w", err) + } + + activeSessions := filterActiveSessions(states) + + // --all path: stop all active sessions in current worktree (scoped inside runStopAll). + if all { + return runStopAll(ctx, cmd, activeSessions, force) + } + + // No-flags path: scope to current worktree before presenting options. + // RunE already validated the git repo, so this call succeeds in practice. + worktreePath, err := paths.WorktreeRoot(ctx) + if err != nil { + return fmt.Errorf("failed to resolve worktree root: %w", err) + } + var scoped []*strategy.SessionState + for _, s := range activeSessions { + if s.WorktreePath == worktreePath || s.WorktreePath == "" { + scoped = append(scoped, s) + } + } + + if len(scoped) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No active sessions.") + return nil + } + + // One active session: confirm + stop. + if len(scoped) == 1 { + return runStopSession(ctx, cmd, scoped[0].SessionID, force) + } + + // Multiple active sessions: show TUI multi-select. + return runStopMultiSelect(ctx, cmd, scoped, force) +} + +// filterActiveSessions returns sessions in PhaseIdle or PhaseActive — all sessions +// that have not been explicitly ended. Both phases are considered stoppable: IDLE +// means the agent finished its last turn but the session is still open. +// +// The dual check (Phase != PhaseEnded AND EndedAt == nil) is intentionally stricter +// than status.go's EndedAt-only check: it ensures sessions where only the state +// machine transition succeeded (Phase=Ended) but EndedAt was never written are still +// treated as ended, avoiding an accidental re-stop of a partially-ended session. +func filterActiveSessions(states []*strategy.SessionState) []*strategy.SessionState { + var active []*strategy.SessionState + for _, s := range states { + if s == nil { + continue + } + if s.Phase != session.PhaseEnded && s.EndedAt == nil { + active = append(active, s) + } + } + return active +} + +// runStopSession stops a single session by ID, with optional confirmation. +func runStopSession(ctx context.Context, cmd *cobra.Command, sessionID string, force bool) error { + state, err := strategy.LoadSessionState(ctx, sessionID) + if err != nil { + return fmt.Errorf("failed to load session: %w", err) + } + if state == nil { + cmd.SilenceUsage = true + fmt.Fprintln(cmd.ErrOrStderr(), "Session not found.") + return NewSilentError(fmt.Errorf("session not found: %s", sessionID)) + } + + if state.Phase == session.PhaseEnded || state.EndedAt != nil { + fmt.Fprintf(cmd.OutOrStdout(), "Session %s is already stopped.\n", sessionID) + return nil + } + + if !force { + var confirmed bool + form := NewAccessibleForm( + huh.NewGroup( + huh.NewConfirm(). + Title(fmt.Sprintf("Stop session %s?", sessionID)). + Value(&confirmed), + ), + ) + if err := form.Run(); err != nil { + return handleFormCancellation(cmd.OutOrStdout(), "Stop", err) + } + if !confirmed { + fmt.Fprintln(cmd.OutOrStdout(), "Stop cancelled.") + return nil + } + } + + return stopSessionAndPrint(ctx, cmd, state) +} + +// runStopAll stops all active sessions scoped to the current worktree. +func runStopAll(ctx context.Context, cmd *cobra.Command, activeSessions []*strategy.SessionState, force bool) error { + // Scope to current worktree. Sessions with an empty WorktreePath predate + // worktree-path tracking and cannot be attributed to any specific worktree — + // including them here prevents them from being permanently unreachable via --all. + // RunE already validated the git repo, so this call succeeds in practice. + worktreePath, err := paths.WorktreeRoot(ctx) + if err != nil { + return fmt.Errorf("failed to resolve worktree root: %w", err) + } + + var toStop []*strategy.SessionState + for _, s := range activeSessions { + if s.WorktreePath == worktreePath || s.WorktreePath == "" { + toStop = append(toStop, s) + } + } + + if len(toStop) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No active sessions.") + return nil + } + + if !force { + var confirmed bool + form := NewAccessibleForm( + huh.NewGroup( + huh.NewConfirm(). + Title(fmt.Sprintf("Stop %d session(s)?", len(toStop))). + Value(&confirmed), + ), + ) + if err := form.Run(); err != nil { + return handleFormCancellation(cmd.OutOrStdout(), "Stop", err) + } + if !confirmed { + fmt.Fprintln(cmd.OutOrStdout(), "Stop cancelled.") + return nil + } + } + + return stopSelectedSessions(ctx, cmd, toStop) +} + +// runStopMultiSelect shows a TUI multi-select for multiple active sessions. +func runStopMultiSelect(ctx context.Context, cmd *cobra.Command, activeSessions []*strategy.SessionState, force bool) error { + options := make([]huh.Option[string], len(activeSessions)) + for i, s := range activeSessions { + label := fmt.Sprintf("%s · %s", s.AgentType, s.SessionID) + if s.LastPrompt != "" { + label = fmt.Sprintf("%s · %q", label, s.LastPrompt) + } + options[i] = huh.NewOption(label, s.SessionID) + } + + var selectedIDs []string + form := NewAccessibleForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Select sessions to stop"). + Description("Use space to select, enter to confirm."). + Options(options...). + Value(&selectedIDs), + ), + ) + if err := form.Run(); err != nil { + return handleFormCancellation(cmd.OutOrStdout(), "Stop", err) + } + + if len(selectedIDs) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "Stop cancelled.") + return nil + } + + // Build a map for quick lookup + stateByID := make(map[string]*strategy.SessionState, len(activeSessions)) + for _, s := range activeSessions { + stateByID[s.SessionID] = s + } + + // Confirm only if not forcing + if !force { + var confirmed bool + form := NewAccessibleForm( + huh.NewGroup( + huh.NewConfirm(). + Title(fmt.Sprintf("Stop %d session(s)?", len(selectedIDs))). + Value(&confirmed), + ), + ) + if err := form.Run(); err != nil { + return handleFormCancellation(cmd.OutOrStdout(), "Stop", err) + } + if !confirmed { + fmt.Fprintln(cmd.OutOrStdout(), "Stop cancelled.") + return nil + } + } + + var toStop []*strategy.SessionState + for _, id := range selectedIDs { + if s, ok := stateByID[id]; ok { + toStop = append(toStop, s) + } else { + // Session was concurrently stopped between form render and confirmation. + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: session %s no longer found, skipping.\n", id) + } + } + if len(toStop) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No sessions to stop.") + return nil + } + return stopSelectedSessions(ctx, cmd, toStop) +} + +// stopSelectedSessions stops each session in the list and prints a result line. +// Errors from individual sessions are accumulated so a single failure does not +// prevent remaining sessions from being stopped. Each failure is printed to stderr +// immediately so the user knows which sessions could not be stopped. +func stopSelectedSessions(ctx context.Context, cmd *cobra.Command, sessions []*strategy.SessionState) error { + var errs []error + for _, s := range sessions { + if err := stopSessionAndPrint(ctx, cmd, s); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "✗ %v\n", err) + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// stopSessionAndPrint stops a session and prints a summary line. +// Fields needed for output are read before calling markSessionEnded because +// markSessionEnded loads and operates on its own copy of the session state by ID — +// it does not update the caller's state pointer. +func stopSessionAndPrint(ctx context.Context, cmd *cobra.Command, state *strategy.SessionState) error { + sessionID := state.SessionID + lastCheckpointID := state.LastCheckpointID + stepCount := state.StepCount + + if err := markSessionEnded(ctx, nil, sessionID); err != nil { + return fmt.Errorf("failed to stop session %s: %w", sessionID, err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "✓ Session %s stopped.\n", sessionID) + switch { + case lastCheckpointID != "": + fmt.Fprintf(cmd.OutOrStdout(), " Checkpoint: %s\n", lastCheckpointID) + case stepCount > 0: + fmt.Fprintln(cmd.OutOrStdout(), " Work will be captured in your next checkpoint.") + default: + fmt.Fprintln(cmd.OutOrStdout(), " No work recorded.") + } + return nil +} diff --git a/cmd/entire/cli/stop_test.go b/cmd/entire/cli/stop_test.go new file mode 100644 index 000000000..e9e8c9d6d --- /dev/null +++ b/cmd/entire/cli/stop_test.go @@ -0,0 +1,608 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +// setupStopTestRepo initializes a temporary git repo, changes to it, and clears +// path/session caches. Must NOT be used with t.Parallel() because it calls t.Chdir. +func setupStopTestRepo(t *testing.T) { + t.Helper() + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "f.txt", "init") + testutil.GitAdd(t, tmpDir, "f.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + session.ClearGitCommonDirCache() +} + +// makeSessionState returns a minimal SessionState suitable for test setup. +func makeSessionState(id string, phase session.Phase) *strategy.SessionState { + return &strategy.SessionState{ + SessionID: id, + BaseCommit: "abc123", + StartedAt: time.Now(), + Phase: phase, + } +} + +func TestStopCmd_NoActiveSessions(t *testing.T) { + setupStopTestRepo(t) + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{}) + + err := cmd.ExecuteContext(context.Background()) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if !strings.Contains(stdout.String(), "No active sessions.") { + t.Errorf("expected 'No active sessions.' in output, got: %q", stdout.String()) + } +} + +// TestStopCmd_SingleSession_EmptyWorktreePath_Force verifies that a session with an +// empty WorktreePath (legacy session without worktree tracking) is included in the +// current worktree's scope and stopped via the no-flags path. +func TestStopCmd_SingleSession_EmptyWorktreePath_Force(t *testing.T) { + setupStopTestRepo(t) + + // WorktreePath intentionally left empty — exercises the s.WorktreePath == "" fallback. + state := makeSessionState("test-stop-single-1", session.PhaseIdle) + state.StepCount = 0 + if err := strategy.SaveSessionState(context.Background(), state); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--force"}) + + err := cmd.ExecuteContext(context.Background()) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "No work recorded.") { + t.Errorf("expected 'No work recorded.' in output, got: %q", out) + } + + loaded, err := strategy.LoadSessionState(context.Background(), "test-stop-single-1") + if err != nil { + t.Fatalf("LoadSessionState() error = %v", err) + } + if loaded == nil { + t.Fatal("expected session state to still exist after stop") + } + if loaded.Phase != session.PhaseEnded { + t.Errorf("expected Phase=PhaseEnded, got: %v", loaded.Phase) + } +} + +func TestStopCmd_SingleSession_WithCheckpoint(t *testing.T) { + setupStopTestRepo(t) + + state := makeSessionState("test-stop-checkpoint-1", session.PhaseIdle) + state.LastCheckpointID = "a3b2c4d5e6f7" + if err := strategy.SaveSessionState(context.Background(), state); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--session", "test-stop-checkpoint-1", "--force"}) + + err := cmd.ExecuteContext(context.Background()) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "Checkpoint: a3b2c4d5e6f7") { + t.Errorf("expected 'Checkpoint: a3b2c4d5e6f7' in output, got: %q", out) + } +} + +func TestStopCmd_SingleSession_UncommittedWork(t *testing.T) { + setupStopTestRepo(t) + + state := makeSessionState("test-stop-uncommitted-1", session.PhaseIdle) + state.StepCount = 2 + if err := strategy.SaveSessionState(context.Background(), state); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--session", "test-stop-uncommitted-1", "--force"}) + + err := cmd.ExecuteContext(context.Background()) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "Work will be captured in your next checkpoint.") { + t.Errorf("expected 'Work will be captured in your next checkpoint.' in output, got: %q", out) + } +} + +func TestStopCmd_AlreadyStopped(t *testing.T) { + setupStopTestRepo(t) + + state := makeSessionState("test-stop-already-ended-1", session.PhaseEnded) + now := time.Now() + state.EndedAt = &now + if err := strategy.SaveSessionState(context.Background(), state); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--session", "test-stop-already-ended-1", "--force"}) + + err := cmd.ExecuteContext(context.Background()) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "Session test-stop-already-ended-1 is already stopped.") { + t.Errorf("expected 'is already stopped.' in output, got: %q", out) + } + + // State should be unchanged (still ended) + loaded, err := strategy.LoadSessionState(context.Background(), "test-stop-already-ended-1") + if err != nil { + t.Fatalf("LoadSessionState() error = %v", err) + } + if loaded == nil { + t.Fatal("expected session state to still exist") + } + if loaded.Phase != session.PhaseEnded { + t.Errorf("expected Phase=PhaseEnded unchanged, got: %v", loaded.Phase) + } +} + +func TestStopCmd_SessionFlag(t *testing.T) { + setupStopTestRepo(t) + + state1 := makeSessionState("test-stop-target-session", session.PhaseIdle) + state2 := makeSessionState("test-stop-other-session", session.PhaseIdle) + for _, s := range []*strategy.SessionState{state1, state2} { + if err := strategy.SaveSessionState(context.Background(), s); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--session", "test-stop-target-session", "--force"}) + + err := cmd.ExecuteContext(context.Background()) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + target, err := strategy.LoadSessionState(context.Background(), "test-stop-target-session") + if err != nil { + t.Fatalf("LoadSessionState(target) error = %v", err) + } + if target == nil { + t.Fatal("expected target session state to exist") + } + if target.Phase != session.PhaseEnded { + t.Errorf("expected target Phase=PhaseEnded, got: %v", target.Phase) + } + + other, err := strategy.LoadSessionState(context.Background(), "test-stop-other-session") + if err != nil { + t.Fatalf("LoadSessionState(other) error = %v", err) + } + if other == nil { + t.Fatal("expected other session state to exist") + } + if other.Phase == session.PhaseEnded { + t.Errorf("expected other session to remain non-ended, got: %v", other.Phase) + } +} + +func TestStopCmd_AllFlag(t *testing.T) { + setupStopTestRepo(t) + + // Resolve the worktree root as the command sees it (handles macOS symlinks like /var -> /private/var). + ctx := context.Background() + worktreePath, wtErr := paths.WorktreeRoot(ctx) + if wtErr != nil { + t.Fatalf("WorktreeRoot() error = %v", wtErr) + } + + state1 := makeSessionState("test-stop-all-sess-1", session.PhaseIdle) + state1.WorktreePath = worktreePath + state2 := makeSessionState("test-stop-all-sess-2", session.PhaseIdle) + state2.WorktreePath = worktreePath + for _, s := range []*strategy.SessionState{state1, state2} { + if err := strategy.SaveSessionState(ctx, s); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--all", "--force"}) + + if err := cmd.ExecuteContext(ctx); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + out := stdout.String() + for _, id := range []string{"test-stop-all-sess-1", "test-stop-all-sess-2"} { + if !strings.Contains(out, id) { + t.Errorf("expected session ID %q in output, got: %q", id, out) + } + + loaded, err := strategy.LoadSessionState(context.Background(), id) + if err != nil { + t.Fatalf("LoadSessionState(%s) error = %v", id, err) + } + if loaded == nil { + t.Fatalf("expected session %s to exist after stop", id) + } + if loaded.Phase != session.PhaseEnded { + t.Errorf("expected session %s Phase=PhaseEnded, got: %v", id, loaded.Phase) + } + } +} + +func TestStopCmd_AllFlag_ExcludesOtherWorktrees(t *testing.T) { + setupStopTestRepo(t) + + ctx := context.Background() + worktreePath, wtErr := paths.WorktreeRoot(ctx) + if wtErr != nil { + t.Fatalf("WorktreeRoot() error = %v", wtErr) + } + + inScope := makeSessionState("test-all-scope-in", session.PhaseIdle) + inScope.WorktreePath = worktreePath + + outOfScope := makeSessionState("test-all-scope-out", session.PhaseIdle) + outOfScope.WorktreePath = "/other/worktree" + + for _, s := range []*strategy.SessionState{inScope, outOfScope} { + if err := strategy.SaveSessionState(ctx, s); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--all", "--force"}) + + if err := cmd.ExecuteContext(ctx); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + stopped, err := strategy.LoadSessionState(ctx, "test-all-scope-in") + if err != nil { + t.Fatalf("LoadSessionState(in-scope) error = %v", err) + } + if stopped == nil || stopped.Phase != session.PhaseEnded { + t.Errorf("expected in-scope session to be PhaseEnded, got: %v", stopped.Phase) + } + + untouched, err := strategy.LoadSessionState(ctx, "test-all-scope-out") + if err != nil { + t.Fatalf("LoadSessionState(out-of-scope) error = %v", err) + } + if untouched == nil || untouched.Phase == session.PhaseEnded { + t.Errorf("expected out-of-scope session to remain non-ended, got: %v", untouched.Phase) + } +} + +func TestStopCmd_AllFlag_NoActiveSessions(t *testing.T) { + setupStopTestRepo(t) + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--all", "--force"}) + + if err := cmd.ExecuteContext(context.Background()); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if !strings.Contains(stdout.String(), "No active sessions.") { + t.Errorf("expected 'No active sessions.' in output, got: %q", stdout.String()) + } +} + +func TestStopCmd_AllAndSessionMutuallyExclusive(t *testing.T) { + setupStopTestRepo(t) + + state := makeSessionState("test-stop-mutex-sess", session.PhaseIdle) + if err := strategy.SaveSessionState(context.Background(), state); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--all", "--session", "test-stop-mutex-sess", "--force"}) + + err := cmd.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected error for --all and --session together, got nil") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("expected error to mention 'mutually exclusive', got: %v", err) + } + + // State should be unchanged + loaded, err2 := strategy.LoadSessionState(context.Background(), "test-stop-mutex-sess") + if err2 != nil { + t.Fatalf("LoadSessionState() error = %v", err2) + } + if loaded == nil { + t.Fatal("expected session state to still exist") + } + if loaded.Phase == session.PhaseEnded { + t.Error("expected session to remain non-ended after mutual exclusion error") + } +} + +func TestStopCmd_SessionNotFound(t *testing.T) { + setupStopTestRepo(t) + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--session", "doesnotexist", "--force"}) + + err := cmd.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected error for unknown session, got nil") + } + + var silentErr *SilentError + if !errors.As(err, &silentErr) { + t.Errorf("expected SilentError, got: %T %v", err, err) + } + + if !strings.Contains(stderr.String(), "Session not found.") { + t.Errorf("expected 'Session not found.' in stderr, got: %q", stderr.String()) + } +} + +func TestStopCmd_NotGitRepo(t *testing.T) { + // Use a plain temp dir with no git init + tmpDir := t.TempDir() + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + session.ClearGitCommonDirCache() + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{}) + + err := cmd.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected error for non-git directory, got nil") + } + + var silentErr *SilentError + if !errors.As(err, &silentErr) { + t.Errorf("expected SilentError, got: %T %v", err, err) + } + + if !strings.Contains(stderr.String(), "Not a git repository.") { + t.Errorf("expected 'Not a git repository.' in stderr, got: %q", stderr.String()) + } +} + +// TestStopSelectedSessions_StopsAll exercises stopSelectedSessions directly, +// bypassing the TUI multi-select. Verifies all sessions in the list are ended +// and that success lines are printed for each. +func TestStopSelectedSessions_StopsAll(t *testing.T) { + setupStopTestRepo(t) + + ctx := context.Background() + s1 := makeSessionState("test-batch-stop-1", session.PhaseIdle) + s2 := makeSessionState("test-batch-stop-2", session.PhaseIdle) + for _, s := range []*strategy.SessionState{s1, s2} { + if err := strategy.SaveSessionState(ctx, s); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + + if err := stopSelectedSessions(ctx, cmd, []*strategy.SessionState{s1, s2}); err != nil { + t.Fatalf("stopSelectedSessions() error = %v", err) + } + + out := stdout.String() + for _, id := range []string{"test-batch-stop-1", "test-batch-stop-2"} { + if !strings.Contains(out, id) { + t.Errorf("expected session ID %q in output, got: %q", id, out) + } + + loaded, err := strategy.LoadSessionState(ctx, id) + if err != nil { + t.Fatalf("LoadSessionState(%s) error = %v", id, err) + } + if loaded == nil || loaded.Phase != session.PhaseEnded { + t.Errorf("expected session %s to be PhaseEnded after batch stop", id) + } + } +} + +// TestStopCmd_AlreadyStopped_EndedAtOnly verifies that a session with EndedAt set +// is treated as already stopped even when Phase has not been updated to PhaseEnded +// (legacy sessions where the phase field may have defaulted to Idle). +func TestStopCmd_AlreadyStopped_EndedAtOnly(t *testing.T) { + setupStopTestRepo(t) + + // Simulate a legacy session: EndedAt is set but Phase is still PhaseIdle. + state := makeSessionState("test-stop-ended-at-only", session.PhaseIdle) + now := time.Now() + state.EndedAt = &now + if err := strategy.SaveSessionState(context.Background(), state); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--session", "test-stop-ended-at-only", "--force"}) + + err := cmd.ExecuteContext(context.Background()) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "is already stopped.") { + t.Errorf("expected 'is already stopped.' in output, got: %q", out) + } + + // Phase should remain unchanged — we must not overwrite legacy state. + loaded, err := strategy.LoadSessionState(context.Background(), "test-stop-ended-at-only") + if err != nil { + t.Fatalf("LoadSessionState() error = %v", err) + } + if loaded == nil { + t.Fatal("expected session state to still exist") + } + if loaded.Phase != session.PhaseIdle { + t.Errorf("expected Phase to remain PhaseIdle (legacy), got: %v", loaded.Phase) + } +} + +// TestFilterActiveSessions_ExcludesEndedAtSet verifies that filterActiveSessions +// excludes sessions with EndedAt set regardless of Phase, and includes sessions in +// both PhaseIdle and PhaseActive. +func TestFilterActiveSessions_ExcludesEndedAtSet(t *testing.T) { + t.Parallel() + + now := time.Now() + + legacyEnded := makeSessionState("legacy-ended", session.PhaseIdle) + legacyEnded.EndedAt = &now + + properEnded := makeSessionState("proper-ended", session.PhaseEnded) + properEnded.EndedAt = &now + + activeIdle := makeSessionState("active-idle", session.PhaseIdle) + activeWorking := makeSessionState("active-working", session.PhaseActive) + + result := filterActiveSessions([]*strategy.SessionState{legacyEnded, properEnded, activeIdle, activeWorking}) + + if len(result) != 2 { + t.Fatalf("expected 2 active sessions, got %d", len(result)) + } + ids := map[string]bool{result[0].SessionID: true, result[1].SessionID: true} + if !ids["active-idle"] || !ids["active-working"] { + t.Errorf("expected active-idle and active-working in result, got: %v", result) + } +} + +// TestStopCmd_WorktreeScoping_NoFlags verifies that the no-flags path scopes session +// listing to the current worktree, so sessions from other worktrees are invisible. +func TestStopCmd_WorktreeScoping_NoFlags(t *testing.T) { + setupStopTestRepo(t) + + ctx := context.Background() + worktreePath, err := paths.WorktreeRoot(ctx) + if err != nil { + t.Fatalf("WorktreeRoot() error = %v", err) + } + + // One session in the current worktree, one in a foreign worktree. + inScope := makeSessionState("test-stop-scope-in", session.PhaseIdle) + inScope.WorktreePath = worktreePath + + outOfScope := makeSessionState("test-stop-scope-out", session.PhaseIdle) + outOfScope.WorktreePath = "/some/other/worktree" + + for _, s := range []*strategy.SessionState{inScope, outOfScope} { + if err := strategy.SaveSessionState(ctx, s); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + // Single in-scope session → confirm + stop path (bypasses TUI selector). + cmd.SetArgs([]string{"--force"}) + + if err := cmd.ExecuteContext(ctx); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // In-scope session must be stopped. + stopped, err := strategy.LoadSessionState(ctx, "test-stop-scope-in") + if err != nil { + t.Fatalf("LoadSessionState(in-scope) error = %v", err) + } + if stopped == nil { + t.Fatal("expected in-scope session to exist") + } + if stopped.Phase != session.PhaseEnded { + t.Errorf("expected in-scope session Phase=PhaseEnded, got: %v", stopped.Phase) + } + + // Out-of-scope session must be untouched. + untouched, err := strategy.LoadSessionState(ctx, "test-stop-scope-out") + if err != nil { + t.Fatalf("LoadSessionState(out-of-scope) error = %v", err) + } + if untouched == nil { + t.Fatal("expected out-of-scope session to exist") + } + if untouched.Phase == session.PhaseEnded { + t.Errorf("expected out-of-scope session to remain non-ended, got PhaseEnded") + } +} diff --git a/cmd/entire/cli/utils.go b/cmd/entire/cli/utils.go index fbde35738..d27334988 100644 --- a/cmd/entire/cli/utils.go +++ b/cmd/entire/cli/utils.go @@ -40,14 +40,14 @@ func NewAccessibleForm(groups ...*huh.Group) *huh.Form { } // handleFormCancellation handles cancellation from huh form prompts. -// If err is a user abort (Ctrl+C), it prints "[action] cancelled." to w and returns nil. -// Otherwise it returns the error as-is. +// User abort (Ctrl+C) and timeout both print a cancelled message and return nil. +// Other errors are wrapped with the action name for context. func handleFormCancellation(w io.Writer, action string, err error) error { - if errors.Is(err, huh.ErrUserAborted) { + if errors.Is(err, huh.ErrUserAborted) || errors.Is(err, huh.ErrTimeout) { fmt.Fprintf(w, "%s cancelled.\n", action) return nil } - return err + return fmt.Errorf("%s prompt failed: %w", action, err) } // printSessionCommand writes a single session resume command line to w. diff --git a/cmd/entire/cli/utils_test.go b/cmd/entire/cli/utils_test.go index 147775320..c15b2576e 100644 --- a/cmd/entire/cli/utils_test.go +++ b/cmd/entire/cli/utils_test.go @@ -37,7 +37,14 @@ func TestHandleFormCancellation(t *testing.T) { wantErr: false, }, { - name: "non-abort error is returned as-is", + name: "timeout prints cancelled and returns nil", + action: "Stop", + err: huh.ErrTimeout, + wantOut: "Stop cancelled.\n", + wantErr: false, + }, + { + name: "unexpected error is wrapped with action name", action: "Reset", err: errors.New("form exploded"), wantOut: "",