diff --git a/internal/cmd/orphans.go b/internal/cmd/orphans.go new file mode 100644 index 00000000..6f77d3c6 --- /dev/null +++ b/internal/cmd/orphans.go @@ -0,0 +1,239 @@ +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", + 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 # Apply to current branch")) + fmt.Printf("%s\n", style.Dim.Render(" git show # View full commit")) + fmt.Printf("%s\n", style.Dim.Render(" git branch rescue # 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 bytes.Buffer + fsckCmd.Stdout = &fsckOut + fsckCmd.Stderr = nil // Ignore warnings + + 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 { + 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 " + 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) +}