Several files were setting cmd.Stderr = nil, which hides potentially critical error messages: - prime.go: bd prime, gt mail check, and bd show commands now log stderr on failure for debugging - orphans.go: git fsck now includes stderr in error messages - patrol_helpers.go: bd list/show/catalog commands now log stderr Daemon launch cases (up.go, daemon.go, daemon_check.go) correctly use nil for I/O detachment but now have clarifying comments. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
246 lines
6.3 KiB
Go
246 lines
6.3 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"fmt"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/gastown/internal/style"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
)
|
|
|
|
var orphansCmd = &cobra.Command{
|
|
Use: "orphans",
|
|
GroupID: GroupWork,
|
|
Short: "Find lost polecat work",
|
|
Long: `Find orphaned commits that were never merged to main.
|
|
|
|
Polecat work can get lost when:
|
|
- Session killed before merge
|
|
- Refinery fails to process
|
|
- Network issues during push
|
|
|
|
This command uses 'git fsck --unreachable' to find dangling commits,
|
|
filters to recent ones, and shows details to help recovery.
|
|
|
|
Examples:
|
|
gt orphans # Last 7 days (default)
|
|
gt orphans --days=14 # Last 2 weeks
|
|
gt orphans --all # Show all orphans (no date filter)`,
|
|
RunE: runOrphans,
|
|
}
|
|
|
|
var (
|
|
orphansDays int
|
|
orphansAll bool
|
|
)
|
|
|
|
func init() {
|
|
orphansCmd.Flags().IntVar(&orphansDays, "days", 7, "Show orphans from last N days")
|
|
orphansCmd.Flags().BoolVar(&orphansAll, "all", false, "Show all orphans (no date filter)")
|
|
|
|
rootCmd.AddCommand(orphansCmd)
|
|
}
|
|
|
|
// OrphanCommit represents an unreachable commit
|
|
type OrphanCommit struct {
|
|
SHA string
|
|
Date time.Time
|
|
Author string
|
|
Subject string
|
|
}
|
|
|
|
func runOrphans(cmd *cobra.Command, args []string) error {
|
|
// Find workspace to determine rig root
|
|
townRoot, err := workspace.FindFromCwdOrError()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
// Find current rig
|
|
rigName, r, err := findCurrentRig(townRoot)
|
|
if err != nil {
|
|
return fmt.Errorf("determining rig: %w", err)
|
|
}
|
|
|
|
// We need to run from the mayor's clone (main git repo for the rig)
|
|
mayorPath := r.Path + "/mayor/rig"
|
|
|
|
fmt.Printf("Scanning for orphaned commits in %s...\n\n", rigName)
|
|
|
|
// Run git fsck
|
|
orphans, err := findOrphanCommits(mayorPath)
|
|
if err != nil {
|
|
return fmt.Errorf("finding orphans: %w", err)
|
|
}
|
|
|
|
if len(orphans) == 0 {
|
|
fmt.Printf("%s No orphaned commits found\n", style.Bold.Render("✓"))
|
|
return nil
|
|
}
|
|
|
|
// Filter by date unless --all
|
|
cutoff := time.Now().AddDate(0, 0, -orphansDays)
|
|
var filtered []OrphanCommit
|
|
|
|
for _, o := range orphans {
|
|
if orphansAll || o.Date.After(cutoff) {
|
|
filtered = append(filtered, o)
|
|
}
|
|
}
|
|
|
|
if len(filtered) == 0 {
|
|
fmt.Printf("%s No orphaned commits in the last %d days\n", style.Bold.Render("✓"), orphansDays)
|
|
fmt.Printf("%s Use --days=N or --all to see older orphans\n", style.Dim.Render("Hint:"))
|
|
return nil
|
|
}
|
|
|
|
// Display results
|
|
fmt.Printf("%s Found %d orphaned commit(s):\n\n", style.Warning.Render("⚠"), len(filtered))
|
|
|
|
for _, o := range filtered {
|
|
age := formatAge(o.Date)
|
|
fmt.Printf(" %s %s\n", style.Bold.Render(o.SHA[:8]), o.Subject)
|
|
fmt.Printf(" %s by %s\n\n", style.Dim.Render(age), o.Author)
|
|
}
|
|
|
|
// Recovery hints
|
|
fmt.Printf("%s\n", style.Dim.Render("To recover a commit:"))
|
|
fmt.Printf("%s\n", style.Dim.Render(" git cherry-pick <sha> # Apply to current branch"))
|
|
fmt.Printf("%s\n", style.Dim.Render(" git show <sha> # View full commit"))
|
|
fmt.Printf("%s\n", style.Dim.Render(" git branch rescue <sha> # Create branch from commit"))
|
|
|
|
return nil
|
|
}
|
|
|
|
// findOrphanCommits runs git fsck and parses orphaned commits
|
|
func findOrphanCommits(repoPath string) ([]OrphanCommit, error) {
|
|
// Run git fsck to find unreachable objects
|
|
fsckCmd := exec.Command("git", "fsck", "--unreachable", "--no-reflogs")
|
|
fsckCmd.Dir = repoPath
|
|
|
|
var fsckOut, fsckErr bytes.Buffer
|
|
fsckCmd.Stdout = &fsckOut
|
|
fsckCmd.Stderr = &fsckErr
|
|
|
|
if err := fsckCmd.Run(); err != nil {
|
|
// git fsck returns non-zero if there are issues, but we still get output
|
|
// Only fail if we got no output at all
|
|
if fsckOut.Len() == 0 {
|
|
// Include stderr in error message for debugging
|
|
errMsg := strings.TrimSpace(fsckErr.String())
|
|
if errMsg != "" {
|
|
return nil, fmt.Errorf("git fsck failed: %w (%s)", err, errMsg)
|
|
}
|
|
return nil, fmt.Errorf("git fsck failed: %w", err)
|
|
}
|
|
}
|
|
|
|
// Parse commit SHAs from output
|
|
var commitSHAs []string
|
|
scanner := bufio.NewScanner(&fsckOut)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
// Format: "unreachable commit <sha>"
|
|
if strings.HasPrefix(line, "unreachable commit ") {
|
|
sha := strings.TrimPrefix(line, "unreachable commit ")
|
|
commitSHAs = append(commitSHAs, sha)
|
|
}
|
|
}
|
|
|
|
if len(commitSHAs) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Get details for each commit
|
|
var orphans []OrphanCommit
|
|
for _, sha := range commitSHAs {
|
|
commit, err := getCommitDetails(repoPath, sha)
|
|
if err != nil {
|
|
continue // Skip commits we can't parse
|
|
}
|
|
|
|
// Skip stash-like and routine sync commits
|
|
if isNoiseCommit(commit.Subject) {
|
|
continue
|
|
}
|
|
|
|
orphans = append(orphans, commit)
|
|
}
|
|
|
|
return orphans, nil
|
|
}
|
|
|
|
// getCommitDetails retrieves commit metadata
|
|
func getCommitDetails(repoPath, sha string) (OrphanCommit, error) {
|
|
// Format: timestamp|author|subject
|
|
cmd := exec.Command("git", "log", "-1", "--format=%at|%an|%s", sha)
|
|
cmd.Dir = repoPath
|
|
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return OrphanCommit{}, err
|
|
}
|
|
|
|
parts := strings.SplitN(strings.TrimSpace(string(out)), "|", 3)
|
|
if len(parts) < 3 {
|
|
return OrphanCommit{}, fmt.Errorf("unexpected format")
|
|
}
|
|
|
|
timestamp, err := strconv.ParseInt(parts[0], 10, 64)
|
|
if err != nil {
|
|
return OrphanCommit{}, err
|
|
}
|
|
|
|
return OrphanCommit{
|
|
SHA: sha,
|
|
Date: time.Unix(timestamp, 0),
|
|
Author: parts[1],
|
|
Subject: parts[2],
|
|
}, nil
|
|
}
|
|
|
|
// isNoiseCommit returns true for stash-related or routine sync commits
|
|
func isNoiseCommit(subject string) bool {
|
|
// Git stash creates commits with these prefixes
|
|
noisePrefixes := []string{
|
|
"WIP on ",
|
|
"index on ",
|
|
"On ", // "On branch: message"
|
|
"stash@{", // Direct stash reference
|
|
"untracked files ", // Stash with untracked
|
|
"bd sync:", // Beads sync commits (routine)
|
|
"bd sync: ", // Beads sync commits (routine)
|
|
}
|
|
|
|
for _, prefix := range noisePrefixes {
|
|
if strings.HasPrefix(subject, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// formatAge returns a human-readable age string
|
|
func formatAge(t time.Time) string {
|
|
d := time.Since(t)
|
|
|
|
if d < time.Hour {
|
|
return fmt.Sprintf("%d minutes ago", int(d.Minutes()))
|
|
}
|
|
if d < 24*time.Hour {
|
|
return fmt.Sprintf("%d hours ago", int(d.Hours()))
|
|
}
|
|
days := int(d.Hours() / 24)
|
|
if days == 1 {
|
|
return "1 day ago"
|
|
}
|
|
return fmt.Sprintf("%d days ago", days)
|
|
}
|