feat(compact): add --dolt flag for Dolt garbage collection

Add `bd compact --dolt` command to run Dolt garbage collection on
.beads/dolt directory. This helps reclaim disk space when using the
Dolt backend, where auto-commit per mutation causes commit history
to grow over time.

Features:
- Runs `dolt gc` in the .beads/dolt directory
- Shows disk space before/after with bytes freed
- Supports --dry-run to preview without running GC
- Supports --json for machine-readable output
- Helpful error messages for missing dolt directory or command

Closes: hq-ew1mbr.14

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
opal
2026-01-17 01:54:38 -08:00
committed by gastown/crew/dennis
parent ab5f507c66
commit ca24c17af8
2 changed files with 154 additions and 2 deletions

View File

@@ -71,6 +71,7 @@ func init() {
compactAliasCmd.Flags().StringVar(&compactSummary, "summary", "", "Path to summary file (use '-' for stdin)") compactAliasCmd.Flags().StringVar(&compactSummary, "summary", "", "Path to summary file (use '-' for stdin)")
compactAliasCmd.Flags().StringVar(&compactActor, "actor", "agent", "Actor name for audit trail") compactAliasCmd.Flags().StringVar(&compactActor, "actor", "agent", "Actor name for audit trail")
compactAliasCmd.Flags().IntVar(&compactLimit, "limit", 0, "Limit number of candidates (0 = no limit)") compactAliasCmd.Flags().IntVar(&compactLimit, "limit", 0, "Limit number of candidates (0 = no limit)")
compactAliasCmd.Flags().BoolVar(&compactDolt, "dolt", false, "Dolt mode: run Dolt garbage collection on .beads/dolt")
// Reset alias flags - these read from cmd.Flags() in the Run function // Reset alias flags - these read from cmd.Flags() in the Run function
resetAliasCmd.Flags().Bool("force", false, "Actually perform the reset (required)") resetAliasCmd.Flags().Bool("force", false, "Actually perform the reset (required)")

View File

@@ -5,9 +5,12 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"os/exec"
"path/filepath"
"time" "time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/compact" "github.com/steveyegge/beads/internal/compact"
"github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/storage/sqlite"
) )
@@ -30,6 +33,7 @@ var (
compactActor string compactActor string
compactLimit int compactLimit int
compactOlderThan int compactOlderThan int
compactDolt bool
) )
var compactCmd = &cobra.Command{ var compactCmd = &cobra.Command{
@@ -45,6 +49,7 @@ Modes:
- Analyze: Export candidates for agent review (no API key needed) - Analyze: Export candidates for agent review (no API key needed)
- Apply: Accept agent-provided summary (no API key needed) - Apply: Accept agent-provided summary (no API key needed)
- Auto: AI-powered compaction (requires ANTHROPIC_API_KEY, legacy) - Auto: AI-powered compaction (requires ANTHROPIC_API_KEY, legacy)
- Dolt: Run Dolt garbage collection (for Dolt-backend repositories)
Tiers: Tiers:
- Tier 1: Semantic compression (30 days closed, 70% reduction) - Tier 1: Semantic compression (30 days closed, 70% reduction)
@@ -60,6 +65,13 @@ Tombstone Cleanup:
removes any tombstone that no open issues depend on, regardless of age. removes any tombstone that no open issues depend on, regardless of age.
Also cleans stale deps from closed issues to tombstones. Also cleans stale deps from closed issues to tombstones.
Dolt Garbage Collection:
With auto-commit per mutation, Dolt commit history grows over time. Use
--dolt to run Dolt garbage collection and reclaim disk space.
--dolt: Run Dolt GC on .beads/dolt directory to free disk space.
This removes unreachable commits and compacts storage.
Examples: Examples:
# Age-based pruning # Age-based pruning
bd compact --prune # Remove tombstones older than 30 days bd compact --prune # Remove tombstones older than 30 days
@@ -70,6 +82,10 @@ Examples:
bd compact --purge-tombstones --dry-run # Preview what would be purged bd compact --purge-tombstones --dry-run # Preview what would be purged
bd compact --purge-tombstones # Remove tombstones with no open deps bd compact --purge-tombstones # Remove tombstones with no open deps
# Dolt garbage collection
bd compact --dolt # Run Dolt GC
bd compact --dolt --dry-run # Preview without running GC
# Agent-driven workflow (recommended) # Agent-driven workflow (recommended)
bd compact --analyze --json # Get candidates with full content bd compact --analyze --json # Get candidates with full content
bd compact --apply --id bd-42 --summary summary.txt bd compact --apply --id bd-42 --summary summary.txt
@@ -84,8 +100,8 @@ Examples:
bd compact --stats # Show statistics bd compact --stats # Show statistics
`, `,
Run: func(_ *cobra.Command, _ []string) { Run: func(_ *cobra.Command, _ []string) {
// Compact modifies data unless --stats or --analyze or --dry-run // Compact modifies data unless --stats or --analyze or --dry-run or --dolt with --dry-run
if !compactStats && !compactAnalyze && !compactDryRun { if !compactStats && !compactAnalyze && !compactDryRun && !(compactDolt && compactDryRun) {
CheckReadonly("compact") CheckReadonly("compact")
} }
ctx := rootCtx ctx := rootCtx
@@ -105,6 +121,12 @@ Examples:
return return
} }
// Handle dolt GC mode
if compactDolt {
runCompactDolt()
return
}
// Handle prune mode (standalone tombstone pruning by age) // Handle prune mode (standalone tombstone pruning by age)
if compactPrune { if compactPrune {
runCompactPrune() runCompactPrune()
@@ -766,6 +788,134 @@ func runCompactApply(ctx context.Context, store *sqlite.SQLiteStorage) {
markDirtyAndScheduleFlush() markDirtyAndScheduleFlush()
} }
// runCompactDolt runs Dolt garbage collection on the .beads/dolt directory
func runCompactDolt() {
start := time.Now()
// Find beads directory
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
fmt.Fprintf(os.Stderr, "Error: could not find .beads directory\n")
os.Exit(1)
}
// Check for dolt directory
doltPath := filepath.Join(beadsDir, "dolt")
if _, err := os.Stat(doltPath); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Error: Dolt directory not found at %s\n", doltPath)
fmt.Fprintf(os.Stderr, "Hint: --dolt flag is only for repositories using the Dolt backend\n")
os.Exit(1)
}
// Check if dolt command is available
if _, err := exec.LookPath("dolt"); err != nil {
fmt.Fprintf(os.Stderr, "Error: dolt command not found in PATH\n")
fmt.Fprintf(os.Stderr, "Hint: install Dolt from https://github.com/dolthub/dolt\n")
os.Exit(1)
}
// Get size before GC
sizeBefore, err := getDirSize(doltPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not calculate directory size: %v\n", err)
sizeBefore = 0
}
if compactDryRun {
if jsonOutput {
output := map[string]interface{}{
"dry_run": true,
"dolt_path": doltPath,
"size_before": sizeBefore,
"size_display": formatBytes(sizeBefore),
}
outputJSON(output)
return
}
fmt.Printf("DRY RUN - Dolt garbage collection\n\n")
fmt.Printf("Dolt directory: %s\n", doltPath)
fmt.Printf("Current size: %s\n", formatBytes(sizeBefore))
fmt.Printf("\nRun without --dry-run to perform garbage collection.\n")
return
}
if !jsonOutput {
fmt.Printf("Running Dolt garbage collection...\n")
}
// Run dolt gc
cmd := exec.Command("dolt", "gc") // #nosec G204 -- fixed command, no user input
cmd.Dir = doltPath
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: dolt gc failed: %v\n", err)
if len(output) > 0 {
fmt.Fprintf(os.Stderr, "Output: %s\n", string(output))
}
os.Exit(1)
}
// Get size after GC
sizeAfter, err := getDirSize(doltPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not calculate directory size after GC: %v\n", err)
sizeAfter = 0
}
elapsed := time.Since(start)
freed := sizeBefore - sizeAfter
if freed < 0 {
freed = 0 // GC may not always reduce size
}
if jsonOutput {
result := map[string]interface{}{
"success": true,
"dolt_path": doltPath,
"size_before": sizeBefore,
"size_after": sizeAfter,
"freed_bytes": freed,
"freed_display": formatBytes(freed),
"elapsed_ms": elapsed.Milliseconds(),
}
outputJSON(result)
return
}
fmt.Printf("✓ Dolt garbage collection complete\n")
fmt.Printf(" %s → %s (freed %s)\n", formatBytes(sizeBefore), formatBytes(sizeAfter), formatBytes(freed))
fmt.Printf(" Time: %v\n", elapsed)
}
// getDirSize calculates the total size of a directory recursively
func getDirSize(path string) (int64, error) {
var size int64
err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
size += info.Size()
}
return nil
})
return size, err
}
// formatBytes formats a byte count as a human-readable string
func formatBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}
func init() { func init() {
compactCmd.Flags().BoolVar(&compactDryRun, "dry-run", false, "Preview without compacting") compactCmd.Flags().BoolVar(&compactDryRun, "dry-run", false, "Preview without compacting")
compactCmd.Flags().IntVar(&compactTier, "tier", 1, "Compaction tier (1 or 2)") compactCmd.Flags().IntVar(&compactTier, "tier", 1, "Compaction tier (1 or 2)")
@@ -787,6 +937,7 @@ func init() {
compactCmd.Flags().StringVar(&compactSummary, "summary", "", "Path to summary file (use '-' for stdin)") compactCmd.Flags().StringVar(&compactSummary, "summary", "", "Path to summary file (use '-' for stdin)")
compactCmd.Flags().StringVar(&compactActor, "actor", "agent", "Actor name for audit trail") compactCmd.Flags().StringVar(&compactActor, "actor", "agent", "Actor name for audit trail")
compactCmd.Flags().IntVar(&compactLimit, "limit", 0, "Limit number of candidates (0 = no limit)") compactCmd.Flags().IntVar(&compactLimit, "limit", 0, "Limit number of candidates (0 = no limit)")
compactCmd.Flags().BoolVar(&compactDolt, "dolt", false, "Dolt mode: run Dolt garbage collection on .beads/dolt")
// Note: compactCmd is added to adminCmd in admin.go // Note: compactCmd is added to adminCmd in admin.go
} }