Organize 43 commands into 7 logical groups using cobra's built-in AddGroup/GroupID feature: - Work Management: spawn, sling, hook, handoff, done, mol, mq, etc. - Agent Management: mayor, witness, refinery, deacon, polecat, etc. - Communication: mail, nudge, broadcast, peek - Services: daemon, start, stop, up, down, shutdown - Workspace: rig, crew, init, install, git-init, namepool - Configuration: account, theme, hooks, issue, completion - Diagnostics: status, doctor, prime, version, help Also renamed molecule to mol as the primary command name (molecule is now an alias). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1016 lines
26 KiB
Go
1016 lines
26 KiB
Go
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 <rig> <name>",
|
|
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 <rig>/<polecat>... | <rig> --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 <rig>/<polecat>",
|
|
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 <rig>/<polecat>",
|
|
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 <rig>/<polecat>",
|
|
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 <rig>/<polecat>",
|
|
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 <rig>/<polecat>",
|
|
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 <rig>/<polecat>",
|
|
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 <rig>/<polecat>",
|
|
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
|
|
}
|