package cmd import ( "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/polecat" "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" ) // Polecat command flags var ( polecatListJSON bool polecatListAll bool polecatForce bool polecatRemoveAll bool ) var polecatCmd = &cobra.Command{ Use: "polecat", Aliases: []string{"cat", "polecats"}, GroupID: GroupAgents, Short: "Manage polecats in rigs", Long: `Manage polecat lifecycle in rigs. Polecats are worker agents that operate in their own git worktrees. Use the subcommands to add, remove, list, wake, and sleep polecats.`, } var polecatListCmd = &cobra.Command{ Use: "list [rig]", Short: "List polecats in a rig", Long: `List polecats in a rig or all rigs. In the transient model, polecats exist only while working. The list shows all currently active polecats with their states: - working: Actively working on an issue - done: Completed work, waiting for cleanup - stuck: Needs assistance Examples: gt polecat list gastown gt polecat list --all gt polecat list gastown --json`, RunE: runPolecatList, } var polecatAddCmd = &cobra.Command{ Use: "add ", Short: "Add a new polecat to a rig", Long: `Add a new polecat to a rig. Creates a polecat directory, clones the rig repo, creates a work branch, and initializes state. Example: gt polecat add gastown Toast`, Args: cobra.ExactArgs(2), RunE: runPolecatAdd, } var polecatRemoveCmd = &cobra.Command{ Use: "remove /... | --all", Short: "Remove polecats from a rig", Long: `Remove one or more polecats from a rig. Fails if session is running (stop first). Warns if uncommitted changes exist. Use --force to bypass checks. Examples: gt polecat remove gastown/Toast gt polecat remove gastown/Toast gastown/Furiosa gt polecat remove gastown --all gt polecat remove gastown --all --force`, Args: cobra.MinimumNArgs(1), RunE: runPolecatRemove, } var polecatWakeCmd = &cobra.Command{ Use: "wake /", Short: "(Deprecated) Resume a polecat to working state", Long: `Resume a polecat to working state. DEPRECATED: In the transient model, polecats are created fresh for each task via 'gt spawn'. This command is kept for backward compatibility. Transitions: done → working Example: gt polecat wake gastown/Toast`, Args: cobra.ExactArgs(1), RunE: runPolecatWake, } var polecatSleepCmd = &cobra.Command{ Use: "sleep /", Short: "(Deprecated) Mark polecat as done", Long: `Mark polecat as done. DEPRECATED: In the transient model, polecats use 'gt handoff' when complete, which triggers automatic cleanup by the Witness. This command is kept for backward compatibility. Transitions: working → done Example: gt polecat sleep gastown/Toast`, Args: cobra.ExactArgs(1), RunE: runPolecatSleep, } var polecatDoneCmd = &cobra.Command{ Use: "done /", Aliases: []string{"finish"}, Short: "Mark polecat as done with work and return to idle", Long: `Mark polecat as done with work and return to idle. Transitions: working/done/stuck → idle Clears the assigned issue. Fails if session is running (stop first). Example: gt polecat done gastown/Toast gt polecat finish gastown/Toast`, Args: cobra.ExactArgs(1), RunE: runPolecatDone, } var polecatResetCmd = &cobra.Command{ Use: "reset /", Short: "Force reset polecat to idle state", Long: `Force reset polecat to idle state. Transitions: any state → idle Clears the assigned issue. Use when polecat is stuck in an unexpected state. Fails if session is running (stop first). Example: gt polecat reset gastown/Toast`, Args: cobra.ExactArgs(1), RunE: runPolecatReset, } var polecatSyncCmd = &cobra.Command{ Use: "sync /", Short: "Sync beads for a polecat", Long: `Sync beads for a polecat's worktree. Runs 'bd sync' in the polecat's worktree to push local beads changes to the shared sync branch and pull remote changes. Use --all to sync all polecats in a rig. Use --from-main to only pull (no push). Examples: gt polecat sync gastown/Toast gt polecat sync gastown --all gt polecat sync gastown/Toast --from-main`, Args: cobra.MaximumNArgs(1), RunE: runPolecatSync, } var polecatStatusCmd = &cobra.Command{ Use: "status /", Short: "Show detailed status for a polecat", Long: `Show detailed status for a polecat. Displays comprehensive information including: - Current lifecycle state (working, done, stuck, idle) - Assigned issue (if any) - Session status (running/stopped, attached/detached) - Session creation time - Last activity time Examples: gt polecat status gastown/Toast gt polecat status gastown/Toast --json`, Args: cobra.ExactArgs(1), RunE: runPolecatStatus, } var ( polecatSyncAll bool polecatSyncFromMain bool polecatStatusJSON bool polecatGitStateJSON bool ) var polecatGitStateCmd = &cobra.Command{ Use: "git-state /", Short: "Show git state for pre-kill verification", Long: `Show git state for a polecat's worktree. Used by the Witness for pre-kill verification to ensure no work is lost. Returns whether the worktree is clean (safe to kill) or dirty (needs cleanup). Checks: - Working tree: uncommitted changes - Unpushed commits: commits ahead of origin/main - Stashes: stashed changes Examples: gt polecat git-state gastown/Toast gt polecat git-state gastown/Toast --json`, Args: cobra.ExactArgs(1), RunE: runPolecatGitState, } func init() { // List flags polecatListCmd.Flags().BoolVar(&polecatListJSON, "json", false, "Output as JSON") polecatListCmd.Flags().BoolVar(&polecatListAll, "all", false, "List polecats in all rigs") // Remove flags polecatRemoveCmd.Flags().BoolVarP(&polecatForce, "force", "f", false, "Force removal, bypassing checks") polecatRemoveCmd.Flags().BoolVar(&polecatRemoveAll, "all", false, "Remove all polecats in the rig") // Sync flags polecatSyncCmd.Flags().BoolVar(&polecatSyncAll, "all", false, "Sync all polecats in the rig") polecatSyncCmd.Flags().BoolVar(&polecatSyncFromMain, "from-main", false, "Pull only, no push") // Status flags polecatStatusCmd.Flags().BoolVar(&polecatStatusJSON, "json", false, "Output as JSON") // Git-state flags polecatGitStateCmd.Flags().BoolVar(&polecatGitStateJSON, "json", false, "Output as JSON") // Add subcommands polecatCmd.AddCommand(polecatListCmd) polecatCmd.AddCommand(polecatAddCmd) polecatCmd.AddCommand(polecatRemoveCmd) polecatCmd.AddCommand(polecatWakeCmd) polecatCmd.AddCommand(polecatSleepCmd) polecatCmd.AddCommand(polecatDoneCmd) polecatCmd.AddCommand(polecatResetCmd) polecatCmd.AddCommand(polecatSyncCmd) polecatCmd.AddCommand(polecatStatusCmd) polecatCmd.AddCommand(polecatGitStateCmd) rootCmd.AddCommand(polecatCmd) } // PolecatListItem represents a polecat in list output. type PolecatListItem struct { Rig string `json:"rig"` Name string `json:"name"` State polecat.State `json:"state"` Issue string `json:"issue,omitempty"` SessionRunning bool `json:"session_running"` } // getPolecatManager creates a polecat manager for the given rig. func getPolecatManager(rigName string) (*polecat.Manager, *rig.Rig, error) { _, r, err := getRig(rigName) if err != nil { return nil, nil, err } polecatGit := git.NewGit(r.Path) mgr := polecat.NewManager(r, polecatGit) return mgr, r, nil } func runPolecatList(cmd *cobra.Command, args []string) error { var rigs []*rig.Rig if polecatListAll { // List all rigs allRigs, _, err := getAllRigs() if err != nil { return err } rigs = allRigs } else { // Need a rig name if len(args) < 1 { return fmt.Errorf("rig name required (or use --all)") } _, r, err := getPolecatManager(args[0]) if err != nil { return err } rigs = []*rig.Rig{r} } // Collect polecats from all rigs t := tmux.NewTmux() var allPolecats []PolecatListItem for _, r := range rigs { polecatGit := git.NewGit(r.Path) mgr := polecat.NewManager(r, polecatGit) sessMgr := session.NewManager(t, r) polecats, err := mgr.List() if err != nil { fmt.Fprintf(os.Stderr, "warning: failed to list polecats in %s: %v\n", r.Name, err) continue } for _, p := range polecats { running, _ := sessMgr.IsRunning(p.Name) allPolecats = append(allPolecats, PolecatListItem{ Rig: r.Name, Name: p.Name, State: p.State, Issue: p.Issue, SessionRunning: running, }) } } // Output if polecatListJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(allPolecats) } if len(allPolecats) == 0 { fmt.Println("No active polecats found.") return nil } fmt.Printf("%s\n\n", style.Bold.Render("Active Polecats")) for _, p := range allPolecats { // Session indicator sessionStatus := style.Dim.Render("○") if p.SessionRunning { sessionStatus = style.Success.Render("●") } // Normalize state for display (legacy idle/active → working) displayState := p.State if p.State == polecat.StateIdle || p.State == polecat.StateActive { displayState = polecat.StateWorking } // State color stateStr := string(displayState) switch displayState { case polecat.StateWorking: stateStr = style.Info.Render(stateStr) case polecat.StateStuck: stateStr = style.Warning.Render(stateStr) case polecat.StateDone: stateStr = style.Success.Render(stateStr) default: stateStr = style.Dim.Render(stateStr) } fmt.Printf(" %s %s/%s %s\n", sessionStatus, p.Rig, p.Name, stateStr) if p.Issue != "" { fmt.Printf(" %s\n", style.Dim.Render(p.Issue)) } } return nil } func runPolecatAdd(cmd *cobra.Command, args []string) error { rigName := args[0] polecatName := args[1] mgr, _, err := getPolecatManager(rigName) if err != nil { return err } fmt.Printf("Adding polecat %s to rig %s...\n", polecatName, rigName) p, err := mgr.Add(polecatName) if err != nil { return fmt.Errorf("adding polecat: %w", err) } fmt.Printf("%s Polecat %s added.\n", style.SuccessPrefix, p.Name) fmt.Printf(" %s\n", style.Dim.Render(p.ClonePath)) fmt.Printf(" Branch: %s\n", style.Dim.Render(p.Branch)) return nil } func runPolecatRemove(cmd *cobra.Command, args []string) error { // Build list of polecats to remove type polecatToRemove struct { rigName string polecatName string mgr *polecat.Manager r *rig.Rig } var toRemove []polecatToRemove if polecatRemoveAll { // --all flag: first arg is just the rig name rigName := args[0] // Check if it looks like rig/polecat format if _, _, err := parseAddress(rigName); err == nil { return fmt.Errorf("with --all, provide just the rig name (e.g., 'gt polecat remove gastown --all')") } mgr, r, err := getPolecatManager(rigName) if err != nil { return err } polecats, err := mgr.List() if err != nil { return fmt.Errorf("listing polecats: %w", err) } if len(polecats) == 0 { fmt.Println("No polecats to remove.") return nil } for _, p := range polecats { toRemove = append(toRemove, polecatToRemove{ rigName: rigName, polecatName: p.Name, mgr: mgr, r: r, }) } } else { // Multiple rig/polecat arguments for _, arg := range args { rigName, polecatName, err := parseAddress(arg) if err != nil { return fmt.Errorf("invalid address '%s': %w", arg, err) } mgr, r, err := getPolecatManager(rigName) if err != nil { return err } toRemove = append(toRemove, polecatToRemove{ rigName: rigName, polecatName: polecatName, mgr: mgr, r: r, }) } } // Remove each polecat t := tmux.NewTmux() var removeErrors []string removed := 0 for _, p := range toRemove { // Check if session is running if !polecatForce { sessMgr := session.NewManager(t, p.r) running, _ := sessMgr.IsRunning(p.polecatName) if running { removeErrors = append(removeErrors, fmt.Sprintf("%s/%s: session is running (stop first or use --force)", p.rigName, p.polecatName)) continue } } fmt.Printf("Removing polecat %s/%s...\n", p.rigName, p.polecatName) if err := p.mgr.Remove(p.polecatName, polecatForce); err != nil { if errors.Is(err, polecat.ErrHasChanges) { removeErrors = append(removeErrors, fmt.Sprintf("%s/%s: has uncommitted changes (use --force)", p.rigName, p.polecatName)) } else { removeErrors = append(removeErrors, fmt.Sprintf("%s/%s: %v", p.rigName, p.polecatName, err)) } continue } fmt.Printf(" %s removed\n", style.Success.Render("✓")) removed++ } // Report results if len(removeErrors) > 0 { fmt.Printf("\n%s Some removals failed:\n", style.Warning.Render("Warning:")) for _, e := range removeErrors { fmt.Printf(" - %s\n", e) } } if removed > 0 { fmt.Printf("\n%s Removed %d polecat(s).\n", style.SuccessPrefix, removed) } if len(removeErrors) > 0 { return fmt.Errorf("%d removal(s) failed", len(removeErrors)) } return nil } func runPolecatWake(cmd *cobra.Command, args []string) error { fmt.Println(style.Warning.Render("DEPRECATED: Use 'gt spawn' to create fresh polecats instead")) fmt.Println() rigName, polecatName, err := parseAddress(args[0]) if err != nil { return err } mgr, _, err := getPolecatManager(rigName) if err != nil { return err } if err := mgr.Wake(polecatName); err != nil { return fmt.Errorf("waking polecat: %w", err) } fmt.Printf("%s Polecat %s is now working.\n", style.SuccessPrefix, polecatName) return nil } func runPolecatSleep(cmd *cobra.Command, args []string) error { fmt.Println(style.Warning.Render("DEPRECATED: Use 'gt handoff' from within a polecat session instead")) fmt.Println() rigName, polecatName, err := parseAddress(args[0]) if err != nil { return err } mgr, r, err := getPolecatManager(rigName) if err != nil { return err } // Check if session is running t := tmux.NewTmux() sessMgr := session.NewManager(t, r) running, _ := sessMgr.IsRunning(polecatName) if running { return fmt.Errorf("session is running. Use 'gt handoff' from the polecat session, or stop it with: gt session stop %s/%s", rigName, polecatName) } if err := mgr.Sleep(polecatName); err != nil { return fmt.Errorf("marking polecat as done: %w", err) } fmt.Printf("%s Polecat %s is now done.\n", style.SuccessPrefix, polecatName) return nil } func runPolecatDone(cmd *cobra.Command, args []string) error { rigName, polecatName, err := parseAddress(args[0]) if err != nil { return err } mgr, r, err := getPolecatManager(rigName) if err != nil { return err } // Check if session is running t := tmux.NewTmux() sessMgr := session.NewManager(t, r) running, _ := sessMgr.IsRunning(polecatName) if running { return fmt.Errorf("session is running. Stop it first with: gt session stop %s/%s", rigName, polecatName) } if err := mgr.Finish(polecatName); err != nil { return fmt.Errorf("finishing polecat: %w", err) } fmt.Printf("%s Polecat %s is now idle.\n", style.SuccessPrefix, polecatName) return nil } func runPolecatReset(cmd *cobra.Command, args []string) error { rigName, polecatName, err := parseAddress(args[0]) if err != nil { return err } mgr, r, err := getPolecatManager(rigName) if err != nil { return err } // Check if session is running t := tmux.NewTmux() sessMgr := session.NewManager(t, r) running, _ := sessMgr.IsRunning(polecatName) if running { return fmt.Errorf("session is running. Stop it first with: gt session stop %s/%s", rigName, polecatName) } if err := mgr.Reset(polecatName); err != nil { return fmt.Errorf("resetting polecat: %w", err) } fmt.Printf("%s Polecat %s has been reset to idle.\n", style.SuccessPrefix, polecatName) return nil } func runPolecatSync(cmd *cobra.Command, args []string) error { if len(args) < 1 { return fmt.Errorf("rig or rig/polecat address required") } // Parse address - could be "rig" or "rig/polecat" rigName, polecatName, err := parseAddress(args[0]) if err != nil { // Might just be a rig name rigName = args[0] polecatName = "" } mgr, r, err := getPolecatManager(rigName) if err != nil { return err } // Get list of polecats to sync var polecatsToSync []string if polecatSyncAll || polecatName == "" { polecats, err := mgr.List() if err != nil { return fmt.Errorf("listing polecats: %w", err) } for _, p := range polecats { polecatsToSync = append(polecatsToSync, p.Name) } } else { polecatsToSync = []string{polecatName} } if len(polecatsToSync) == 0 { fmt.Println("No polecats to sync.") return nil } // Sync each polecat var syncErrors []string for _, name := range polecatsToSync { polecatDir := filepath.Join(r.Path, "polecats", name) // Check directory exists if _, err := os.Stat(polecatDir); os.IsNotExist(err) { syncErrors = append(syncErrors, fmt.Sprintf("%s: directory not found", name)) continue } // Build sync command syncArgs := []string{"sync"} if polecatSyncFromMain { syncArgs = append(syncArgs, "--from-main") } fmt.Printf("Syncing %s/%s...\n", rigName, name) syncCmd := exec.Command("bd", syncArgs...) syncCmd.Dir = polecatDir output, err := syncCmd.CombinedOutput() if err != nil { syncErrors = append(syncErrors, fmt.Sprintf("%s: %v", name, err)) if len(output) > 0 { fmt.Printf(" %s\n", style.Dim.Render(string(output))) } } else { fmt.Printf(" %s\n", style.Success.Render("✓ synced")) } } if len(syncErrors) > 0 { fmt.Printf("\n%s Some syncs failed:\n", style.Warning.Render("Warning:")) for _, e := range syncErrors { fmt.Printf(" - %s\n", e) } return fmt.Errorf("%d sync(s) failed", len(syncErrors)) } return nil } // PolecatStatus represents detailed polecat status for JSON output. type PolecatStatus struct { Rig string `json:"rig"` Name string `json:"name"` State polecat.State `json:"state"` Issue string `json:"issue,omitempty"` ClonePath string `json:"clone_path"` Branch string `json:"branch"` SessionRunning bool `json:"session_running"` SessionID string `json:"session_id,omitempty"` Attached bool `json:"attached,omitempty"` Windows int `json:"windows,omitempty"` CreatedAt string `json:"created_at,omitempty"` LastActivity string `json:"last_activity,omitempty"` } func runPolecatStatus(cmd *cobra.Command, args []string) error { rigName, polecatName, err := parseAddress(args[0]) if err != nil { return err } mgr, r, err := getPolecatManager(rigName) if err != nil { return err } // Get polecat info p, err := mgr.Get(polecatName) if err != nil { return fmt.Errorf("polecat '%s' not found in rig '%s'", polecatName, rigName) } // Get session info t := tmux.NewTmux() sessMgr := session.NewManager(t, r) sessInfo, err := sessMgr.Status(polecatName) if err != nil { // Non-fatal - continue without session info sessInfo = &session.Info{ Polecat: polecatName, Running: false, } } // JSON output if polecatStatusJSON { status := PolecatStatus{ Rig: rigName, Name: polecatName, State: p.State, Issue: p.Issue, ClonePath: p.ClonePath, Branch: p.Branch, SessionRunning: sessInfo.Running, SessionID: sessInfo.SessionID, Attached: sessInfo.Attached, Windows: sessInfo.Windows, } if !sessInfo.Created.IsZero() { status.CreatedAt = sessInfo.Created.Format("2006-01-02 15:04:05") } if !sessInfo.LastActivity.IsZero() { status.LastActivity = sessInfo.LastActivity.Format("2006-01-02 15:04:05") } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(status) } // Human-readable output fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Polecat: %s/%s", rigName, polecatName))) // State with color stateStr := string(p.State) switch p.State { case polecat.StateWorking: stateStr = style.Info.Render(stateStr) case polecat.StateStuck: stateStr = style.Warning.Render(stateStr) case polecat.StateDone: stateStr = style.Success.Render(stateStr) default: stateStr = style.Dim.Render(stateStr) } fmt.Printf(" State: %s\n", stateStr) // Issue if p.Issue != "" { fmt.Printf(" Issue: %s\n", p.Issue) } else { fmt.Printf(" Issue: %s\n", style.Dim.Render("(none)")) } // Clone path and branch fmt.Printf(" Clone: %s\n", style.Dim.Render(p.ClonePath)) fmt.Printf(" Branch: %s\n", style.Dim.Render(p.Branch)) // Session info fmt.Println() fmt.Printf("%s\n", style.Bold.Render("Session")) if sessInfo.Running { fmt.Printf(" Status: %s\n", style.Success.Render("running")) fmt.Printf(" Session ID: %s\n", style.Dim.Render(sessInfo.SessionID)) if sessInfo.Attached { fmt.Printf(" Attached: %s\n", style.Info.Render("yes")) } else { fmt.Printf(" Attached: %s\n", style.Dim.Render("no")) } if sessInfo.Windows > 0 { fmt.Printf(" Windows: %d\n", sessInfo.Windows) } if !sessInfo.Created.IsZero() { fmt.Printf(" Created: %s\n", sessInfo.Created.Format("2006-01-02 15:04:05")) } if !sessInfo.LastActivity.IsZero() { // Show relative time for activity ago := formatActivityTime(sessInfo.LastActivity) fmt.Printf(" Last Activity: %s (%s)\n", sessInfo.LastActivity.Format("15:04:05"), style.Dim.Render(ago)) } } else { fmt.Printf(" Status: %s\n", style.Dim.Render("not running")) } return nil } // formatActivityTime returns a human-readable relative time string. func formatActivityTime(t time.Time) string { d := time.Since(t) switch { case d < time.Minute: return fmt.Sprintf("%d seconds ago", int(d.Seconds())) case d < time.Hour: return fmt.Sprintf("%d minutes ago", int(d.Minutes())) case d < 24*time.Hour: return fmt.Sprintf("%d hours ago", int(d.Hours())) default: return fmt.Sprintf("%d days ago", int(d.Hours()/24)) } } // GitState represents the git state of a polecat's worktree. type GitState struct { Clean bool `json:"clean"` UncommittedFiles []string `json:"uncommitted_files"` UnpushedCommits int `json:"unpushed_commits"` StashCount int `json:"stash_count"` } func runPolecatGitState(cmd *cobra.Command, args []string) error { rigName, polecatName, err := parseAddress(args[0]) if err != nil { return err } mgr, r, err := getPolecatManager(rigName) if err != nil { return err } // Verify polecat exists p, err := mgr.Get(polecatName) if err != nil { return fmt.Errorf("polecat '%s' not found in rig '%s'", polecatName, rigName) } // Get git state from the polecat's worktree state, err := getGitState(p.ClonePath) if err != nil { return fmt.Errorf("getting git state: %w", err) } // JSON output if polecatGitStateJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(state) } // Human-readable output fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Git State: %s/%s", r.Name, polecatName))) // Working tree status if len(state.UncommittedFiles) == 0 { fmt.Printf(" Working Tree: %s\n", style.Success.Render("clean")) } else { fmt.Printf(" Working Tree: %s\n", style.Warning.Render("dirty")) fmt.Printf(" Uncommitted: %s\n", style.Warning.Render(fmt.Sprintf("%d files", len(state.UncommittedFiles)))) for _, f := range state.UncommittedFiles { fmt.Printf(" %s\n", style.Dim.Render(f)) } } // Unpushed commits if state.UnpushedCommits == 0 { fmt.Printf(" Unpushed: %s\n", style.Success.Render("0 commits")) } else { fmt.Printf(" Unpushed: %s\n", style.Warning.Render(fmt.Sprintf("%d commits ahead", state.UnpushedCommits))) } // Stashes if state.StashCount == 0 { fmt.Printf(" Stashes: %s\n", style.Dim.Render("0")) } else { fmt.Printf(" Stashes: %s\n", style.Warning.Render(fmt.Sprintf("%d", state.StashCount))) } // Verdict fmt.Println() if state.Clean { fmt.Printf(" Verdict: %s\n", style.Success.Render("CLEAN (safe to kill)")) } else { fmt.Printf(" Verdict: %s\n", style.Error.Render("DIRTY (needs cleanup)")) } return nil } // getGitState checks the git state of a worktree. func getGitState(worktreePath string) (*GitState, error) { state := &GitState{ Clean: true, UncommittedFiles: []string{}, } // Check for uncommitted changes (git status --porcelain) statusCmd := exec.Command("git", "status", "--porcelain") statusCmd.Dir = worktreePath output, err := statusCmd.Output() if err != nil { return nil, fmt.Errorf("git status: %w", err) } if len(output) > 0 { lines := splitLines(string(output)) for _, line := range lines { if line != "" { // Extract filename (skip the status prefix) if len(line) > 3 { state.UncommittedFiles = append(state.UncommittedFiles, line[3:]) } else { state.UncommittedFiles = append(state.UncommittedFiles, line) } } } state.Clean = false } // Check for unpushed commits (git log origin/main..HEAD) logCmd := exec.Command("git", "log", "origin/main..HEAD", "--oneline") logCmd.Dir = worktreePath output, err = logCmd.Output() if err != nil { // origin/main might not exist - try origin/master logCmd = exec.Command("git", "log", "origin/master..HEAD", "--oneline") logCmd.Dir = worktreePath output, _ = logCmd.Output() // Ignore error - might be a new repo } if len(output) > 0 { lines := splitLines(string(output)) count := 0 for _, line := range lines { if line != "" { count++ } } state.UnpushedCommits = count if count > 0 { state.Clean = false } } // Check for stashes (git stash list) stashCmd := exec.Command("git", "stash", "list") stashCmd.Dir = worktreePath output, err = stashCmd.Output() if err != nil { // Ignore stash errors output = nil } if len(output) > 0 { lines := splitLines(string(output)) count := 0 for _, line := range lines { if line != "" { count++ } } state.StashCount = count if count > 0 { state.Clean = false } } return state, nil } // splitLines splits a string into non-empty lines. func splitLines(s string) []string { var lines []string for _, line := range filepath.SplitList(s) { if line != "" { lines = append(lines, line) } } // filepath.SplitList doesn't work for newlines, use strings.Split instead lines = nil for _, line := range strings.Split(s, "\n") { lines = append(lines, line) } return lines }