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(&compactActor, "actor", "agent", "Actor name for audit trail")
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
resetAliasCmd.Flags().Bool("force", false, "Actually perform the reset (required)")

View File

@@ -5,9 +5,12 @@ import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/compact"
"github.com/steveyegge/beads/internal/storage/sqlite"
)
@@ -30,6 +33,7 @@ var (
compactActor string
compactLimit int
compactOlderThan int
compactDolt bool
)
var compactCmd = &cobra.Command{
@@ -45,6 +49,7 @@ Modes:
- Analyze: Export candidates for agent review (no API key needed)
- Apply: Accept agent-provided summary (no API key needed)
- Auto: AI-powered compaction (requires ANTHROPIC_API_KEY, legacy)
- Dolt: Run Dolt garbage collection (for Dolt-backend repositories)
Tiers:
- 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.
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:
# Age-based pruning
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 # 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)
bd compact --analyze --json # Get candidates with full content
bd compact --apply --id bd-42 --summary summary.txt
@@ -84,8 +100,8 @@ Examples:
bd compact --stats # Show statistics
`,
Run: func(_ *cobra.Command, _ []string) {
// Compact modifies data unless --stats or --analyze or --dry-run
if !compactStats && !compactAnalyze && !compactDryRun {
// Compact modifies data unless --stats or --analyze or --dry-run or --dolt with --dry-run
if !compactStats && !compactAnalyze && !compactDryRun && !(compactDolt && compactDryRun) {
CheckReadonly("compact")
}
ctx := rootCtx
@@ -105,6 +121,12 @@ Examples:
return
}
// Handle dolt GC mode
if compactDolt {
runCompactDolt()
return
}
// Handle prune mode (standalone tombstone pruning by age)
if compactPrune {
runCompactPrune()
@@ -766,6 +788,134 @@ func runCompactApply(ctx context.Context, store *sqlite.SQLiteStorage) {
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() {
compactCmd.Flags().BoolVar(&compactDryRun, "dry-run", false, "Preview without compacting")
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(&compactActor, "actor", "agent", "Actor name for audit trail")
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
}