Files
beads/cmd/bd/merge.go
Steve Yegge c4c5c8063a Fix: Change default JSONL filename from beads.jsonl to issues.jsonl
The canonical beads database name is issues.jsonl. Tens of thousands of users
have issues.jsonl, and beads.jsonl was only used by the Beads project itself
due to git history pollution.

Changes:
- Updated bd doctor to warn about beads.jsonl instead of issues.jsonl
- Changed default config from beads.jsonl to issues.jsonl
- Reversed precedence in checkGitForIssues to prefer issues.jsonl
- Updated git merge driver config to use issues.jsonl
- Updated all tests to expect issues.jsonl as the default

issues.jsonl is now the canonical default; beads.jsonl is legacy

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 23:34:22 -08:00

156 lines
4.9 KiB
Go

package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/merge"
)
var (
debugMerge bool
)
var mergeCmd = &cobra.Command{
Use: "merge <output> <base> <left> <right>",
Short: "3-way merge tool for beads JSONL issue files",
Long: `bd merge is a 3-way merge tool for beads issue tracker JSONL files.
It intelligently merges issues based on identity (id + created_at + created_by),
applies field-specific merge rules, combines dependencies, and outputs conflict
markers for unresolvable conflicts.
Designed to work as a git merge driver. Configure with:
git config merge.beads.driver "bd merge %A %O %A %B"
git config merge.beads.name "bd JSONL merge driver"
echo ".beads/issues.jsonl merge=beads" >> .gitattributes
Or use 'bd init' which automatically configures the merge driver.
Exit codes:
0 - Merge successful (no conflicts)
1 - Merge completed with conflicts (conflict markers in output)
2 - Error (invalid arguments, file not found, etc.)
Original tool by @neongreen: https://github.com/neongreen/mono/tree/main/beads-merge
Vendored into bd with permission.`,
Args: cobra.ExactArgs(4),
// PreRun disables PersistentPreRun for this command (no database needed)
PreRun: func(cmd *cobra.Command, args []string) {},
Run: func(cmd *cobra.Command, args []string) {
outputPath := args[0]
basePath := args[1]
leftPath := args[2]
rightPath := args[3]
// Log arguments for debugging
if debugMerge {
fmt.Fprintf(os.Stderr, "=== MERGE DRIVER INVOKED ===\n")
fmt.Fprintf(os.Stderr, "Arguments received:\n")
fmt.Fprintf(os.Stderr, " %%A (output): %q\n", outputPath)
fmt.Fprintf(os.Stderr, " %%O (base): %q\n", basePath)
fmt.Fprintf(os.Stderr, " %%L (left): %q\n", leftPath)
fmt.Fprintf(os.Stderr, " %%R (right): %q\n", rightPath)
fmt.Fprintf(os.Stderr, "\nFile existence check:\n")
for i, path := range []string{outputPath, basePath, leftPath, rightPath} {
label := []string{"%%A (output)", "%%O (base)", "%%L (left)", "%%R (right)"}[i]
if _, err := os.Stat(path); err == nil {
fmt.Fprintf(os.Stderr, " %s: EXISTS\n", label)
} else {
fmt.Fprintf(os.Stderr, " %s: NOT FOUND - %v\n", label, err)
}
}
fmt.Fprintf(os.Stderr, "\n")
}
// 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) ||
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)
os.Exit(1)
}
// Other errors - exit with 2
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(2)
}
// Success - exit with 0
os.Exit(0)
},
}
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
// #nosec G204 -- fullPath is safely constructed via filepath.Join from entry.Name()
// from os.ReadDir. exec.Command does NOT use shell interpretation - arguments
// are passed directly to git binary. See TestCleanupMergeArtifacts_CommandInjectionPrevention
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)
}