gt polecat git-state: Pre-kill verification command (gt-lnji)
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 <rig>/<polecat> [--json] 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -194,8 +195,29 @@ var (
|
|||||||
polecatSyncAll bool
|
polecatSyncAll bool
|
||||||
polecatSyncFromMain bool
|
polecatSyncFromMain bool
|
||||||
polecatStatusJSON 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() {
|
func init() {
|
||||||
// List flags
|
// List flags
|
||||||
polecatListCmd.Flags().BoolVar(&polecatListJSON, "json", false, "Output as JSON")
|
polecatListCmd.Flags().BoolVar(&polecatListJSON, "json", false, "Output as JSON")
|
||||||
@@ -212,6 +234,9 @@ func init() {
|
|||||||
// Status flags
|
// Status flags
|
||||||
polecatStatusCmd.Flags().BoolVar(&polecatStatusJSON, "json", false, "Output as JSON")
|
polecatStatusCmd.Flags().BoolVar(&polecatStatusJSON, "json", false, "Output as JSON")
|
||||||
|
|
||||||
|
// Git-state flags
|
||||||
|
polecatGitStateCmd.Flags().BoolVar(&polecatGitStateJSON, "json", false, "Output as JSON")
|
||||||
|
|
||||||
// Add subcommands
|
// Add subcommands
|
||||||
polecatCmd.AddCommand(polecatListCmd)
|
polecatCmd.AddCommand(polecatListCmd)
|
||||||
polecatCmd.AddCommand(polecatAddCmd)
|
polecatCmd.AddCommand(polecatAddCmd)
|
||||||
@@ -222,6 +247,7 @@ func init() {
|
|||||||
polecatCmd.AddCommand(polecatResetCmd)
|
polecatCmd.AddCommand(polecatResetCmd)
|
||||||
polecatCmd.AddCommand(polecatSyncCmd)
|
polecatCmd.AddCommand(polecatSyncCmd)
|
||||||
polecatCmd.AddCommand(polecatStatusCmd)
|
polecatCmd.AddCommand(polecatStatusCmd)
|
||||||
|
polecatCmd.AddCommand(polecatGitStateCmd)
|
||||||
|
|
||||||
rootCmd.AddCommand(polecatCmd)
|
rootCmd.AddCommand(polecatCmd)
|
||||||
}
|
}
|
||||||
@@ -815,3 +841,174 @@ func formatActivityTime(t time.Time) string {
|
|||||||
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user