From 184e14f010dc4fab5cc27526b5f00abd8364a314 Mon Sep 17 00:00:00 2001 From: Mark C Allen Date: Sun, 1 Mar 2026 08:48:10 +0000 Subject: [PATCH] feat(git-cleaner): add config, json output, and cleanup UX flags --- git-cleaner/README.md | 24 ++- git-cleaner/go.mod | 5 +- git-cleaner/go.sum | 4 + git-cleaner/main.go | 361 +++++++++++++++++++++++++++++++-------- git-cleaner/main_test.go | 59 ++++++- 5 files changed, 373 insertions(+), 80 deletions(-) diff --git a/git-cleaner/README.md b/git-cleaner/README.md index d169ca8..4d628f9 100644 --- a/git-cleaner/README.md +++ b/git-cleaner/README.md @@ -15,6 +15,8 @@ A cross-platform Go CLI tool that scans directories for `.git` directories, repo - **Size reporting**: Shows disk usage for each repository's `.git` directory - **Optimization**: Runs `git gc` to optimize repositories and shows disk savings - **Table output**: Clean, formatted table showing repository paths and sizes +- **JSON output**: Machine-readable output for scripts and CI +- **Config support**: Optional YAML config with default scan path (`--init`, `--config`) ## Installation @@ -59,8 +61,14 @@ make build-darwin # macOS builds only | Flag | Description | |------|-------------| -| `--scan PATH` | Directory to scan for .git directories (required) | +| `--scan PATH` | Directory to scan for .git directories (overrides config) | | `--clean` | Run `git gc` in each repository and show disk savings | +| `--yes` | Skip cleanup confirmation prompt | +| `--json` | Output structured JSON (no table/prose on stdout) | +| `--show-pct` | Add `.git` size as % of total repo size | +| `--config PATH` | Config path (default: `~/.config/git-cleaner/config.yaml`) | +| `--init` | Write starter config and exit | +| `--force` | Overwrite existing config when used with `--init` | ## Examples @@ -76,6 +84,20 @@ make build-darwin # macOS builds only ./build/git-cleaner --scan ~/projects --clean ``` +### Initialize config and scan with defaults + +```bash +./build/git-cleaner --init +./build/git-cleaner +``` + +### JSON output for automation + +```bash +./build/git-cleaner --scan ~/projects --json +./build/git-cleaner --scan ~/projects --clean --yes --json +``` + This will: 1. Scan for all `.git` directories 2. Display their sizes in a table diff --git a/git-cleaner/go.mod b/git-cleaner/go.mod index edae06b..4de9530 100644 --- a/git-cleaner/go.mod +++ b/git-cleaner/go.mod @@ -2,7 +2,10 @@ module git-cleaner go 1.21 -require github.com/olekukonko/tablewriter v1.1.3 +require ( + github.com/olekukonko/tablewriter v1.1.3 + gopkg.in/yaml.v3 v3.0.1 +) require ( github.com/clipperhouse/displaywidth v0.6.2 // indirect diff --git a/git-cleaner/go.sum b/git-cleaner/go.sum index 138ef30..658dbd6 100644 --- a/git-cleaner/go.sum +++ b/git-cleaner/go.sum @@ -23,3 +23,7 @@ github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/git-cleaner/main.go b/git-cleaner/main.go index c02c8b2..2441b6d 100644 --- a/git-cleaner/main.go +++ b/git-cleaner/main.go @@ -1,17 +1,21 @@ package main import ( + "bufio" + "encoding/json" "flag" "fmt" "io/fs" "os" "os/exec" "path/filepath" + "runtime" "sort" "strings" "time" "github.com/olekukonko/tablewriter" + yaml "gopkg.in/yaml.v3" ) // ----- Version info ----- @@ -23,20 +27,64 @@ var ( // ----- CLI flags ----- var ( - flagScan = flag.String("scan", "", "Directory to scan for .git directories") - flagClean = flag.Bool("clean", false, "Run git gc in each repository and show disk savings") + flagScan = flag.String("scan", "", "Directory to scan for .git directories (overrides config default)") + flagClean = flag.Bool("clean", false, "Run git gc in each repository and show disk savings") + flagJSON = flag.Bool("json", false, "Output results as JSON") + flagConfig = flag.String("config", defaultConfigPath(), "Path to YAML config") + flagInit = flag.Bool("init", false, "Write a starter config to --config and exit") + flagForce = flag.Bool("force", false, "Force overwrite existing config (use with --init)") + flagYes = flag.Bool("yes", false, "Skip confirmation prompt for cleanup") + flagShowPct = flag.Bool("show-pct", false, "Show .git size as percentage of repository size") ) -// ----- Finding types ----- +// ----- Config types ----- + +type Config struct { + Version int `yaml:"version"` + Options Options `yaml:"options"` +} + +type Options struct { + DefaultScanPath string `yaml:"defaultScanPath"` +} + +// ----- Finding/report types ----- type Finding struct { - Path string `json:"path"` - RepoPath string `json:"repo_path"` // Parent directory containing .git - SizeBytes int64 `json:"size_bytes"` - Items int `json:"items"` + Path string `json:"path"` + RepoPath string `json:"repo_path"` + SizeBytes int64 `json:"size_bytes"` + Items int `json:"items"` + RepoTotalBytes int64 `json:"repo_total_bytes,omitempty"` + GitPercent float64 `json:"git_percent,omitempty"` +} + +type Report struct { + Hostname string `json:"hostname,omitempty"` + OS string `json:"os"` + Arch string `json:"arch"` + When time.Time `json:"when"` + ScanPath string `json:"scan_path"` + DryRun bool `json:"dry_run"` + ShowPct bool `json:"show_pct"` + TotalBytes int64 `json:"total_bytes"` + TotalAfterBytes int64 `json:"total_after_bytes,omitempty"` + DiskSavingsBytes int64 `json:"disk_savings_bytes,omitempty"` + DiskSavingsPercent float64 `json:"disk_savings_percent,omitempty"` + CleanedRepos int `json:"cleaned_repos,omitempty"` + Findings []Finding `json:"findings"` + Warnings []string `json:"warnings,omitempty"` +} + +func defaultConfigPath() string { + h, _ := os.UserHomeDir() + if h == "" { + return "./config.yaml" + } + return filepath.Join(h, ".config", "git-cleaner", "config.yaml") } -// ----- Utilities ----- +func ensureDir(p string) error { return os.MkdirAll(filepath.Dir(p), 0o755) } func human(n int64) string { if n < 1024 { @@ -73,7 +121,7 @@ func inspectPath(root string) (Finding, error) { f.SizeBytes = fi.Size() return f, nil } - errWalk := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + errWalk := filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error { if err != nil { return nil } @@ -91,7 +139,6 @@ func inspectPath(root string) (Finding, error) { return f, errWalk } -// scanDirectory walks through the directory tree and finds all .git directories func scanDirectory(root string) []Finding { var findings []Finding @@ -103,60 +150,72 @@ func scanDirectory(root string) []Finding { err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { - return nil // Continue on errors + return nil } - if !d.IsDir() { return nil } - - // Check if this directory is named .git if d.Name() == ".git" { - // Get the repository path (parent of .git) repoPath := filepath.Dir(path) - - // Inspect the .git directory f, err := inspectPath(path) if err != nil { return nil } f.RepoPath = repoPath findings = append(findings, f) - - // Skip subdirectories of .git return filepath.SkipDir } - return nil }) if err != nil { - // Log error but continue - return what we found so far fmt.Fprintf(os.Stderr, "Warning: error walking directory: %v\n", err) } return findings } -// runGitGC runs git gc in the specified repository directory -func runGitGC(repoPath string) error { +func enrichWithRepoPercent(findings []Finding) []string { + var warnings []string + for i := range findings { + repo, err := inspectPath(findings[i].RepoPath) + if err != nil { + warnings = append(warnings, fmt.Sprintf("failed repo size for %s: %v", findings[i].RepoPath, err)) + continue + } + findings[i].RepoTotalBytes = repo.SizeBytes + if repo.SizeBytes > 0 { + findings[i].GitPercent = (float64(findings[i].SizeBytes) / float64(repo.SizeBytes)) * 100 + } + } + return warnings +} + +func runGitGC(repoPath string, jsonMode bool) error { cmd := exec.Command("git", "gc") cmd.Dir = repoPath - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + if jsonMode { + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + } else { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } return cmd.Run() } -// displayResults displays findings in a table -func displayResults(findings []Finding, total int64) { +func displayResults(findings []Finding, total int64, showPct bool) { if len(findings) == 0 { fmt.Println("No .git directories found.") return } table := tablewriter.NewWriter(os.Stdout) - table.Header("Repository Path", ".git Size", "Items") + if showPct { + table.Header("Repository Path", ".git Size", "Items", "Git %") + } else { + table.Header("Repository Path", ".git Size", "Items") + } - // Sort by size (largest first) sortedFindings := make([]Finding, len(findings)) copy(sortedFindings, findings) sort.Slice(sortedFindings, func(i, j int) bool { @@ -165,20 +224,28 @@ func displayResults(findings []Finding, total int64) { var totalItems int for _, f := range sortedFindings { - if err := table.Append(f.RepoPath, human(f.SizeBytes), fmt.Sprintf("%d", f.Items)); err != nil { - fmt.Fprintf(os.Stderr, "Warning: error appending to table: %v\n", err) + if showPct { + if err := table.Append(f.RepoPath, human(f.SizeBytes), fmt.Sprintf("%d", f.Items), fmt.Sprintf("%.2f%%", f.GitPercent)); err != nil { + fmt.Fprintf(os.Stderr, "Warning: error appending to table: %v\n", err) + } + } else { + if err := table.Append(f.RepoPath, human(f.SizeBytes), fmt.Sprintf("%d", f.Items)); err != nil { + fmt.Fprintf(os.Stderr, "Warning: error appending to table: %v\n", err) + } } totalItems += f.Items } - table.Footer("TOTAL", human(total), fmt.Sprintf("%d", totalItems)) + if showPct { + table.Footer("TOTAL", human(total), fmt.Sprintf("%d", totalItems), "") + } else { + table.Footer("TOTAL", human(total), fmt.Sprintf("%d", totalItems)) + } if err := table.Render(); err != nil { fmt.Fprintf(os.Stderr, "Error rendering table: %v\n", err) } } -// expandScanPath expands ~ and env vars, resolves to absolute path, and verifies it exists. -// Returns the expanded path or an error. func expandScanPath(scanPath string) (string, error) { if scanPath == "" { return "", fmt.Errorf("scan path is required") @@ -206,6 +273,65 @@ func expandScanPath(scanPath string) (string, error) { return abs, nil } +func loadConfig(path string) (*Config, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg Config + if err := yaml.Unmarshal(b, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func writeStarterConfig(path string, force bool) error { + if _, err := os.Stat(path); err == nil { + if !force { + return fmt.Errorf("config file already exists at %s. Use --force to overwrite", path) + } + backupPath := fmt.Sprintf("%s.%s", path, time.Now().Format("20060102-150405")) + if err := os.Rename(path, backupPath); err != nil { + return fmt.Errorf("failed to backup existing config: %w", err) + } + fmt.Printf("Existing config backed up to: %s\n", backupPath) + } + + starter := Config{ + Version: 1, + Options: Options{DefaultScanPath: "~/src"}, + } + if err := ensureDir(path); err != nil { + return err + } + b, err := yaml.Marshal(starter) + if err != nil { + return err + } + return os.WriteFile(path, b, 0o644) +} + +func resolveScanPath(flagValue string, cfg *Config) (string, error) { + if flagValue != "" { + return expandScanPath(flagValue) + } + if cfg != nil && cfg.Options.DefaultScanPath != "" { + return expandScanPath(cfg.Options.DefaultScanPath) + } + return "", fmt.Errorf("scan path is required") +} + +func confirmCleanup() bool { + fmt.Print("Proceed with git gc for all discovered repositories? [y/N]: ") + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + return false + } + line = strings.TrimSpace(strings.ToLower(line)) + return line == "y" || line == "yes" +} + func main() { if checkVersionFlag() { fmt.Printf("version %s, commit %s, built at %s\n", version, commit, date) @@ -214,75 +340,160 @@ func main() { flag.Parse() - scanPath, err := expandScanPath(*flagScan) + if *flagInit { + if err := writeStarterConfig(*flagConfig, *flagForce); err != nil { + fmt.Println("init error:", err) + os.Exit(1) + } + fmt.Println("Starter config written to:", *flagConfig) + return + } + + var cfg *Config + if loaded, err := loadConfig(*flagConfig); err == nil { + cfg = loaded + } else if !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Warning: failed to load config %s: %v\n", *flagConfig, err) + } + + scanPath, err := resolveScanPath(*flagScan, cfg) if err != nil { if err.Error() == "scan path is required" { - fmt.Println("Error: --scan flag is required") - fmt.Println("Usage: git-cleaner --scan [--clean]") + fmt.Println("Error: --scan flag is required (or configure options.defaultScanPath)") + fmt.Println("Usage: git-cleaner --scan [--clean] [--json]") } else { fmt.Printf("Error: %v\n", err) } os.Exit(1) } - fmt.Printf("Scanning %s for .git directories...\n", scanPath) - - // Initial scan findings := scanDirectory(scanPath) + if *flagShowPct { + _ = enrichWithRepoPercent(findings) + } var totalBefore int64 for _, f := range findings { totalBefore += f.SizeBytes } + report := Report{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + When: time.Now(), + ScanPath: scanPath, + DryRun: !*flagClean, + ShowPct: *flagShowPct, + TotalBytes: totalBefore, + Findings: findings, + } + if h, err := os.Hostname(); err == nil { + report.Hostname = h + } + if len(findings) == 0 { + if *flagJSON { + b, _ := json.MarshalIndent(report, "", " ") + fmt.Println(string(b)) + return + } fmt.Println("No .git directories found.") return } - // Display initial results - fmt.Printf("\nFound %d repositories:\n\n", len(findings)) - displayResults(findings, totalBefore) - - // Clean if requested - if *flagClean { - fmt.Printf("\nRunning git gc in %d repositories...\n", len(findings)) - var errors []string - var cleanedCount int - - for _, f := range findings { - fmt.Printf(" Cleaning %s...\n", f.RepoPath) - if err := runGitGC(f.RepoPath); err != nil { - errors = append(errors, fmt.Sprintf("%s: %v", f.RepoPath, err)) - continue + if *flagJSON { + if *flagClean { + if !*flagYes { + report.Warnings = append(report.Warnings, "cleanup skipped: confirmation required (use --yes with --clean in non-interactive mode)") + b, _ := json.MarshalIndent(report, "", " ") + fmt.Println(string(b)) + return } - cleanedCount++ - } - if len(errors) > 0 { - fmt.Println("\nErrors:") - for _, e := range errors { - fmt.Printf(" - %s\n", e) + var cleanedCount int + for _, f := range findings { + if err := runGitGC(f.RepoPath, true); err != nil { + report.Warnings = append(report.Warnings, fmt.Sprintf("%s: %v", f.RepoPath, err)) + continue + } + cleanedCount++ + } + + afterFindings := scanDirectory(scanPath) + if *flagShowPct { + _ = enrichWithRepoPercent(afterFindings) + } + var totalAfter int64 + for _, f := range afterFindings { + totalAfter += f.SizeBytes + } + report.TotalAfterBytes = totalAfter + report.DiskSavingsBytes = totalBefore - totalAfter + report.CleanedRepos = cleanedCount + report.Findings = afterFindings + if totalBefore > 0 { + report.DiskSavingsPercent = float64(report.DiskSavingsBytes) / float64(totalBefore) * 100 } } - // Rescan after cleanup - fmt.Println("\nRescanning after cleanup...") - time.Sleep(100 * time.Millisecond) // Brief pause to ensure file system updates - afterFindings := scanDirectory(scanPath) + b, _ := json.MarshalIndent(report, "", " ") + fmt.Println(string(b)) + return + } + + fmt.Printf("Scanning %s for .git directories...\n", scanPath) + fmt.Printf("\nFound %d repositories:\n\n", len(findings)) + displayResults(findings, totalBefore, *flagShowPct) + + if !*flagClean { + return + } + + if !*flagYes && !confirmCleanup() { + fmt.Println("Cleanup cancelled.") + return + } + + fmt.Printf("\nRunning git gc in %d repositories...\n", len(findings)) + var errors []string + var cleanedCount int + + for _, f := range findings { + fmt.Printf(" Cleaning %s...\n", f.RepoPath) + if err := runGitGC(f.RepoPath, false); err != nil { + errors = append(errors, fmt.Sprintf("%s: %v", f.RepoPath, err)) + continue + } + cleanedCount++ + } - var totalAfter int64 - for _, f := range afterFindings { - totalAfter += f.SizeBytes + if len(errors) > 0 { + fmt.Println("\nErrors:") + for _, e := range errors { + fmt.Printf(" - %s\n", e) } + } + + fmt.Println("\nRescanning after cleanup...") + time.Sleep(100 * time.Millisecond) + afterFindings := scanDirectory(scanPath) + if *flagShowPct { + _ = enrichWithRepoPercent(afterFindings) + } + + var totalAfter int64 + for _, f := range afterFindings { + totalAfter += f.SizeBytes + } - diskSavings := totalBefore - totalAfter - fmt.Printf("\nResults after cleanup:\n\n") - displayResults(afterFindings, totalAfter) + diskSavings := totalBefore - totalAfter + fmt.Printf("\nResults after cleanup:\n\n") + displayResults(afterFindings, totalAfter, *flagShowPct) - fmt.Printf("\nDisk savings: %s (%.2f%%)\n", - human(diskSavings), - float64(diskSavings)/float64(totalBefore)*100) - fmt.Printf("Cleaned %d repositories\n", cleanedCount) + pct := 0.0 + if totalBefore > 0 { + pct = float64(diskSavings) / float64(totalBefore) * 100 } + fmt.Printf("\nDisk savings: %s (%.2f%%)\n", human(diskSavings), pct) + fmt.Printf("Cleaned %d repositories\n", cleanedCount) } diff --git a/git-cleaner/main_test.go b/git-cleaner/main_test.go index 401ada3..a4f6fe0 100644 --- a/git-cleaner/main_test.go +++ b/git-cleaner/main_test.go @@ -283,7 +283,7 @@ func TestRunGitGC(t *testing.T) { t.Skip("git commit failed:", err) } - if err := runGitGC(dir); err != nil { + if err := runGitGC(dir, true); err != nil { t.Fatalf("runGitGC failed: %v", err) } } @@ -298,7 +298,7 @@ func TestDisplayResults(t *testing.T) { {Path: "/repo1/.git", RepoPath: "/repo1", SizeBytes: 1024, Items: 10}, {Path: "/repo2/.git", RepoPath: "/repo2", SizeBytes: 2048, Items: 5}, } - displayResults(findings, 3072) + displayResults(findings, 3072, false) _ = w.Close() var buf bytes.Buffer @@ -318,7 +318,7 @@ func TestDisplayResultsEmpty(t *testing.T) { os.Stdout = w defer func() { os.Stdout = old }() - displayResults([]Finding{}, 0) + displayResults([]Finding{}, 0, false) _ = w.Close() var buf bytes.Buffer _, _ = buf.ReadFrom(r) @@ -406,3 +406,56 @@ func TestExpandScanPath(t *testing.T) { t.Fatalf("expandScanPath($TEST_SCAN_DIR) = %q, want %q", got, abs) } } + +func TestResolveScanPath(t *testing.T) { + dir := t.TempDir() + + got, err := resolveScanPath(dir, nil) + if err != nil { + t.Fatalf("resolveScanPath from flag failed: %v", err) + } + if got == "" { + t.Fatal("expected non-empty scan path") + } + + cfg := &Config{ + Version: 1, + Options: Options{DefaultScanPath: dir}, + } + got, err = resolveScanPath("", cfg) + if err != nil { + t.Fatalf("resolveScanPath from config failed: %v", err) + } + if got == "" { + t.Fatal("expected non-empty scan path from config") + } + + _, err = resolveScanPath("", nil) + if err == nil { + t.Fatal("expected error when no scan path is provided") + } +} + +func TestWriteStarterConfigAndLoad(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + + if err := writeStarterConfig(cfgPath, false); err != nil { + t.Fatalf("writeStarterConfig failed: %v", err) + } + + cfg, err := loadConfig(cfgPath) + if err != nil { + t.Fatalf("loadConfig failed: %v", err) + } + if cfg.Version != 1 { + t.Fatalf("expected version 1, got %d", cfg.Version) + } + if cfg.Options.DefaultScanPath == "" { + t.Fatal("expected defaultScanPath in starter config") + } + + if err := writeStarterConfig(cfgPath, false); err == nil { + t.Fatal("expected error when writing config without --force") + } +}