diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1740cab..b6d806a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,9 @@ concurrency: cancel-in-progress: true jobs: - test: + # Code Quality Checks (formatting, linting, etc.) + quality: + name: Code Quality runs-on: ubuntu-latest steps: @@ -36,25 +38,91 @@ jobs: - name: Check formatting run: | if [ "$(gofmt -l .)" != "" ]; then - echo "Code is not formatted. Run 'gofmt -w .' to fix." + echo "❌ Code is not formatted. Run 'go fmt ./...' to fix." + echo "Files that need formatting:" gofmt -l . exit 1 fi + echo "βœ… Code formatting is correct" - name: Check go mod tidy run: | go mod tidy if ! git diff --quiet go.mod go.sum; then - echo "go.mod or go.sum is not tidy. Run 'go mod tidy' to fix." + echo "❌ go.mod or go.sum is not tidy. Run 'go mod tidy' to fix." git diff go.mod go.sum exit 1 fi + echo "βœ… go.mod and go.sum are tidy" + + - name: Run linting with go vet + run: | + echo "πŸ” Running go vet..." + go vet ./... + echo "βœ… Linting passed" + + # Build Check + build: + name: Build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.23" + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download - name: Build - run: go build -v ./... + run: | + echo "πŸ”¨ Building..." + go build -v ./... + echo "βœ… Build successful" - - name: Run linting with go vet - run: go vet ./... + # Tests + test: + name: Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.23" + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download - name: Run tests with coverage - run: go test -v -coverprofile=coverage.out ./... + run: | + echo "πŸ§ͺ Running tests..." + go test -v -coverprofile=coverage.out ./... + echo "βœ… All tests passed" + + - name: Upload coverage reports + uses: codecov/codecov-action@v3 + with: + file: ./coverage.out + fail_ci_if_error: false diff --git a/coverage.html b/coverage.html new file mode 100644 index 0000000..e7e0d0f --- /dev/null +++ b/coverage.html @@ -0,0 +1,8109 @@ + + + + + + cli: Go Coverage Report + + + +
+ +
+ not tracked + + not covered + covered + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/cwt b/cwt new file mode 100755 index 0000000..f0ccbf6 Binary files /dev/null and b/cwt differ diff --git a/internal/cli/attach.go b/internal/cli/attach.go index 0747849..b63d8be 100644 --- a/internal/cli/attach.go +++ b/internal/cli/attach.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" + "github.com/jlaneve/cwt-cli/internal/operations" "github.com/jlaneve/cwt-cli/internal/state" "github.com/jlaneve/cwt-cli/internal/types" ) @@ -165,46 +166,14 @@ func promptForAttachSelection(sessions []types.Session) (*types.Session, error) // recreateSessionWithClaudeResume recreates a dead tmux session and resumes Claude if possible func recreateSessionWithClaudeResume(sm *state.Manager, session *types.Session) error { - // Find Claude executable - claudeExec := findClaudeExecutable() - if claudeExec == "" { - return fmt.Errorf("claude executable not found") - } + sessionOps := operations.NewSessionOperations(sm) - // Check if there's an existing Claude session to resume for this worktree - var command string - if existingSessionID, err := sm.GetClaudeChecker().FindSessionID(session.Core.WorktreePath); err == nil && existingSessionID != "" { - command = fmt.Sprintf("%s -r %s", claudeExec, existingSessionID) - fmt.Printf("πŸ“‹ Resuming Claude session %s\n", existingSessionID) - } else { - command = claudeExec - fmt.Printf("πŸ†• Starting new Claude session\n") - } + // Use operations layer for recreation logic + fmt.Printf("πŸ“‹ Recreating session with Claude resume...\n") - // Recreate the tmux session - tmuxChecker := sm.GetTmuxChecker() - if err := tmuxChecker.CreateSession(session.Core.TmuxSession, session.Core.WorktreePath, command); err != nil { - return fmt.Errorf("failed to recreate tmux session: %w", err) + if err := sessionOps.RecreateDeadSession(session); err != nil { + return fmt.Errorf("failed to recreate session: %w", err) } return nil } - -// findClaudeExecutable searches for claude in common installation paths -func findClaudeExecutable() string { - claudePaths := []string{ - "claude", - os.ExpandEnv("$HOME/.claude/local/claude"), - os.ExpandEnv("$HOME/.claude/local/node_modules/.bin/claude"), - "/usr/local/bin/claude", - } - - for _, path := range claudePaths { - cmd := exec.Command(path, "--version") - if err := cmd.Run(); err == nil { - return path - } - } - - return "" -} diff --git a/internal/cli/cleanup.go b/internal/cli/cleanup.go index deabfef..8f9e310 100644 --- a/internal/cli/cleanup.go +++ b/internal/cli/cleanup.go @@ -2,12 +2,8 @@ package cli import ( "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "github.com/jlaneve/cwt-cli/internal/state" + "github.com/jlaneve/cwt-cli/internal/operations" "github.com/spf13/cobra" ) @@ -42,242 +38,47 @@ func runCleanupCmd(dryRun bool) error { fmt.Println("πŸ” Scanning for orphaned resources...") - cleanupStats := struct { - staleSessions int - orphanedTmux int - orphanedWorktrees int - cleaned int - failed int - }{} - - // 1. Find stale sessions (sessions with dead tmux sessions) - staleSessions, err := sm.FindStaleSessions() + // Use operations layer for cleanup + cleanupOps := operations.NewCleanupOperations(sm) + stats, err := cleanupOps.FindAndCleanupStaleResources(dryRun) if err != nil { - return fmt.Errorf("failed to find stale sessions: %w", err) - } - cleanupStats.staleSessions = len(staleSessions) - - // 2. Find orphaned tmux sessions (cwt-* sessions not in our metadata) - orphanedTmux, err := findOrphanedTmuxSessions(sm) - if err != nil { - fmt.Printf("Warning: failed to scan tmux sessions: %v\n", err) - } else { - cleanupStats.orphanedTmux = len(orphanedTmux) - } - - // 3. Find orphaned worktrees (.cwt/worktrees/* not in our metadata) - orphanedWorktrees, err := findOrphanedWorktrees(sm) - if err != nil { - fmt.Printf("Warning: failed to scan worktrees: %v\n", err) - } else { - cleanupStats.orphanedWorktrees = len(orphanedWorktrees) + return fmt.Errorf("cleanup failed: %w", err) } // Show what was found - totalOrphans := cleanupStats.staleSessions + cleanupStats.orphanedTmux + cleanupStats.orphanedWorktrees + totalOrphans := stats.StaleSessions + stats.OrphanedTmux + stats.OrphanedWorktrees if totalOrphans == 0 { fmt.Println("βœ… No orphaned resources found. Everything looks clean!") return nil } fmt.Printf("\nFound orphaned resources:\n") - if cleanupStats.staleSessions > 0 { - fmt.Printf(" πŸ“‚ %d stale session(s) with dead tmux\n", cleanupStats.staleSessions) + if stats.StaleSessions > 0 { + fmt.Printf(" πŸ“‚ %d stale session(s) with dead tmux\n", stats.StaleSessions) } - if cleanupStats.orphanedTmux > 0 { - fmt.Printf(" πŸ”§ %d orphaned tmux session(s)\n", cleanupStats.orphanedTmux) + if stats.OrphanedTmux > 0 { + fmt.Printf(" πŸ”§ %d orphaned tmux session(s)\n", stats.OrphanedTmux) } - if cleanupStats.orphanedWorktrees > 0 { - fmt.Printf(" 🌳 %d orphaned git worktree(s)\n", cleanupStats.orphanedWorktrees) + if stats.OrphanedWorktrees > 0 { + fmt.Printf(" 🌳 %d orphaned git worktree(s)\n", stats.OrphanedWorktrees) } fmt.Println() - // Show details - if cleanupStats.staleSessions > 0 { - fmt.Printf("Stale sessions:\n") - for _, session := range staleSessions { - fmt.Printf(" πŸ—‘οΈ %s (tmux: %s, worktree: %s)\n", - session.Core.Name, session.Core.TmuxSession, session.Core.WorktreePath) - } - fmt.Println() - } - - if cleanupStats.orphanedTmux > 0 { - fmt.Printf("Orphaned tmux sessions:\n") - for _, tmuxSession := range orphanedTmux { - fmt.Printf(" πŸ”§ %s\n", tmuxSession) - } - fmt.Println() - } - - if cleanupStats.orphanedWorktrees > 0 { - fmt.Printf("Orphaned worktrees:\n") - for _, worktree := range orphanedWorktrees { - fmt.Printf(" 🌳 %s\n", worktree) - } - fmt.Println() - } - if dryRun { fmt.Println("πŸ” Dry run mode - no changes made.") fmt.Printf("Run 'cwt cleanup' to actually clean up these %d resource(s).\n", totalOrphans) return nil } - // Clean up stale sessions - if cleanupStats.staleSessions > 0 { - fmt.Printf("Cleaning up %d stale session(s)...\n", cleanupStats.staleSessions) - for _, session := range staleSessions { - fmt.Printf(" Cleaning session '%s'...\n", session.Core.Name) - - if err := sm.DeleteSession(session.Core.ID); err != nil { - fmt.Printf(" ❌ Failed: %v\n", err) - cleanupStats.failed++ - } else { - fmt.Printf(" βœ… Cleaned\n") - cleanupStats.cleaned++ - } - } - fmt.Println() - } - - // Clean up orphaned tmux sessions - if cleanupStats.orphanedTmux > 0 { - fmt.Printf("Cleaning up %d orphaned tmux session(s)...\n", cleanupStats.orphanedTmux) - tmuxChecker := sm.GetTmuxChecker() - for _, tmuxSession := range orphanedTmux { - fmt.Printf(" Killing tmux session '%s'...\n", tmuxSession) - - if err := tmuxChecker.KillSession(tmuxSession); err != nil { - fmt.Printf(" ❌ Failed: %v\n", err) - cleanupStats.failed++ - } else { - fmt.Printf(" βœ… Killed\n") - cleanupStats.cleaned++ - } + // Show cleanup results + fmt.Printf("🧹 Cleanup complete!\n") + fmt.Printf(" βœ… Cleaned: %d\n", stats.Cleaned) + if stats.Failed > 0 { + fmt.Printf(" ❌ Failed: %d\n", stats.Failed) + for _, errMsg := range stats.Errors { + fmt.Printf(" - %s\n", errMsg) } - fmt.Println() - } - - // Clean up orphaned worktrees - if cleanupStats.orphanedWorktrees > 0 { - fmt.Printf("Cleaning up %d orphaned worktree(s)...\n", cleanupStats.orphanedWorktrees) - for _, worktree := range orphanedWorktrees { - fmt.Printf(" Removing worktree '%s'...\n", worktree) - - if err := removeWorktreeWithFallback(worktree); err != nil { - fmt.Printf(" ❌ Failed: %v\n", err) - cleanupStats.failed++ - } else { - fmt.Printf(" βœ… Removed\n") - cleanupStats.cleaned++ - } - } - fmt.Println() - } - - fmt.Printf("🧹 Cleanup complete: %d cleaned, %d failed\n", cleanupStats.cleaned, cleanupStats.failed) - - if cleanupStats.failed > 0 { - return fmt.Errorf("some resources could not be cleaned up") - } - - return nil -} - -// findOrphanedTmuxSessions finds tmux sessions with "cwt-" prefix that aren't tracked by CWT -func findOrphanedTmuxSessions(sm *state.Manager) ([]string, error) { - // Get all tmux sessions - tmuxChecker := sm.GetTmuxChecker() - allSessions, err := tmuxChecker.ListSessions() - if err != nil { - return nil, fmt.Errorf("failed to list tmux sessions: %w", err) } - // Get all sessions tracked by CWT - cwtSessions, err := sm.DeriveFreshSessions() - if err != nil { - return nil, fmt.Errorf("failed to get CWT sessions: %w", err) - } - - // Build map of tracked tmux sessions - trackedTmux := make(map[string]bool) - for _, session := range cwtSessions { - trackedTmux[session.Core.TmuxSession] = true - } - - // Find orphaned tmux sessions - var orphaned []string - for _, tmuxSession := range allSessions { - if strings.HasPrefix(tmuxSession, "cwt-") && !trackedTmux[tmuxSession] { - orphaned = append(orphaned, tmuxSession) - } - } - - return orphaned, nil -} - -// findOrphanedWorktrees finds git worktrees in .cwt/worktrees/ that aren't tracked by CWT -func findOrphanedWorktrees(sm *state.Manager) ([]string, error) { - // Get all git worktrees - worktreesDir := filepath.Join(sm.GetDataDir(), "worktrees") - if _, err := os.Stat(worktreesDir); os.IsNotExist(err) { - return []string{}, nil // No worktrees directory - } - - entries, err := os.ReadDir(worktreesDir) - if err != nil { - return nil, fmt.Errorf("failed to read worktrees directory: %w", err) - } - - // Get all sessions tracked by CWT - cwtSessions, err := sm.DeriveFreshSessions() - if err != nil { - return nil, fmt.Errorf("failed to get CWT sessions: %w", err) - } - - // Build map of tracked worktree paths - trackedWorktrees := make(map[string]bool) - for _, session := range cwtSessions { - // Normalize path for comparison - absPath, _ := filepath.Abs(session.Core.WorktreePath) - trackedWorktrees[absPath] = true - } - - // Find orphaned worktrees - var orphaned []string - for _, entry := range entries { - if !entry.IsDir() { - continue - } - - worktreePath := filepath.Join(worktreesDir, entry.Name()) - absPath, _ := filepath.Abs(worktreePath) - - if !trackedWorktrees[absPath] { - orphaned = append(orphaned, worktreePath) - } - } - - return orphaned, nil -} - -// removeWorktreeWithFallback tries to remove a worktree using git, falling back to directory removal -func removeWorktreeWithFallback(worktreePath string) error { - // First try to remove with git worktree command - cmd := exec.Command("git", "worktree", "remove", worktreePath, "--force") - if err := cmd.Run(); err == nil { - return nil // Successfully removed with git - } - - // Fallback to manual directory removal - if err := os.RemoveAll(worktreePath); err != nil { - return fmt.Errorf("failed to remove worktree directory: %w", err) - } - - // Try to clean up git references - cmd = exec.Command("git", "worktree", "prune") - cmd.Run() // Ignore errors - this is just cleanup - return nil } diff --git a/internal/cli/delete.go b/internal/cli/delete.go index d361c56..25d623a 100644 --- a/internal/cli/delete.go +++ b/internal/cli/delete.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" + "github.com/jlaneve/cwt-cli/internal/operations" "github.com/jlaneve/cwt-cli/internal/types" ) @@ -42,8 +43,11 @@ func runDeleteCmd(args []string, force bool) error { } defer sm.Close() + // Create operations layer + sessionOps := operations.NewSessionOperations(sm) + // Get sessions - sessions, err := sm.DeriveFreshSessions() + sessions, err := sessionOps.GetAllSessions() if err != nil { return fmt.Errorf("failed to load sessions: %w", err) } @@ -58,19 +62,14 @@ func runDeleteCmd(args []string, force bool) error { var sessionID string if len(args) > 0 { - // Session name provided + // Session name provided - use operations layer sessionName := args[0] - for _, session := range sessions { - if session.Core.Name == sessionName { - sessionToDelete = &sessionName - sessionID = session.Core.ID - break - } - } - - if sessionToDelete == nil { - return fmt.Errorf("session '%s' not found", sessionName) + session, id, err := sessionOps.FindSessionByName(sessionName) + if err != nil { + return err } + sessionToDelete = &session.Core.Name + sessionID = id } else { // Interactive selection sessionName, id, err := promptForSessionSelection(sessions) @@ -89,10 +88,10 @@ func runDeleteCmd(args []string, force bool) error { } } - // Delete session + // Delete session using operations layer fmt.Printf("Deleting session '%s'...\n", *sessionToDelete) - if err := sm.DeleteSession(sessionID); err != nil { + if err := sessionOps.DeleteSession(sessionID); err != nil { return fmt.Errorf("failed to delete session: %w", err) } diff --git a/internal/cli/list.go b/internal/cli/list.go index 53bc181..c8002ea 100644 --- a/internal/cli/list.go +++ b/internal/cli/list.go @@ -9,6 +9,7 @@ import ( "github.com/mattn/go-runewidth" "github.com/spf13/cobra" + "github.com/jlaneve/cwt-cli/internal/operations" "github.com/jlaneve/cwt-cli/internal/types" ) @@ -42,12 +43,15 @@ func runListCmd(verbose bool) error { } defer sm.Close() - // Derive fresh sessions - sessions, err := sm.DeriveFreshSessions() + // Use operations layer for session retrieval and formatting + sessionOps := operations.NewSessionOperations(sm) + sessions, err := sessionOps.GetAllSessions() if err != nil { return fmt.Errorf("failed to load sessions: %w", err) } + formatter := operations.NewStatusFormat() + if len(sessions) == 0 { fmt.Println("No sessions found.") fmt.Println("\nCreate a new session with: cwt new [session-name] [task-description]") @@ -60,15 +64,15 @@ func runListCmd(verbose bool) error { }) if verbose { - renderVerboseSessionList(sessions) + renderVerboseSessionList(sessions, formatter) } else { - renderCompactSessionList(sessions) + renderCompactSessionList(sessions, formatter) } return nil } -func renderCompactSessionList(sessions []types.Session) { +func renderCompactSessionList(sessions []types.Session, formatter *operations.StatusFormat) { fmt.Printf("Found %d session(s):\n\n", len(sessions)) // Calculate max widths for each column based on content @@ -91,10 +95,10 @@ func renderCompactSessionList(sessions []types.Session) { for i, session := range sessions { rows[i] = rowData{ name: truncate(session.Core.Name, 30), - tmux: formatTmuxStatus(session.IsAlive), - claude: formatClaudeStatus(session.ClaudeStatus), - git: formatGitStatus(session.GitStatus), - activity: FormatActivity(session.LastActivity), + tmux: formatter.FormatTmuxStatus(session.IsAlive), + claude: formatter.FormatClaudeStatus(session.ClaudeStatus), + git: formatter.FormatGitStatus(session.GitStatus), + activity: formatter.FormatActivity(session.LastActivity), } // Update max lengths (using visual length) @@ -148,7 +152,7 @@ func renderCompactSessionList(sessions []types.Session) { } } -func renderVerboseSessionList(sessions []types.Session) { +func renderVerboseSessionList(sessions []types.Session, formatter *operations.StatusFormat) { fmt.Printf("Found %d session(s):\n\n", len(sessions)) for i, session := range sessions { @@ -164,7 +168,7 @@ func renderVerboseSessionList(sessions []types.Session) { // Tmux status fmt.Printf(" πŸ–₯️ Tmux: %s (session: %s)\n", - formatTmuxStatus(session.IsAlive), session.Core.TmuxSession) + formatter.FormatTmuxStatus(session.IsAlive), session.Core.TmuxSession) // Git status gitDetails := "" @@ -181,7 +185,7 @@ func renderVerboseSessionList(sessions []types.Session) { } gitDetails = fmt.Sprintf(" (%s)", strings.Join(changes, ", ")) } - fmt.Printf(" πŸ“ Git: %s%s\n", formatGitStatus(session.GitStatus), gitDetails) + fmt.Printf(" πŸ“ Git: %s%s\n", formatter.FormatGitStatus(session.GitStatus), gitDetails) // Claude status claudeDetails := "" @@ -190,9 +194,9 @@ func renderVerboseSessionList(sessions []types.Session) { } if !session.ClaudeStatus.LastMessage.IsZero() { age := time.Since(session.ClaudeStatus.LastMessage) - claudeDetails += fmt.Sprintf(" (last: %s ago)", FormatDuration(age)) + claudeDetails += fmt.Sprintf(" (last: %s ago)", formatter.FormatDuration(age)) } - fmt.Printf(" πŸ€– Claude: %s%s\n", formatClaudeStatusShort(session.ClaudeStatus), claudeDetails) + fmt.Printf(" πŸ€– Claude: %s%s\n", formatter.FormatClaudeStatus(session.ClaudeStatus), claudeDetails) // Show full message in verbose mode if available if session.ClaudeStatus.StatusMessage != "" { @@ -200,95 +204,8 @@ func renderVerboseSessionList(sessions []types.Session) { } // Last activity - fmt.Printf(" ⏰ Activity: %s\n", FormatActivity(session.LastActivity)) - } -} - -func formatTmuxStatus(isAlive bool) string { - if isAlive { - return "🟒 alive" - } - return "πŸ”΄ dead" -} - -func formatClaudeStatusShort(status types.ClaudeStatus) string { - switch status.State { - case types.ClaudeWorking: - return "πŸ”„ working" - case types.ClaudeWaiting: - return "⏸️ waiting" - case types.ClaudeComplete: - return "βœ… complete" - case types.ClaudeIdle: - return "πŸ’€ idle" - default: - return "❓ unknown" - } -} - -func formatClaudeStatus(status types.ClaudeStatus) string { - switch status.State { - case types.ClaudeWorking: - return "πŸ”„ working" - case types.ClaudeWaiting: - if status.StatusMessage != "" { - // Truncate message for compact view - msg := status.StatusMessage - if len(msg) > 30 { - msg = msg[:27] + "..." - } - return "⏸️ " + msg - } - return "⏸️ waiting" - case types.ClaudeComplete: - return "βœ… complete" - case types.ClaudeIdle: - return "πŸ’€ idle" - default: - return "❓ unknown" - } -} - -func formatGitStatus(status types.GitStatus) string { - if status.HasChanges { - // Calculate total changes - total := len(status.ModifiedFiles) + len(status.AddedFiles) + - len(status.DeletedFiles) + len(status.UntrackedFiles) - - if total == 1 { - return "πŸ“ 1 change" - } - return fmt.Sprintf("πŸ“ %d changes", total) - } - return "✨ clean" -} - -func FormatActivity(lastActivity time.Time) string { - if lastActivity.IsZero() { - return "unknown" - } - - age := time.Since(lastActivity) - if age < time.Minute { - return "just now" - } - return FormatDuration(age) + " ago" -} - -func FormatDuration(d time.Duration) string { - if d < time.Minute { - return "just now" - } - if d < time.Hour { - minutes := int(d.Minutes()) - return fmt.Sprintf("%dm", minutes) - } - if d < 24*time.Hour { - hours := int(d.Hours()) - return fmt.Sprintf("%dh", hours) + fmt.Printf(" ⏰ Activity: %s\n", formatter.FormatActivity(session.LastActivity)) } - days := int(d.Hours() / 24) - return fmt.Sprintf("%dd", days) } func truncate(s string, maxLen int) string { diff --git a/internal/cli/new.go b/internal/cli/new.go index 185d1c4..6b6f26c 100644 --- a/internal/cli/new.go +++ b/internal/cli/new.go @@ -7,6 +7,8 @@ import ( "strings" "github.com/spf13/cobra" + + "github.com/jlaneve/cwt-cli/internal/operations" ) func newNewCmd() *cobra.Command { @@ -44,10 +46,11 @@ func runNewCmd(cmd *cobra.Command, args []string) error { } } - // Create session + // Create session using operations layer fmt.Printf("Creating session '%s'...\n", sessionName) - if err := sm.CreateSession(sessionName); err != nil { + sessionOps := operations.NewSessionOperations(sm) + if err := sessionOps.CreateSession(sessionName); err != nil { return fmt.Errorf("failed to create session: %w", err) } diff --git a/internal/cli/selector.go b/internal/cli/selector.go index 38031d5..3016d41 100644 --- a/internal/cli/selector.go +++ b/internal/cli/selector.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/jlaneve/cwt-cli/internal/operations" "github.com/jlaneve/cwt-cli/internal/types" ) @@ -114,7 +115,8 @@ func selectSessionFallback(sessions []types.Session, title string) (*types.Sessi for i, session := range sessions { status := getSessionStatusIndicator(session) - activity := FormatActivity(session.LastActivity) + formatter := operations.NewStatusFormat() + activity := formatter.FormatActivity(session.LastActivity) fmt.Printf(" %d. %s %s (%s)\n", i+1, session.Core.Name, status, activity) } @@ -198,7 +200,8 @@ func (m *sessionSelectorModel) View() string { // Session info status := getSessionStatusIndicator(session) - activity := FormatActivity(session.LastActivity) + formatter := operations.NewStatusFormat() + activity := formatter.FormatActivity(session.LastActivity) line := fmt.Sprintf("%s%s %s (%s)", prefix, diff --git a/internal/cli/status.go b/internal/cli/status.go index 64c574d..1c5ff48 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" + "github.com/jlaneve/cwt-cli/internal/operations" "github.com/jlaneve/cwt-cli/internal/state" "github.com/jlaneve/cwt-cli/internal/types" ) @@ -78,6 +79,7 @@ func showEnhancedStatus(sm *state.Manager, summary, showBranch bool) error { // showStatusSummary shows a high-level summary of all sessions func showStatusSummary(sessions []types.Session) error { + formatter := operations.NewStatusFormat() fmt.Println("πŸ“Š Session Summary") fmt.Println(strings.Repeat("=", 50)) @@ -134,7 +136,7 @@ func showStatusSummary(sessions []types.Session) error { if i >= 3 { // Show top 3 most recent break } - fmt.Printf(" β€’ %s: %s\n", session.Core.Name, FormatActivity(session.LastActivity)) + fmt.Printf(" β€’ %s: %s\n", session.Core.Name, formatter.FormatActivity(session.LastActivity)) } } @@ -159,6 +161,7 @@ func showDetailedStatus(sessions []types.Session, showBranch bool) error { // renderSessionStatus renders detailed status for a single session func renderSessionStatus(session types.Session, showBranch bool) { + formatter := operations.NewStatusFormat() // Session header fmt.Printf("🏷️ %s", session.Core.Name) @@ -185,7 +188,7 @@ func renderSessionStatus(session types.Session, showBranch bool) { fmt.Printf(" (%s)\n", strings.Join(statusIndicators, ", ")) // Show activity timing - fmt.Printf(" ⏰ Last activity: %s\n", FormatActivity(session.LastActivity)) + fmt.Printf(" ⏰ Last activity: %s\n", formatter.FormatActivity(session.LastActivity)) // Show Claude status claudeIcon := getClaudeIcon(session.ClaudeStatus.State) @@ -197,7 +200,7 @@ func renderSessionStatus(session types.Session, showBranch bool) { if !session.ClaudeStatus.LastMessage.IsZero() { age := time.Since(session.ClaudeStatus.LastMessage) - fmt.Printf(" (last: %s ago)", FormatDuration(age)) + fmt.Printf(" (last: %s ago)", formatter.FormatDuration(age)) } fmt.Println() diff --git a/internal/operations/cleanup.go b/internal/operations/cleanup.go new file mode 100644 index 0000000..56670d1 --- /dev/null +++ b/internal/operations/cleanup.go @@ -0,0 +1,255 @@ +package operations + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/jlaneve/cwt-cli/internal/state" +) + +// CleanupStats tracks the results of a cleanup operation +type CleanupStats struct { + StaleSessions int + OrphanedTmux int + OrphanedWorktrees int + Cleaned int + Failed int + Errors []string +} + +// CleanupOperations provides business logic for cleanup operations +type CleanupOperations struct { + stateManager *state.Manager +} + +// NewCleanupOperations creates a new CleanupOperations instance +func NewCleanupOperations(sm *state.Manager) *CleanupOperations { + return &CleanupOperations{ + stateManager: sm, + } +} + +// FindAndCleanupStaleResources finds and optionally cleans up stale CWT resources +func (c *CleanupOperations) FindAndCleanupStaleResources(dryRun bool) (*CleanupStats, error) { + stats := &CleanupStats{ + Errors: make([]string, 0), + } + + // Find stale sessions + staleSessions, err := c.stateManager.FindStaleSessions() + if err != nil { + return nil, fmt.Errorf("failed to find stale sessions: %w", err) + } + stats.StaleSessions = len(staleSessions) + + // Clean up stale sessions + for _, session := range staleSessions { + if dryRun { + fmt.Printf("Would clean up stale session: %s (tmux: %s, worktree: %s)\n", + session.Core.Name, session.Core.TmuxSession, session.Core.WorktreePath) + continue + } + + if err := c.stateManager.DeleteSession(session.Core.ID); err != nil { + stats.Failed++ + errMsg := fmt.Sprintf("Failed to delete session %s: %v", session.Core.Name, err) + stats.Errors = append(stats.Errors, errMsg) + } else { + stats.Cleaned++ + } + } + + // Find orphaned tmux sessions + orphanedTmux, err := c.findOrphanedTmuxSessions() + if err != nil { + return stats, fmt.Errorf("failed to find orphaned tmux sessions: %w", err) + } + stats.OrphanedTmux = len(orphanedTmux) + + // Clean up orphaned tmux sessions + for _, tmuxSession := range orphanedTmux { + if dryRun { + fmt.Printf("Would kill orphaned tmux session: %s\n", tmuxSession) + continue + } + + if err := c.killTmuxSession(tmuxSession); err != nil { + stats.Failed++ + errMsg := fmt.Sprintf("Failed to kill tmux session %s: %v", tmuxSession, err) + stats.Errors = append(stats.Errors, errMsg) + } else { + stats.Cleaned++ + } + } + + // Find orphaned worktrees + orphanedWorktrees, err := c.findOrphanedWorktrees() + if err != nil { + return stats, fmt.Errorf("failed to find orphaned worktrees: %w", err) + } + stats.OrphanedWorktrees = len(orphanedWorktrees) + + // Clean up orphaned worktrees + for _, worktree := range orphanedWorktrees { + if dryRun { + fmt.Printf("Would remove orphaned worktree: %s\n", worktree) + continue + } + + if err := c.removeWorktree(worktree); err != nil { + stats.Failed++ + errMsg := fmt.Sprintf("Failed to remove worktree %s: %v", worktree, err) + stats.Errors = append(stats.Errors, errMsg) + } else { + stats.Cleaned++ + } + } + + return stats, nil +} + +// findOrphanedTmuxSessions finds tmux sessions that start with "cwt-" but don't have corresponding CWT sessions +func (c *CleanupOperations) findOrphanedTmuxSessions() ([]string, error) { + // Get all tmux sessions + tmuxSessions, err := c.stateManager.GetTmuxChecker().ListSessions() + if err != nil { + return nil, fmt.Errorf("failed to list tmux sessions: %w", err) + } + + // Get current CWT sessions + sessions, err := c.stateManager.DeriveFreshSessions() + if err != nil { + return nil, fmt.Errorf("failed to get current sessions: %w", err) + } + + // Create a map of active CWT tmux session names + activeTmux := make(map[string]bool) + for _, session := range sessions { + activeTmux[session.Core.TmuxSession] = true + } + + // Find orphaned sessions + var orphaned []string + for _, tmuxSession := range tmuxSessions { + if strings.HasPrefix(tmuxSession, "cwt-") && !activeTmux[tmuxSession] { + orphaned = append(orphaned, tmuxSession) + } + } + + return orphaned, nil +} + +// findOrphanedWorktrees finds git worktrees in .cwt/worktrees/ that don't have corresponding CWT sessions +func (c *CleanupOperations) findOrphanedWorktrees() ([]string, error) { + worktreesDir := filepath.Join(c.stateManager.GetDataDir(), "worktrees") + + // Check if worktrees directory exists + if _, err := os.Stat(worktreesDir); os.IsNotExist(err) { + return nil, nil // No worktrees directory means no orphaned worktrees + } + + // Get all worktree directories + entries, err := os.ReadDir(worktreesDir) + if err != nil { + return nil, fmt.Errorf("failed to read worktrees directory: %w", err) + } + + // Get current CWT sessions + sessions, err := c.stateManager.DeriveFreshSessions() + if err != nil { + return nil, fmt.Errorf("failed to get current sessions: %w", err) + } + + // Create a map of active session names + activeNames := make(map[string]bool) + for _, session := range sessions { + activeNames[session.Core.Name] = true + } + + // Find orphaned worktrees + var orphaned []string + for _, entry := range entries { + if entry.IsDir() && !activeNames[entry.Name()] { + orphaned = append(orphaned, entry.Name()) + } + } + + return orphaned, nil +} + +// killTmuxSession kills a tmux session +func (c *CleanupOperations) killTmuxSession(sessionName string) error { + return c.stateManager.GetTmuxChecker().KillSession(sessionName) +} + +// removeWorktree removes a git worktree +func (c *CleanupOperations) removeWorktree(name string) error { + // Security: Validate worktree name to prevent command injection + if !isValidWorktreeName(name) { + return fmt.Errorf("invalid worktree name: %s", name) + } + + worktreePath := filepath.Join(c.stateManager.GetDataDir(), "worktrees", name) + + // Security: Validate that the path is within our data directory + if !isPathWithinDataDir(worktreePath, c.stateManager.GetDataDir()) { + return fmt.Errorf("worktree path outside data directory: %s", worktreePath) + } + + // Use git worktree remove command + cmd := exec.Command("git", "worktree", "remove", worktreePath) + if err := cmd.Run(); err != nil { + // If git worktree remove fails, try force removal + cmd = exec.Command("git", "worktree", "remove", "--force", worktreePath) + if err := cmd.Run(); err != nil { + // If that also fails, remove the directory manually + return os.RemoveAll(worktreePath) + } + } + + return nil +} + +// isValidWorktreeName validates that a worktree name is safe +func isValidWorktreeName(name string) bool { + // Reject empty names + if name == "" { + return false + } + // Reject paths with directory traversal patterns + if strings.Contains(name, "..") { + return false + } + // Reject paths with null bytes + if strings.Contains(name, "\x00") { + return false + } + // Reject names with shell metacharacters + dangerousChars := []string{";", "&", "|", "$", "`", "(", ")", "{", "}", "[", "]", "*", "?", "<", ">", "~", " ", "\t", "\n", "\r"} + for _, char := range dangerousChars { + if strings.Contains(name, char) { + return false + } + } + // Reject names starting with dash (could be interpreted as flags) + if strings.HasPrefix(name, "-") { + return false + } + return true +} + +// isPathWithinDataDir validates that a path is within the expected data directory +func isPathWithinDataDir(path, dataDir string) bool { + // Clean and resolve both paths + cleanPath := filepath.Clean(path) + cleanDataDir := filepath.Clean(dataDir) + + // Make sure the path starts with the data directory + expectedPrefix := filepath.Join(cleanDataDir, "worktrees") + string(filepath.Separator) + cleanPathWithSep := cleanPath + string(filepath.Separator) + + return strings.HasPrefix(cleanPathWithSep, expectedPrefix) || cleanPath == filepath.Join(cleanDataDir, "worktrees") +} diff --git a/internal/operations/cleanup_test.go b/internal/operations/cleanup_test.go new file mode 100644 index 0000000..6a992a6 --- /dev/null +++ b/internal/operations/cleanup_test.go @@ -0,0 +1,266 @@ +package operations + +import ( + "path/filepath" + "testing" + + "github.com/jlaneve/cwt-cli/internal/clients/claude" + "github.com/jlaneve/cwt-cli/internal/clients/git" + "github.com/jlaneve/cwt-cli/internal/clients/tmux" + "github.com/jlaneve/cwt-cli/internal/state" +) + +func TestCleanupOperations_FindAndCleanupStaleResources_NoOrphans(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, ".cwt") + + tmuxChecker := tmux.NewMockChecker() + config := state.Config{ + DataDir: dataDir, + TmuxChecker: tmuxChecker, + GitChecker: git.NewMockChecker(), + ClaudeChecker: claude.NewMockChecker(), + BaseBranch: "main", + } + + manager := state.NewManager(config) + defer manager.Close() + + cleanupOps := NewCleanupOperations(manager) + + // Test with no sessions (should find no orphans) + stats, err := cleanupOps.FindAndCleanupStaleResources(true) // dry run + if err != nil { + t.Fatalf("FindAndCleanupStaleResources() error = %v", err) + } + + if stats.StaleSessions != 0 { + t.Errorf("Expected 0 stale sessions, got %d", stats.StaleSessions) + } + if stats.OrphanedTmux != 0 { + t.Errorf("Expected 0 orphaned tmux sessions, got %d", stats.OrphanedTmux) + } + if stats.OrphanedWorktrees != 0 { + t.Errorf("Expected 0 orphaned worktrees, got %d", stats.OrphanedWorktrees) + } +} + +func TestCleanupOperations_FindAndCleanupStaleResources_WithStaleSession(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, ".cwt") + + tmuxChecker := tmux.NewMockChecker() + config := state.Config{ + DataDir: dataDir, + TmuxChecker: tmuxChecker, + GitChecker: git.NewMockChecker(), + ClaudeChecker: claude.NewMockChecker(), + BaseBranch: "main", + } + + manager := state.NewManager(config) + defer manager.Close() + + // Create a session + sessionOps := NewSessionOperations(manager) + err := sessionOps.CreateSession("stale-session") + if err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + // Make the tmux session appear dead + tmuxChecker.SetSessionAlive("cwt-stale-session", false) + + cleanupOps := NewCleanupOperations(manager) + + // Test dry run - should find stale session but not clean it + stats, err := cleanupOps.FindAndCleanupStaleResources(true) + if err != nil { + t.Fatalf("FindAndCleanupStaleResources(dry run) error = %v", err) + } + + if stats.StaleSessions != 1 { + t.Errorf("Expected 1 stale session, got %d", stats.StaleSessions) + } + if stats.Cleaned != 0 { + t.Errorf("Expected 0 cleaned in dry run, got %d", stats.Cleaned) + } + + // Verify session still exists + sessions, _ := sessionOps.GetAllSessions() + if len(sessions) != 1 { + t.Errorf("Expected session to still exist after dry run, got %d sessions", len(sessions)) + } + + // Test actual cleanup + stats, err = cleanupOps.FindAndCleanupStaleResources(false) + if err != nil { + t.Fatalf("FindAndCleanupStaleResources(cleanup) error = %v", err) + } + + if stats.StaleSessions != 1 { + t.Errorf("Expected 1 stale session found, got %d", stats.StaleSessions) + } + if stats.Cleaned < 1 { + t.Errorf("Expected at least 1 session cleaned, got %d", stats.Cleaned) + } + + // Verify session was deleted + sessions, _ = sessionOps.GetAllSessions() + if len(sessions) != 0 { + t.Errorf("Expected session to be deleted after cleanup, got %d sessions", len(sessions)) + } +} + +func TestCleanupOperations_FindAndCleanupStaleResources_WithOrphanedTmux(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, ".cwt") + + tmuxChecker := tmux.NewMockChecker() + + // Add orphaned tmux sessions + tmuxChecker.SetSessionAlive("cwt-orphaned-1", true) + tmuxChecker.SetSessionAlive("cwt-orphaned-2", true) + tmuxChecker.SetSessionAlive("non-cwt-session", true) // Should be ignored + + config := state.Config{ + DataDir: dataDir, + TmuxChecker: tmuxChecker, + GitChecker: git.NewMockChecker(), + ClaudeChecker: claude.NewMockChecker(), + BaseBranch: "main", + } + + manager := state.NewManager(config) + defer manager.Close() + + cleanupOps := NewCleanupOperations(manager) + + // Test dry run - should find orphaned tmux sessions + stats, err := cleanupOps.FindAndCleanupStaleResources(true) + if err != nil { + t.Fatalf("FindAndCleanupStaleResources(dry run) error = %v", err) + } + + if stats.OrphanedTmux != 2 { + t.Errorf("Expected 2 orphaned tmux sessions, got %d", stats.OrphanedTmux) + } + if stats.Cleaned != 0 { + t.Errorf("Expected 0 cleaned in dry run, got %d", stats.Cleaned) + } + + // Verify tmux sessions still exist + if len(tmuxChecker.KilledSessions) != 0 { + t.Errorf("Expected no sessions killed in dry run, got %d", len(tmuxChecker.KilledSessions)) + } + + // Test actual cleanup + stats, err = cleanupOps.FindAndCleanupStaleResources(false) + if err != nil { + t.Fatalf("FindAndCleanupStaleResources(cleanup) error = %v", err) + } + + if stats.OrphanedTmux != 2 { + t.Errorf("Expected 2 orphaned tmux sessions found, got %d", stats.OrphanedTmux) + } + if stats.Cleaned != 2 { + t.Errorf("Expected 2 sessions cleaned, got %d", stats.Cleaned) + } + + // Verify tmux sessions were killed + if len(tmuxChecker.KilledSessions) != 2 { + t.Errorf("Expected 2 sessions killed, got %d", len(tmuxChecker.KilledSessions)) + } +} + +func TestCleanupOperations_CleanupStats(t *testing.T) { + stats := &CleanupStats{ + StaleSessions: 2, + OrphanedTmux: 1, + OrphanedWorktrees: 0, + Cleaned: 2, + Failed: 1, + Errors: []string{"Failed to delete session: permission denied"}, + } + + if len(stats.Errors) != 1 { + t.Errorf("Expected 1 error, got %d", len(stats.Errors)) + } + + if stats.Errors[0] != "Failed to delete session: permission denied" { + t.Errorf("Unexpected error message: %s", stats.Errors[0]) + } + + totalFound := stats.StaleSessions + stats.OrphanedTmux + stats.OrphanedWorktrees + if totalFound != 3 { + t.Errorf("Expected total found = 3, got %d", totalFound) + } +} + +func TestIsValidWorktreeName(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"valid name", "valid-session", true}, + {"valid with underscore", "valid_session", true}, + {"valid with numbers", "session123", true}, + {"empty name", "", false}, + {"directory traversal", "../etc/passwd", false}, + {"null byte", "session\x00name", false}, + {"semicolon injection", "session;rm -rf /", false}, + {"ampersand injection", "session&whoami", false}, + {"pipe injection", "session|cat /etc/passwd", false}, + {"dollar injection", "session$USER", false}, + {"backtick injection", "session`whoami`", false}, + {"parentheses injection", "session(whoami)", false}, + {"braces injection", "session{whoami}", false}, + {"brackets injection", "session[whoami]", false}, + {"asterisk", "session*", false}, + {"question mark", "session?", false}, + {"less than", "sessionfile", false}, + {"tilde", "session~", false}, + {"space", "session name", false}, + {"tab", "session\tname", false}, + {"newline", "session\nname", false}, + {"carriage return", "session\rname", false}, + {"starts with dash", "-session", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isValidWorktreeName(tt.input) + if result != tt.expected { + t.Errorf("isValidWorktreeName(%q) = %v, expected %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestIsPathWithinDataDir(t *testing.T) { + dataDir := "/home/user/.cwt" + + tests := []struct { + name string + path string + expected bool + }{ + {"valid worktree path", "/home/user/.cwt/worktrees/session1", true}, + {"worktrees directory itself", "/home/user/.cwt/worktrees", true}, + {"path outside data dir", "/etc/passwd", false}, + {"directory traversal", "/home/user/.cwt/worktrees/../../../etc/passwd", false}, + {"relative path traversal", "/home/user/.cwt/worktrees/../../etc/passwd", false}, + {"path not in worktrees", "/home/user/.cwt/sessions.json", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isPathWithinDataDir(tt.path, dataDir) + if result != tt.expected { + t.Errorf("isPathWithinDataDir(%q, %q) = %v, expected %v", tt.path, dataDir, result, tt.expected) + } + }) + } +} diff --git a/internal/operations/formatting.go b/internal/operations/formatting.go new file mode 100644 index 0000000..37f03b8 --- /dev/null +++ b/internal/operations/formatting.go @@ -0,0 +1,152 @@ +package operations + +import ( + "fmt" + "strings" + "time" + + "github.com/jlaneve/cwt-cli/internal/types" +) + +// StatusFormat defines how to format session status information +type StatusFormat struct { + // Add configuration options if needed in the future +} + +// NewStatusFormat creates a new StatusFormat instance +func NewStatusFormat() *StatusFormat { + return &StatusFormat{} +} + +// FormatTmuxStatus formats the tmux status with appropriate emoji and color +func (f *StatusFormat) FormatTmuxStatus(isAlive bool) string { + if isAlive { + return "🟒 alive" + } + return "πŸ”΄ dead" +} + +// FormatClaudeStatus formats the Claude status with appropriate emoji and details +func (f *StatusFormat) FormatClaudeStatus(claudeStatus types.ClaudeStatus) string { + switch claudeStatus.State { + case types.ClaudeWorking: + return "πŸ”΅ working" + case types.ClaudeWaiting: + if claudeStatus.StatusMessage != "" { + return fmt.Sprintf("⏸️ %s", claudeStatus.StatusMessage) + } + return "⏸️ waiting" + case types.ClaudeComplete: + return "βœ… complete" + case types.ClaudeIdle: + return "🟑 idle" + case types.ClaudeUnknown: + return "❓ unknown" + default: + return "❓ unknown" + } +} + +// FormatGitStatus formats the git status with file change information +func (f *StatusFormat) FormatGitStatus(gitStatus types.GitStatus) string { + if !gitStatus.HasChanges { + return "🟒 clean" + } + + parts := []string{} + + if len(gitStatus.ModifiedFiles) > 0 { + if len(gitStatus.ModifiedFiles) == 1 { + parts = append(parts, "1 file") + } else { + parts = append(parts, fmt.Sprintf("%d files", len(gitStatus.ModifiedFiles))) + } + } + + if len(gitStatus.UntrackedFiles) > 0 { + if len(gitStatus.UntrackedFiles) == 1 { + parts = append(parts, "1 untracked") + } else { + parts = append(parts, fmt.Sprintf("%d untracked", len(gitStatus.UntrackedFiles))) + } + } + + if len(parts) == 0 { + return "🟑 changes" + } + + return fmt.Sprintf("🟑 %s", strings.Join(parts, ", ")) +} + +// FormatActivity formats the last activity time +func (f *StatusFormat) FormatActivity(lastActivity time.Time) string { + if lastActivity.IsZero() { + return "never" + } + + duration := time.Since(lastActivity) + return f.FormatDuration(duration) + " ago" +} + +// FormatDuration formats a duration in a human-readable way +func (f *StatusFormat) FormatDuration(duration time.Duration) string { + if duration < time.Minute { + return "just now" + } else if duration < time.Hour { + minutes := int(duration.Minutes()) + if minutes == 1 { + return "1 minute" + } + return fmt.Sprintf("%d minutes", minutes) + } else if duration < 24*time.Hour { + hours := int(duration.Hours()) + if hours == 1 { + return "1 hour" + } + return fmt.Sprintf("%d hours", hours) + } else { + days := int(duration.Hours() / 24) + if days == 1 { + return "1 day" + } + return fmt.Sprintf("%d days", days) + } +} + +// FormatSessionSummary creates a one-line summary of a session's status +func (f *StatusFormat) FormatSessionSummary(session types.Session) string { + tmux := f.FormatTmuxStatus(session.IsAlive) + claude := f.FormatClaudeStatus(session.ClaudeStatus) + git := f.FormatGitStatus(session.GitStatus) + activity := f.FormatActivity(session.LastActivity) + + return fmt.Sprintf("tmux: %s | claude: %s | git: %s | activity: %s", + tmux, claude, git, activity) +} + +// FormatSessionList formats a list of sessions for display +func (f *StatusFormat) FormatSessionList(sessions []types.Session, detailed bool) string { + if len(sessions) == 0 { + return "No sessions found." + } + + var result strings.Builder + + for i, session := range sessions { + if i > 0 { + result.WriteString("\n") + } + + result.WriteString(fmt.Sprintf("πŸ“‚ %s", session.Core.Name)) + + if detailed { + result.WriteString(fmt.Sprintf(" (%s)", session.Core.ID)) + result.WriteString(fmt.Sprintf("\n %s", f.FormatSessionSummary(session))) + result.WriteString(fmt.Sprintf("\n πŸ“ %s", session.Core.WorktreePath)) + } else { + result.WriteString(fmt.Sprintf(" - %s", f.FormatSessionSummary(session))) + } + } + + return result.String() +} diff --git a/internal/operations/formatting_test.go b/internal/operations/formatting_test.go new file mode 100644 index 0000000..ca7ea00 --- /dev/null +++ b/internal/operations/formatting_test.go @@ -0,0 +1,275 @@ +package operations + +import ( + "strings" + "testing" + "time" + + "github.com/jlaneve/cwt-cli/internal/types" +) + +func TestStatusFormat_FormatTmuxStatus(t *testing.T) { + formatter := NewStatusFormat() + + tests := []struct { + name string + isAlive bool + expected string + }{ + {"alive session", true, "🟒 alive"}, + {"dead session", false, "πŸ”΄ dead"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatter.FormatTmuxStatus(tt.isAlive) + if result != tt.expected { + t.Errorf("FormatTmuxStatus(%v) = %q, want %q", tt.isAlive, result, tt.expected) + } + }) + } +} + +func TestStatusFormat_FormatClaudeStatus(t *testing.T) { + formatter := NewStatusFormat() + + tests := []struct { + name string + status types.ClaudeStatus + expected string + }{ + { + name: "working status", + status: types.ClaudeStatus{State: types.ClaudeWorking}, + expected: "πŸ”΅ working", + }, + { + name: "idle status", + status: types.ClaudeStatus{State: types.ClaudeIdle}, + expected: "🟑 idle", + }, + { + name: "waiting status", + status: types.ClaudeStatus{State: types.ClaudeWaiting}, + expected: "⏸️ waiting", + }, + { + name: "waiting with message", + status: types.ClaudeStatus{State: types.ClaudeWaiting, StatusMessage: "waiting for input"}, + expected: "⏸️ waiting for input", + }, + { + name: "complete status", + status: types.ClaudeStatus{State: types.ClaudeComplete}, + expected: "βœ… complete", + }, + { + name: "unknown status", + status: types.ClaudeStatus{State: types.ClaudeUnknown}, + expected: "❓ unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatter.FormatClaudeStatus(tt.status) + if result != tt.expected { + t.Errorf("FormatClaudeStatus(%+v) = %q, want %q", tt.status, result, tt.expected) + } + }) + } +} + +func TestStatusFormat_FormatGitStatus(t *testing.T) { + formatter := NewStatusFormat() + + tests := []struct { + name string + status types.GitStatus + expected string + }{ + { + name: "clean repository", + status: types.GitStatus{HasChanges: false}, + expected: "🟒 clean", + }, + { + name: "one modified file", + status: types.GitStatus{ + HasChanges: true, + ModifiedFiles: []string{"test.go"}, + }, + expected: "🟑 1 file", + }, + { + name: "multiple modified files", + status: types.GitStatus{ + HasChanges: true, + ModifiedFiles: []string{"test.go", "main.go"}, + }, + expected: "🟑 2 files", + }, + { + name: "one untracked file", + status: types.GitStatus{ + HasChanges: true, + UntrackedFiles: []string{"new.txt"}, + }, + expected: "🟑 1 untracked", + }, + { + name: "mixed changes", + status: types.GitStatus{ + HasChanges: true, + ModifiedFiles: []string{"test.go"}, + UntrackedFiles: []string{"new.txt", "other.txt"}, + }, + expected: "🟑 1 file, 2 untracked", + }, + { + name: "changes without specific files", + status: types.GitStatus{ + HasChanges: true, + }, + expected: "🟑 changes", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatter.FormatGitStatus(tt.status) + if result != tt.expected { + t.Errorf("FormatGitStatus(%+v) = %q, want %q", tt.status, result, tt.expected) + } + }) + } +} + +func TestStatusFormat_FormatDuration(t *testing.T) { + formatter := NewStatusFormat() + + tests := []struct { + name string + duration time.Duration + expected string + }{ + {"30 seconds", 30 * time.Second, "just now"}, + {"1 minute", 1 * time.Minute, "1 minute"}, + {"5 minutes", 5 * time.Minute, "5 minutes"}, + {"1 hour", 1 * time.Hour, "1 hour"}, + {"3 hours", 3 * time.Hour, "3 hours"}, + {"1 day", 24 * time.Hour, "1 day"}, + {"3 days", 72 * time.Hour, "3 days"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatter.FormatDuration(tt.duration) + if result != tt.expected { + t.Errorf("FormatDuration(%v) = %q, want %q", tt.duration, result, tt.expected) + } + }) + } +} + +func TestStatusFormat_FormatActivity(t *testing.T) { + formatter := NewStatusFormat() + + now := time.Now() + + tests := []struct { + name string + lastActivity time.Time + expected string + }{ + {"never active", time.Time{}, "never"}, + {"5 minutes ago", now.Add(-5 * time.Minute), "5 minutes ago"}, + {"1 hour ago", now.Add(-1 * time.Hour), "1 hour ago"}, + {"2 days ago", now.Add(-48 * time.Hour), "2 days ago"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatter.FormatActivity(tt.lastActivity) + if result != tt.expected { + t.Errorf("FormatActivity(%v) = %q, want %q", tt.lastActivity, result, tt.expected) + } + }) + } +} + +func TestStatusFormat_FormatSessionSummary(t *testing.T) { + formatter := NewStatusFormat() + + session := types.Session{ + Core: types.CoreSession{ + Name: "test-session", + }, + IsAlive: true, + ClaudeStatus: types.ClaudeStatus{ + State: types.ClaudeWorking, + }, + GitStatus: types.GitStatus{ + HasChanges: false, + }, + LastActivity: time.Now().Add(-10 * time.Minute), + } + + result := formatter.FormatSessionSummary(session) + + // Check that all components are present + expectedParts := []string{ + "tmux: 🟒 alive", + "claude: πŸ”΅ working", + "git: 🟒 clean", + "activity:", + "minutes ago", + } + + for _, part := range expectedParts { + if !strings.Contains(result, part) { + t.Errorf("FormatSessionSummary() result missing expected part %q\nGot: %q", part, result) + } + } +} + +func TestStatusFormat_FormatSessionList(t *testing.T) { + formatter := NewStatusFormat() + + // Test empty list + result := formatter.FormatSessionList([]types.Session{}, false) + expected := "No sessions found." + if result != expected { + t.Errorf("FormatSessionList(empty) = %q, want %q", result, expected) + } + + // Test single session + sessions := []types.Session{ + { + Core: types.CoreSession{ + ID: "test-id", + Name: "test-session", + }, + IsAlive: true, + ClaudeStatus: types.ClaudeStatus{ + State: types.ClaudeIdle, + }, + GitStatus: types.GitStatus{ + HasChanges: false, + }, + LastActivity: time.Now(), + }, + } + + // Test simple format + result = formatter.FormatSessionList(sessions, false) + if !strings.Contains(result, "πŸ“‚ test-session") { + t.Errorf("FormatSessionList() missing session name, got: %q", result) + } + + // Test detailed format + result = formatter.FormatSessionList(sessions, true) + if !strings.Contains(result, "test-id") { + t.Errorf("FormatSessionList(detailed) missing session ID, got: %q", result) + } +} diff --git a/internal/operations/sessions.go b/internal/operations/sessions.go new file mode 100644 index 0000000..ae8a793 --- /dev/null +++ b/internal/operations/sessions.go @@ -0,0 +1,137 @@ +package operations + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/jlaneve/cwt-cli/internal/state" + "github.com/jlaneve/cwt-cli/internal/types" +) + +// SessionOperations provides business logic for session management +type SessionOperations struct { + stateManager *state.Manager +} + +// NewSessionOperations creates a new SessionOperations instance +func NewSessionOperations(sm *state.Manager) *SessionOperations { + return &SessionOperations{ + stateManager: sm, + } +} + +// CreateSession creates a new session with the given name +func (s *SessionOperations) CreateSession(name string) error { + return s.stateManager.CreateSession(name) +} + +// DeleteSession deletes the session with the given ID +func (s *SessionOperations) DeleteSession(sessionID string) error { + return s.stateManager.DeleteSession(sessionID) +} + +// FindSessionByName finds a session by its name +// Returns the session and its ID, or an error if not found +func (s *SessionOperations) FindSessionByName(name string) (*types.Session, string, error) { + sessions, err := s.stateManager.DeriveFreshSessions() + if err != nil { + return nil, "", fmt.Errorf("failed to load sessions: %w", err) + } + + for _, session := range sessions { + if session.Core.Name == name { + return &session, session.Core.ID, nil + } + } + + return nil, "", fmt.Errorf("session '%s' not found", name) +} + +// FindSessionByID finds a session by its ID +func (s *SessionOperations) FindSessionByID(sessionID string) (*types.Session, error) { + sessions, err := s.stateManager.DeriveFreshSessions() + if err != nil { + return nil, fmt.Errorf("failed to load sessions: %w", err) + } + + for _, session := range sessions { + if session.Core.ID == sessionID { + return &session, nil + } + } + + return nil, fmt.Errorf("session with ID '%s' not found", sessionID) +} + +// GetAllSessions returns all current sessions +func (s *SessionOperations) GetAllSessions() ([]types.Session, error) { + return s.stateManager.DeriveFreshSessions() +} + +// RecreateDeadSession recreates a tmux session for a session that has died +// This handles Claude session resumption if a previous session exists +func (s *SessionOperations) RecreateDeadSession(session *types.Session) error { + claudeExec := FindClaudeExecutable() + if claudeExec == "" { + return fmt.Errorf("claude executable not found in PATH") + } + + command := claudeExec + + // Check if there's an existing Claude session to resume + if existingSessionID, err := s.stateManager.GetClaudeChecker().FindSessionID(session.Core.WorktreePath); err == nil && existingSessionID != "" { + command = fmt.Sprintf("%s -r %s", claudeExec, existingSessionID) + } + + // Create the tmux session + tmuxChecker := s.stateManager.GetTmuxChecker() + return tmuxChecker.CreateSession(session.Core.TmuxSession, session.Core.WorktreePath, command) +} + +// FindClaudeExecutable searches for the Claude CLI executable in common locations +func FindClaudeExecutable() string { + claudePaths := []string{ + "claude", + os.ExpandEnv("$HOME/.claude/local/claude"), + os.ExpandEnv("$HOME/.claude/local/node_modules/.bin/claude"), + "/usr/local/bin/claude", + } + + for _, path := range claudePaths { + // Security: Validate expanded paths to prevent directory traversal + if !isValidExecutablePath(path) { + continue + } + if _, err := exec.LookPath(path); err == nil { + return path + } + } + + return "" +} + +// isValidExecutablePath validates that a path is safe to use as an executable +func isValidExecutablePath(path string) bool { + // Reject paths with directory traversal patterns + if strings.Contains(path, "..") { + return false + } + // Reject paths with null bytes + if strings.Contains(path, "\x00") { + return false + } + // Reject paths with shell metacharacters (except legitimate path separators) + dangerousChars := []string{";", "&", "|", "$", "`", "(", ")", "{", "}", "[", "]", "*", "?", "<", ">", "~"} + for _, char := range dangerousChars { + if strings.Contains(path, char) { + // Allow $HOME in environment expansion, but only at the start + if char == "$" && strings.HasPrefix(path, "$HOME") { + continue + } + return false + } + } + return true +} diff --git a/internal/operations/sessions_test.go b/internal/operations/sessions_test.go new file mode 100644 index 0000000..95bc7d3 --- /dev/null +++ b/internal/operations/sessions_test.go @@ -0,0 +1,294 @@ +package operations + +import ( + "os/exec" + "path/filepath" + "testing" + + "github.com/jlaneve/cwt-cli/internal/clients/claude" + "github.com/jlaneve/cwt-cli/internal/clients/git" + "github.com/jlaneve/cwt-cli/internal/clients/tmux" + "github.com/jlaneve/cwt-cli/internal/state" +) + +func TestSessionOperations_CreateSession(t *testing.T) { + // Create temp directory for testing + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, ".cwt") + + // Create manager with mocks + config := state.Config{ + DataDir: dataDir, + TmuxChecker: tmux.NewMockChecker(), + GitChecker: git.NewMockChecker(), + ClaudeChecker: claude.NewMockChecker(), + BaseBranch: "main", + } + + manager := state.NewManager(config) + defer manager.Close() + + sessionOps := NewSessionOperations(manager) + + // Test creating a session + err := sessionOps.CreateSession("test-session") + if err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + // Verify session was created + sessions, err := sessionOps.GetAllSessions() + if err != nil { + t.Fatalf("GetAllSessions() error = %v", err) + } + + if len(sessions) != 1 { + t.Errorf("Expected 1 session, got %d", len(sessions)) + } + + if sessions[0].Core.Name != "test-session" { + t.Errorf("Expected session name 'test-session', got %v", sessions[0].Core.Name) + } +} + +func TestSessionOperations_FindSessionByName(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, ".cwt") + + config := state.Config{ + DataDir: dataDir, + TmuxChecker: tmux.NewMockChecker(), + GitChecker: git.NewMockChecker(), + ClaudeChecker: claude.NewMockChecker(), + BaseBranch: "main", + } + + manager := state.NewManager(config) + defer manager.Close() + + sessionOps := NewSessionOperations(manager) + + // Create a session first + err := sessionOps.CreateSession("findme-session") + if err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + // Test finding existing session + session, sessionID, err := sessionOps.FindSessionByName("findme-session") + if err != nil { + t.Fatalf("FindSessionByName() error = %v", err) + } + + if session.Core.Name != "findme-session" { + t.Errorf("Expected session name 'findme-session', got %v", session.Core.Name) + } + + if sessionID == "" { + t.Error("Expected non-empty session ID") + } + + // Test finding non-existent session + _, _, err = sessionOps.FindSessionByName("nonexistent") + if err == nil { + t.Error("Expected error for non-existent session") + } +} + +func TestSessionOperations_FindSessionByID(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, ".cwt") + + config := state.Config{ + DataDir: dataDir, + TmuxChecker: tmux.NewMockChecker(), + GitChecker: git.NewMockChecker(), + ClaudeChecker: claude.NewMockChecker(), + BaseBranch: "main", + } + + manager := state.NewManager(config) + defer manager.Close() + + sessionOps := NewSessionOperations(manager) + + // Create a session first + err := sessionOps.CreateSession("findbyid-session") + if err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + // Get the session ID + sessions, err := sessionOps.GetAllSessions() + if err != nil { + t.Fatalf("GetAllSessions() error = %v", err) + } + sessionID := sessions[0].Core.ID + + // Test finding by ID + session, err := sessionOps.FindSessionByID(sessionID) + if err != nil { + t.Fatalf("FindSessionByID() error = %v", err) + } + + if session.Core.Name != "findbyid-session" { + t.Errorf("Expected session name 'findbyid-session', got %v", session.Core.Name) + } + + // Test finding non-existent ID + _, err = sessionOps.FindSessionByID("nonexistent-id") + if err == nil { + t.Error("Expected error for non-existent session ID") + } +} + +func TestSessionOperations_DeleteSession(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, ".cwt") + + config := state.Config{ + DataDir: dataDir, + TmuxChecker: tmux.NewMockChecker(), + GitChecker: git.NewMockChecker(), + ClaudeChecker: claude.NewMockChecker(), + BaseBranch: "main", + } + + manager := state.NewManager(config) + defer manager.Close() + + sessionOps := NewSessionOperations(manager) + + // Create a session first + err := sessionOps.CreateSession("delete-me") + if err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + // Get session ID + sessions, _ := sessionOps.GetAllSessions() + sessionID := sessions[0].Core.ID + + // Delete the session + err = sessionOps.DeleteSession(sessionID) + if err != nil { + t.Fatalf("DeleteSession() error = %v", err) + } + + // Verify session was deleted + sessions, _ = sessionOps.GetAllSessions() + if len(sessions) != 0 { + t.Errorf("Expected 0 sessions after deletion, got %d", len(sessions)) + } +} + +func TestFindClaudeExecutable(t *testing.T) { + // This test checks that the function doesn't crash + // We can't reliably test the actual finding logic without + // modifying PATH or creating fake executables + result := FindClaudeExecutable() + + // Should return a string (empty if not found) + if result == "" { + t.Log("Claude executable not found in PATH (this is expected in test environment)") + } else { + t.Logf("Found Claude executable at: %s", result) + + // If we found something, verify it's actually executable + if _, err := exec.LookPath(result); err != nil { + t.Errorf("FindClaudeExecutable() returned %q but it's not in PATH: %v", result, err) + } + } +} + +func TestSessionOperations_RecreateDeadSession(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, ".cwt") + + tmuxChecker := tmux.NewMockChecker() + claudeChecker := claude.NewMockChecker() + + config := state.Config{ + DataDir: dataDir, + TmuxChecker: tmuxChecker, + GitChecker: git.NewMockChecker(), + ClaudeChecker: claudeChecker, + BaseBranch: "main", + } + + manager := state.NewManager(config) + defer manager.Close() + + sessionOps := NewSessionOperations(manager) + + // Create a session first + err := sessionOps.CreateSession("recreate-test") + if err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + // Get the session + session, _, err := sessionOps.FindSessionByName("recreate-test") + if err != nil { + t.Fatalf("FindSessionByName() error = %v", err) + } + + // Test recreating session (will only work if claude executable is available) + err = sessionOps.RecreateDeadSession(session) + + // If claude is not available, expect specific error + claudeExec := FindClaudeExecutable() + if claudeExec == "" { + if err == nil { + t.Error("Expected error when claude executable not found") + } + return + } + + // If claude is available, the operation should succeed + if err != nil { + t.Errorf("RecreateDeadSession() error = %v", err) + } + + // Verify tmux session was created + if len(tmuxChecker.CreatedSessions) != 2 { // One from initial creation, one from recreation + t.Errorf("Expected 2 tmux sessions created, got %d", len(tmuxChecker.CreatedSessions)) + } +} + +func TestIsValidExecutablePath(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"valid path", "/usr/local/bin/claude", true}, + {"valid relative path", "claude", true}, + {"valid home expansion", "$HOME/.claude/local/claude", true}, + {"directory traversal", "../../../etc/passwd", false}, + {"null byte", "/usr/bin/claude\x00", false}, + {"semicolon injection", "/usr/bin/claude;rm -rf /", false}, + {"ampersand injection", "/usr/bin/claude&whoami", false}, + {"pipe injection", "/usr/bin/claude|cat /etc/passwd", false}, + {"backtick injection", "/usr/bin/claude`whoami`", false}, + {"parentheses injection", "/usr/bin/claude(whoami)", false}, + {"braces injection", "/usr/bin/claude{whoami}", false}, + {"brackets injection", "/usr/bin/claude[whoami]", false}, + {"asterisk", "/usr/bin/claude*", false}, + {"question mark", "/usr/bin/claude?", false}, + {"less than", "/usr/bin/claudefile", false}, + {"tilde", "/usr/bin/claude~", false}, + {"dollar in middle", "/usr/bin/clau$de", false}, + {"home at start is ok", "$HOME/bin/claude", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isValidExecutablePath(tt.input) + if result != tt.expected { + t.Errorf("isValidExecutablePath(%q) = %v, expected %v", tt.input, result, tt.expected) + } + }) + } +} diff --git a/internal/state/manager.go b/internal/state/manager.go index e7b0234..303846a 100644 --- a/internal/state/manager.go +++ b/internal/state/manager.go @@ -338,7 +338,7 @@ func (m *Manager) createExternalResources(core types.CoreSession) error { // Create tmux session // Check if claude is available, otherwise create session without it var command string - if claudeExec := m.findClaudeExecutable(); claudeExec != "" { + if claudeExec := findClaudeExecutable(); claudeExec != "" { command = claudeExec } @@ -493,8 +493,7 @@ func (m *Manager) getCwtExecutablePath() string { } // findClaudeExecutable searches for claude in common installation paths -func (m *Manager) findClaudeExecutable() string { - // Check common installation paths +func findClaudeExecutable() string { claudePaths := []string{ "claude", os.ExpandEnv("$HOME/.claude/local/claude"),