feat(reset): implement bd reset CLI command with unit tests

Implements the bd reset command for GitHub issue #479:
- CLI command with flags: --hard, --force, --backup, --dry-run, --skip-init, --verbose
- Impact summary showing issues/tombstones to be deleted
- Confirmation prompt (skippable with --force)
- Colored output for better UX
- Unit tests for reset.go and git.go
- Fix: use --force flag in git rm to handle staged files

Part of epic bd-aydr.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-13 10:00:23 +11:00
parent 0b400c754b
commit 88153f224f
5 changed files with 828 additions and 3 deletions

230
cmd/bd/reset.go Normal file
View File

@@ -0,0 +1,230 @@
package main
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/reset"
)
var resetCmd = &cobra.Command{
Use: "reset",
Short: "Reset beads to a clean starting state",
Long: `Reset beads to a clean starting state by clearing .beads/ and reinitializing.
This command is useful when:
- Your beads workspace is in an invalid state after an update
- You want to start fresh with issue tracking
- bd doctor cannot automatically fix problems
RESET MODES:
Soft Reset (default):
- Kills all daemons
- Clears .beads/ directory
- Reinitializes with bd init
- Git history is unchanged
Hard Reset (--hard):
- Same as soft reset, plus:
- Removes .beads/ files from git (git rm)
- Creates a commit removing the old state
- Creates a commit with fresh initialized state
OPTIONS:
--backup Create .beads-backup-{timestamp}/ before clearing
--dry-run Preview what would happen without making changes
--force Skip confirmation prompt
--hard Include git operations (git rm + commit)
--skip-init Don't reinitialize after clearing (leaves .beads/ empty)
--verbose Show detailed progress
EXAMPLES:
bd reset # Reset with confirmation prompt
bd reset --backup # Reset with backup first
bd reset --dry-run # Preview the impact
bd reset --hard # Reset including git history
bd reset --force # Reset without confirmation`,
Run: func(cmd *cobra.Command, _ []string) {
hard, _ := cmd.Flags().GetBool("hard")
force, _ := cmd.Flags().GetBool("force")
backup, _ := cmd.Flags().GetBool("backup")
dryRun, _ := cmd.Flags().GetBool("dry-run")
skipInit, _ := cmd.Flags().GetBool("skip-init")
verbose, _ := cmd.Flags().GetBool("verbose")
// Color helpers
red := color.New(color.FgRed).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
green := color.New(color.FgGreen).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
// Validate state
if err := reset.ValidateState(); err != nil {
fmt.Fprintf(os.Stderr, "%s %v\n", red("Error:"), err)
os.Exit(1)
}
// Get impact summary
impact, err := reset.CountImpact()
if err != nil {
fmt.Fprintf(os.Stderr, "%s Failed to analyze workspace: %v\n", red("Error:"), err)
os.Exit(1)
}
// Show impact summary
fmt.Printf("\n%s Reset Impact Summary\n", yellow("⚠"))
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
totalIssues := impact.IssueCount - impact.TombstoneCount
if totalIssues > 0 {
fmt.Printf(" Issues to delete: %s\n", cyan(fmt.Sprintf("%d", totalIssues)))
fmt.Printf(" - Open: %d\n", impact.OpenCount)
fmt.Printf(" - Closed: %d\n", impact.ClosedCount)
} else {
fmt.Printf(" Issues to delete: %s\n", cyan("0"))
}
if impact.TombstoneCount > 0 {
fmt.Printf(" Tombstones to delete: %s\n", cyan(fmt.Sprintf("%d", impact.TombstoneCount)))
}
if impact.HasUncommitted {
fmt.Printf(" %s Uncommitted changes in .beads/ will be lost\n", yellow("⚠"))
}
fmt.Printf("\n")
// Show what will happen
fmt.Printf("Actions:\n")
if backup {
fmt.Printf(" 1. Create backup (.beads-backup-{timestamp}/)\n")
}
fmt.Printf(" %s. Kill all daemons\n", actionNumber(backup, 1))
if hard {
fmt.Printf(" %s. Remove .beads/ from git index and commit\n", actionNumber(backup, 2))
}
fmt.Printf(" %s. Delete .beads/ directory\n", actionNumber(backup, hardOffset(hard, 2)))
if !skipInit {
fmt.Printf(" %s. Reinitialize workspace (bd init)\n", actionNumber(backup, hardOffset(hard, 3)))
if hard {
fmt.Printf(" %s. Commit fresh state to git\n", actionNumber(backup, hardOffset(hard, 4)))
}
}
fmt.Printf("\n")
// Dry run - stop here
if dryRun {
fmt.Printf("%s This was a dry run. No changes were made.\n\n", cyan(""))
return
}
// Confirmation prompt (unless --force)
if !force {
fmt.Printf("%s This will permanently delete all issues. Continue? [y/N]: ", yellow("Warning:"))
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
fmt.Fprintf(os.Stderr, "%s Failed to read response: %v\n", red("Error:"), err)
os.Exit(1)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Printf("Reset cancelled.\n")
return
}
}
// Execute reset
if verbose {
fmt.Printf("\n%s Starting reset...\n", cyan("→"))
}
opts := reset.ResetOptions{
Hard: hard,
Backup: backup,
DryRun: false, // Already handled above
SkipInit: skipInit,
}
result, err := reset.Reset(opts)
if err != nil {
fmt.Fprintf(os.Stderr, "%s Reset failed: %v\n", red("Error:"), err)
os.Exit(1)
}
// Handle --hard mode: commit fresh state after reinit
if hard && !skipInit {
beadsDir := beads.FindBeadsDir()
if beadsDir != "" {
commitMsg := "Initialize fresh beads workspace\n\nCreated new .beads/ directory after reset."
if err := reset.GitAddAndCommit(beadsDir, commitMsg); err != nil {
fmt.Fprintf(os.Stderr, "%s Failed to commit fresh state: %v\n", yellow("Warning:"), err)
fmt.Fprintf(os.Stderr, "You may need to manually run: git add .beads && git commit -m \"Fresh beads state\"\n")
} else if verbose {
fmt.Printf(" %s Committed fresh state to git\n", green("✓"))
}
}
}
// Show results
fmt.Printf("\n%s Reset complete!\n\n", green("✓"))
if result.BackupPath != "" {
fmt.Printf(" Backup created: %s\n", cyan(result.BackupPath))
}
if result.DaemonsKilled > 0 {
fmt.Printf(" Daemons stopped: %d\n", result.DaemonsKilled)
}
if result.IssuesDeleted > 0 || result.TombstonesDeleted > 0 {
fmt.Printf(" Issues deleted: %d\n", result.IssuesDeleted)
if result.TombstonesDeleted > 0 {
fmt.Printf(" Tombstones deleted: %d\n", result.TombstonesDeleted)
}
}
if !skipInit {
fmt.Printf("\n Workspace reinitialized. Run %s to get started.\n", cyan("bd quickstart"))
} else {
fmt.Printf("\n .beads/ directory has been cleared. Run %s to reinitialize.\n", cyan("bd init"))
}
fmt.Printf("\n")
},
}
// actionNumber returns the step number accounting for backup offset
func actionNumber(hasBackup bool, step int) string {
if hasBackup {
return fmt.Sprintf("%d", step+1)
}
return fmt.Sprintf("%d", step)
}
// hardOffset adjusts step number for --hard mode which adds extra steps
func hardOffset(isHard bool, step int) int {
if isHard {
return step + 1
}
return step
}
func init() {
resetCmd.Flags().Bool("hard", false, "Include git operations (git rm + commit)")
resetCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
resetCmd.Flags().Bool("backup", false, "Create backup before reset")
resetCmd.Flags().Bool("dry-run", false, "Preview what would happen without making changes")
resetCmd.Flags().Bool("skip-init", false, "Don't reinitialize after clearing")
resetCmd.Flags().BoolP("verbose", "v", false, "Show detailed progress")
rootCmd.AddCommand(resetCmd)
}