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>
156 lines
4.9 KiB
Go
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)
|
|
}
|