From 65cb10de1500be97fb7d4a88d34c48e806fe3087 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Mon, 22 Dec 2025 23:35:16 -0800 Subject: [PATCH] gt polecat git-state: Pre-kill verification command (gt-lnji) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add git-state subcommand to gt polecat for Witness pre-kill verification. Checks working tree, unpushed commits, and stashes to determine if a polecat worktree is clean (safe to kill) or dirty (needs cleanup). Usage: gt polecat git-state / [--json] 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/polecat.go | 197 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/internal/cmd/polecat.go b/internal/cmd/polecat.go index 3fbd54dd..7812eade 100644 --- a/internal/cmd/polecat.go +++ b/internal/cmd/polecat.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "time" "github.com/spf13/cobra" @@ -194,8 +195,29 @@ 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") @@ -212,6 +234,9 @@ func init() { // 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) @@ -222,6 +247,7 @@ func init() { polecatCmd.AddCommand(polecatResetCmd) polecatCmd.AddCommand(polecatSyncCmd) polecatCmd.AddCommand(polecatStatusCmd) + polecatCmd.AddCommand(polecatGitStateCmd) rootCmd.AddCommand(polecatCmd) } @@ -815,3 +841,174 @@ func formatActivityTime(t time.Time) string { 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 +}