Move cleanup, compact, and reset commands under `bd admin` namespace. Creates hidden aliases for backwards compatibility that show deprecation notice when used. - Create cmd/bd/admin.go with parent command - Create cmd/bd/admin_aliases.go for hidden backwards-compat aliases - Update cleanup.go, compact.go, reset.go to remove rootCmd.AddCommand - Update all documentation to use `bd admin <cmd>` syntax 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
367 lines
9.1 KiB
Go
367 lines
9.1 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/beads/internal/git"
|
|
"github.com/steveyegge/beads/internal/ui"
|
|
)
|
|
|
|
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)")
|
|
// Note: resetCmd is added to adminCmd in admin.go
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
|
|
fmt.Println(ui.RenderWarn("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", ui.RenderFail("•"), item.Description)
|
|
if item.Type != "config" {
|
|
fmt.Printf(" %s\n", item.Path)
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
fmt.Println(ui.RenderFail("⚠ This operation cannot be undone!"))
|
|
fmt.Println()
|
|
fmt.Printf("To proceed, run: %s\n", ui.RenderWarn("bd reset --force"))
|
|
}
|
|
|
|
func performReset(items []resetItem, _, beadsDir string) {
|
|
|
|
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", ui.RenderPass("✓"))
|
|
}
|
|
|
|
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", ui.RenderPass("✓"), 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", ui.RenderPass("✓"))
|
|
}
|
|
|
|
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", ui.RenderPass("✓"))
|
|
}
|
|
|
|
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", ui.RenderPass("✓"))
|
|
}
|
|
|
|
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", ui.RenderPass("✓"))
|
|
}
|
|
}
|
|
}
|
|
|
|
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", ui.RenderPass("✓"))
|
|
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) // Best-effort graceful stop
|
|
|
|
// Wait for daemon to stop gracefully
|
|
for i := 0; i < daemonShutdownAttempts; i++ {
|
|
time.Sleep(daemonShutdownPollInterval)
|
|
if isRunning, _ := isDaemonRunning(pidFile); !isRunning {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Force kill if still running
|
|
_ = process.Kill() // Best-effort force kill, process may have already exited
|
|
}
|
|
|
|
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"
|
|
//nolint:gosec // G306: .gitattributes must be world-readable (0644)
|
|
return os.WriteFile(".gitattributes", []byte(newContent), 0644)
|
|
}
|