feat: add bd reset command (GH#505)
Adds a new 'bd reset' command that completely removes beads from a repository: - Stops running daemon - Removes git hooks (pre-commit, post-merge, pre-push, post-checkout) - Removes merge driver configuration - Removes .gitattributes entry - Removes sync branch worktrees - Removes .beads directory By default runs in dry-run mode showing what would be deleted. Use --force to actually perform the reset. Closes steveyegge/beads#505
This commit is contained in:
367
cmd/bd/reset.go
Normal file
367
cmd/bd/reset.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/git"
|
||||
)
|
||||
|
||||
var resetCmd = &cobra.Command{
|
||||
Use: "reset",
|
||||
Short: "Remove all beads data and configuration",
|
||||
Long: `Reset beads to an uninitialized state, removing all local data.
|
||||
|
||||
This command removes:
|
||||
- The .beads directory (database, JSONL, config)
|
||||
- Git hooks installed by bd
|
||||
- Merge driver configuration
|
||||
- Sync branch worktrees
|
||||
|
||||
By default, shows what would be deleted (dry-run mode).
|
||||
Use --force to actually perform the reset.
|
||||
|
||||
Examples:
|
||||
bd reset # Show what would be deleted
|
||||
bd reset --force # Actually delete everything`,
|
||||
Run: runReset,
|
||||
}
|
||||
|
||||
func init() {
|
||||
resetCmd.Flags().Bool("force", false, "Actually perform the reset (required)")
|
||||
rootCmd.AddCommand(resetCmd)
|
||||
}
|
||||
|
||||
func runReset(cmd *cobra.Command, args []string) {
|
||||
CheckReadonly("reset")
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
// Check if we're in a git repo
|
||||
gitDir, err := git.GetGitDir()
|
||||
if err != nil {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "not a git repository",
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: not a git repository\n")
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Check if .beads directory exists
|
||||
beadsDir := ".beads"
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"message": "beads not initialized",
|
||||
"reset": false,
|
||||
})
|
||||
} else {
|
||||
fmt.Println("Beads is not initialized in this repository.")
|
||||
fmt.Println("Nothing to reset.")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Collect what would be deleted
|
||||
items := collectResetItems(gitDir, beadsDir)
|
||||
|
||||
if !force {
|
||||
// Dry-run mode: show what would be deleted
|
||||
showResetPreview(items)
|
||||
return
|
||||
}
|
||||
|
||||
// Actually perform the reset
|
||||
performReset(items, gitDir, beadsDir)
|
||||
}
|
||||
|
||||
type resetItem struct {
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func collectResetItems(gitDir, beadsDir string) []resetItem {
|
||||
var items []resetItem
|
||||
|
||||
// Check for running daemon
|
||||
pidFile := filepath.Join(beadsDir, "daemon.pid")
|
||||
if _, err := os.Stat(pidFile); err == nil {
|
||||
if isRunning, pid := isDaemonRunning(pidFile); isRunning {
|
||||
items = append(items, resetItem{
|
||||
Type: "daemon",
|
||||
Path: pidFile,
|
||||
Description: fmt.Sprintf("Stop running daemon (PID %d)", pid),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check for git hooks
|
||||
hookNames := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"}
|
||||
hooksDir := filepath.Join(gitDir, "hooks")
|
||||
for _, hookName := range hookNames {
|
||||
hookPath := filepath.Join(hooksDir, hookName)
|
||||
if _, err := os.Stat(hookPath); err == nil {
|
||||
// Check if it's a beads hook by looking for version marker
|
||||
if isBdHook(hookPath) {
|
||||
items = append(items, resetItem{
|
||||
Type: "hook",
|
||||
Path: hookPath,
|
||||
Description: fmt.Sprintf("Remove git hook: %s", hookName),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for merge driver config
|
||||
if hasMergeDriverConfig() {
|
||||
items = append(items, resetItem{
|
||||
Type: "config",
|
||||
Path: "merge.beads.*",
|
||||
Description: "Remove merge driver configuration",
|
||||
})
|
||||
}
|
||||
|
||||
// Check for .gitattributes entry
|
||||
if hasGitattributesEntry() {
|
||||
items = append(items, resetItem{
|
||||
Type: "gitattributes",
|
||||
Path: ".gitattributes",
|
||||
Description: "Remove beads entry from .gitattributes",
|
||||
})
|
||||
}
|
||||
|
||||
// Check for sync branch worktrees
|
||||
worktreesDir := filepath.Join(gitDir, "beads-worktrees")
|
||||
if info, err := os.Stat(worktreesDir); err == nil && info.IsDir() {
|
||||
items = append(items, resetItem{
|
||||
Type: "worktrees",
|
||||
Path: worktreesDir,
|
||||
Description: "Remove sync branch worktrees",
|
||||
})
|
||||
}
|
||||
|
||||
// The .beads directory itself
|
||||
items = append(items, resetItem{
|
||||
Type: "directory",
|
||||
Path: beadsDir,
|
||||
Description: "Remove .beads directory (database, JSONL, config)",
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func isBdHook(hookPath string) bool {
|
||||
// #nosec G304 -- hook path is constructed from git dir, not user input
|
||||
file, err := os.Open(hookPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
lineCount := 0
|
||||
for scanner.Scan() && lineCount < 10 {
|
||||
line := scanner.Text()
|
||||
if strings.Contains(line, "bd-hooks-version:") || strings.Contains(line, "beads") {
|
||||
return true
|
||||
}
|
||||
lineCount++
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasMergeDriverConfig() bool {
|
||||
cmd := exec.Command("git", "config", "--get", "merge.beads.driver")
|
||||
if err := cmd.Run(); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasGitattributesEntry() bool {
|
||||
// #nosec G304 -- fixed path
|
||||
content, err := os.ReadFile(".gitattributes")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(content), "merge=beads")
|
||||
}
|
||||
|
||||
func showResetPreview(items []resetItem) {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"items": items,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
yellow := color.New(color.FgYellow).SprintFunc()
|
||||
red := color.New(color.FgRed).SprintFunc()
|
||||
|
||||
fmt.Println(yellow("Reset preview (dry-run mode)"))
|
||||
fmt.Println()
|
||||
fmt.Println("The following will be removed:")
|
||||
fmt.Println()
|
||||
|
||||
for _, item := range items {
|
||||
fmt.Printf(" %s %s\n", red("•"), item.Description)
|
||||
if item.Type != "config" {
|
||||
fmt.Printf(" %s\n", item.Path)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(red("⚠ This operation cannot be undone!"))
|
||||
fmt.Println()
|
||||
fmt.Printf("To proceed, run: %s\n", yellow("bd reset --force"))
|
||||
}
|
||||
|
||||
func performReset(items []resetItem, gitDir, beadsDir string) {
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
|
||||
var errors []string
|
||||
|
||||
for _, item := range items {
|
||||
switch item.Type {
|
||||
case "daemon":
|
||||
pidFile := filepath.Join(beadsDir, "daemon.pid")
|
||||
stopDaemonQuiet(pidFile)
|
||||
if !jsonOutput {
|
||||
fmt.Printf("%s Stopped daemon\n", green("✓"))
|
||||
}
|
||||
|
||||
case "hook":
|
||||
if err := os.Remove(item.Path); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("failed to remove hook %s: %v", item.Path, err))
|
||||
} else if !jsonOutput {
|
||||
fmt.Printf("%s Removed %s\n", green("✓"), filepath.Base(item.Path))
|
||||
}
|
||||
// Restore backup if exists
|
||||
backupPath := item.Path + ".backup"
|
||||
if _, err := os.Stat(backupPath); err == nil {
|
||||
if err := os.Rename(backupPath, item.Path); err == nil && !jsonOutput {
|
||||
fmt.Printf(" Restored backup hook\n")
|
||||
}
|
||||
}
|
||||
|
||||
case "config":
|
||||
// Remove merge driver config (ignore errors - may not exist)
|
||||
exec.Command("git", "config", "--unset", "merge.beads.driver").Run()
|
||||
exec.Command("git", "config", "--unset", "merge.beads.name").Run()
|
||||
if !jsonOutput {
|
||||
fmt.Printf("%s Removed merge driver config\n", green("✓"))
|
||||
}
|
||||
|
||||
case "gitattributes":
|
||||
if err := removeGitattributesEntry(); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("failed to update .gitattributes: %v", err))
|
||||
} else if !jsonOutput {
|
||||
fmt.Printf("%s Updated .gitattributes\n", green("✓"))
|
||||
}
|
||||
|
||||
case "worktrees":
|
||||
if err := os.RemoveAll(item.Path); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("failed to remove worktrees: %v", err))
|
||||
} else if !jsonOutput {
|
||||
fmt.Printf("%s Removed sync worktrees\n", green("✓"))
|
||||
}
|
||||
|
||||
case "directory":
|
||||
if err := os.RemoveAll(item.Path); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("failed to remove .beads: %v", err))
|
||||
} else if !jsonOutput {
|
||||
fmt.Printf("%s Removed .beads directory\n", green("✓"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
result := map[string]interface{}{
|
||||
"reset": true,
|
||||
"success": len(errors) == 0,
|
||||
}
|
||||
if len(errors) > 0 {
|
||||
result["errors"] = errors
|
||||
}
|
||||
outputJSON(result)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
if len(errors) > 0 {
|
||||
fmt.Println("Completed with errors:")
|
||||
for _, e := range errors {
|
||||
fmt.Printf(" • %s\n", e)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("%s Reset complete\n", green("✓"))
|
||||
fmt.Println()
|
||||
fmt.Println("To reinitialize beads, run: bd init")
|
||||
}
|
||||
}
|
||||
|
||||
// stopDaemonQuiet stops the daemon without printing status messages
|
||||
func stopDaemonQuiet(pidFile string) {
|
||||
isRunning, pid := isDaemonRunning(pidFile)
|
||||
if !isRunning {
|
||||
return
|
||||
}
|
||||
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = sendStopSignal(process)
|
||||
|
||||
// Wait up to 5 seconds for daemon to stop
|
||||
for i := 0; i < 50; i++ {
|
||||
if isRunning, _ := isDaemonRunning(pidFile); !isRunning {
|
||||
return
|
||||
}
|
||||
// Small sleep handled by the check
|
||||
}
|
||||
|
||||
// Force kill if still running
|
||||
_ = process.Kill()
|
||||
}
|
||||
|
||||
func removeGitattributesEntry() error {
|
||||
// #nosec G304 -- fixed path
|
||||
content, err := os.ReadFile(".gitattributes")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
var newLines []string
|
||||
for _, line := range lines {
|
||||
if !strings.Contains(line, "merge=beads") {
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
newContent := strings.Join(newLines, "\n")
|
||||
// Remove trailing empty lines
|
||||
newContent = strings.TrimRight(newContent, "\n")
|
||||
|
||||
// If file is now empty or only whitespace, remove it
|
||||
if strings.TrimSpace(newContent) == "" {
|
||||
return os.Remove(".gitattributes")
|
||||
}
|
||||
|
||||
// Add single trailing newline
|
||||
newContent += "\n"
|
||||
return os.WriteFile(".gitattributes", []byte(newContent), 0644)
|
||||
}
|
||||
Reference in New Issue
Block a user