diff --git a/.beads/config.yaml b/.beads/config.yaml index dabc20c1..dc1e19ed 100644 --- a/.beads/config.yaml +++ b/.beads/config.yaml @@ -6,7 +6,7 @@ # Issue prefix for this repository (used by bd init) # If not set, bd init will auto-detect from directory name # Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. -# issue-prefix: "" +issue-prefix: "bd" # Use no-db mode: load from JSONL, no SQLite, write back after each command # When true, bd will use .beads/issues.jsonl as the source of truth diff --git a/cmd/bd/clean.go b/cmd/bd/clean.go new file mode 100644 index 00000000..f7bfc18b --- /dev/null +++ b/cmd/bd/clean.go @@ -0,0 +1,161 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +var cleanCmd = &cobra.Command{ + Use: "clean", + Short: "Clean up temporary beads artifacts", + Long: `Delete temporary beads artifacts to clean up after git operations. + +This removes temporary files created during git merges and conflicts from the +.beads directory. + +Files removed: +- 3-way merge snapshots (beads.base.jsonl, beads.left.jsonl, beads.right.jsonl) +- Merge metadata (*.meta.json) +- Git merge driver temp files (*.json[0-9], *.jsonl[0-9]) + +Files preserved: +- beads.jsonl (source of truth) +- beads.db (SQLite database) +- metadata.json +- config.yaml +- All daemon files + +EXAMPLES: +Clean up temporary files: + bd clean + +Preview what would be deleted: + bd clean --dry-run`, + Run: func(cmd *cobra.Command, args []string) { + dryRun, _ := cmd.Flags().GetBool("dry-run") + + // Find beads directory + beadsDir := findBeadsDir() + if beadsDir == "" { + fmt.Fprintf(os.Stderr, "Error: .beads directory not found\n") + os.Exit(1) + } + + // Read patterns from .beads/.gitignore (only merge artifacts section) + cleanPatterns, err := readMergeArtifactPatterns(beadsDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading .gitignore: %v\n", err) + os.Exit(1) + } + + // Collect files to delete + var filesToDelete []string + for _, pattern := range cleanPatterns { + matches, err := filepath.Glob(filepath.Join(beadsDir, pattern)) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: error matching pattern %s: %v\n", pattern, err) + continue + } + filesToDelete = append(filesToDelete, matches...) + } + + if len(filesToDelete) == 0 { + fmt.Println("Nothing to clean - all artifacts already removed") + return + } + + // Just run by default, no --force needed + + if dryRun { + fmt.Println(color.YellowString("DRY RUN - no changes will be made")) + } + fmt.Printf("Found %d file(s) to clean:\n", len(filesToDelete)) + for _, file := range filesToDelete { + relPath, err := filepath.Rel(beadsDir, file) + if err != nil { + relPath = file + } + fmt.Printf(" %s\n", relPath) + } + + if dryRun { + return + } + + // Actually delete the files + deletedCount := 0 + errorCount := 0 + for _, file := range filesToDelete { + if err := os.Remove(file); err != nil { + if !os.IsNotExist(err) { + relPath, _ := filepath.Rel(beadsDir, file) + fmt.Fprintf(os.Stderr, "Warning: failed to delete %s: %v\n", relPath, err) + errorCount++ + } + } else { + deletedCount++ + } + } + + fmt.Printf("\nDeleted %d file(s)", deletedCount) + if errorCount > 0 { + fmt.Printf(" (%d error(s))", errorCount) + } + fmt.Println() + }, +} + +// readMergeArtifactPatterns reads the .beads/.gitignore file and extracts +// patterns from the "Merge artifacts" section +func readMergeArtifactPatterns(beadsDir string) ([]string, error) { + gitignorePath := filepath.Join(beadsDir, ".gitignore") + file, err := os.Open(gitignorePath) + if err != nil { + return nil, fmt.Errorf("failed to open .gitignore: %w", err) + } + defer file.Close() + + var patterns []string + inMergeSection := false + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Look for the merge artifacts section + if strings.Contains(line, "Merge artifacts") { + inMergeSection = true + continue + } + + // Stop at the next section (starts with #) + if inMergeSection && strings.HasPrefix(line, "#") { + break + } + + // Collect patterns from merge section + if inMergeSection && line != "" && !strings.HasPrefix(line, "#") { + // Skip negation patterns (starting with !) + if !strings.HasPrefix(line, "!") { + patterns = append(patterns, line) + } + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading .gitignore: %w", err) + } + + return patterns, nil +} + +func init() { + cleanCmd.Flags().Bool("dry-run", false, "Preview what would be deleted without making changes") + rootCmd.AddCommand(cleanCmd) +} diff --git a/cmd/bd/merge.go b/cmd/bd/merge.go index 7db900f9..ddc0415d 100644 --- a/cmd/bd/merge.go +++ b/cmd/bd/merge.go @@ -3,6 +3,9 @@ package main import ( "fmt" "os" + "os/exec" + "path/filepath" + "strings" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/merge" @@ -45,10 +48,15 @@ Vendored into bd with permission.`, leftPath := args[2] rightPath := args[3] + // Ensure cleanup runs after merge completes + defer func() { + cleanupMergeArtifacts(outputPath, debugMerge) + }() + err := merge.Merge3Way(outputPath, basePath, leftPath, rightPath, debugMerge) if err != nil { // Check if error is due to conflicts - if err.Error() == fmt.Sprintf("merge completed with %d conflicts", 1) || + if err.Error() == fmt.Sprintf("merge completed with %d conflicts", 1) || err.Error() == fmt.Sprintf("merge completed with %d conflicts", 2) || err.Error()[:len("merge completed with")] == "merge completed with" { // Conflicts present - exit with 1 (standard for merge drivers) @@ -63,6 +71,61 @@ Vendored into bd with permission.`, }, } +func cleanupMergeArtifacts(outputPath string, debug bool) { + // Determine the .beads directory from the output path + // outputPath is typically .beads/beads.jsonl + beadsDir := filepath.Dir(outputPath) + + if debug { + fmt.Fprintf(os.Stderr, "=== CLEANUP ===\n") + fmt.Fprintf(os.Stderr, "Cleaning up artifacts in: %s\n", beadsDir) + } + + // 1. Find and remove any files with "backup" in the name + entries, err := os.ReadDir(beadsDir) + if err != nil { + if debug { + fmt.Fprintf(os.Stderr, "Warning: failed to read directory for cleanup: %v\n", err) + } + return + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + if strings.Contains(strings.ToLower(entry.Name()), "backup") { + fullPath := filepath.Join(beadsDir, entry.Name()) + + // Try to git rm if tracked + gitRmCmd := exec.Command("git", "rm", "-f", "--quiet", fullPath) + gitRmCmd.Dir = filepath.Dir(beadsDir) + _ = gitRmCmd.Run() // Ignore errors, file may not be tracked + + // Also remove from filesystem if git rm didn't work + if err := os.Remove(fullPath); err == nil { + if debug { + fmt.Fprintf(os.Stderr, "Removed backup file: %s\n", entry.Name()) + } + } + } + } + + // 2. Run git clean -f in .beads/ directory to remove untracked files + cleanCmd := exec.Command("git", "clean", "-f") + cleanCmd.Dir = beadsDir + if debug { + cleanCmd.Stderr = os.Stderr + cleanCmd.Stdout = os.Stderr + fmt.Fprintf(os.Stderr, "Running: git clean -f in %s\n", beadsDir) + } + _ = cleanCmd.Run() // Ignore errors, git clean may fail in some contexts + + if debug { + fmt.Fprintf(os.Stderr, "Cleanup complete\n\n") + } +} + func init() { mergeCmd.Flags().BoolVar(&debugMerge, "debug", false, "Enable debug output to stderr") rootCmd.AddCommand(mergeCmd)