From 4b5ea05da2bf6b9ea300f625216d451a5382a780 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 14 Dec 2025 17:39:54 -0800 Subject: [PATCH] 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 --- cmd/bd/reset.go | 367 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 367 insertions(+) create mode 100644 cmd/bd/reset.go diff --git a/cmd/bd/reset.go b/cmd/bd/reset.go new file mode 100644 index 00000000..2b597250 --- /dev/null +++ b/cmd/bd/reset.go @@ -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) +}