Merge branch 'main' of https://github.com/steveyegge/beads
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -176,6 +176,16 @@ var createCmd = &cobra.Command{
|
||||
// In direct mode, we generate the child ID here
|
||||
if parentID != "" && daemonClient == nil {
|
||||
ctx := rootCtx
|
||||
// Validate parent exists before generating child ID
|
||||
parentIssue, err := store.GetIssue(ctx, parentID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to check parent issue: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if parentIssue == nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: parent issue %s not found\n", parentID)
|
||||
os.Exit(1)
|
||||
}
|
||||
childID, err := store.GetNextChildID(ctx, parentID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
@@ -290,7 +300,7 @@ var createCmd = &cobra.Command{
|
||||
depType = types.DependencyType(strings.TrimSpace(parts[0]))
|
||||
dependsOnID = strings.TrimSpace(parts[1])
|
||||
|
||||
if depType == types.DepDiscoveredFrom {
|
||||
if depType == types.DepDiscoveredFrom && dependsOnID != "" {
|
||||
discoveredFromParentID = dependsOnID
|
||||
break
|
||||
}
|
||||
|
||||
110
cmd/bd/sync.go
110
cmd/bd/sync.go
@@ -204,19 +204,49 @@ Use --merge to merge the sync branch back to main branch.`,
|
||||
|
||||
fmt.Println("→ Pulling from remote...")
|
||||
if err := gitPull(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error pulling: %v\n", err)
|
||||
// Check if it's a rebase conflict on beads.jsonl that we can auto-resolve
|
||||
if isInRebase() && hasJSONLConflict() {
|
||||
fmt.Println("→ Auto-resolving JSONL merge conflict...")
|
||||
|
||||
// Check if this looks like a merge driver failure
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "merge driver") ||
|
||||
strings.Contains(errStr, "no such file or directory") ||
|
||||
strings.Contains(errStr, "MERGE DRIVER INVOKED") {
|
||||
fmt.Fprintf(os.Stderr, "\nThis may be caused by an incorrect merge driver configuration.\n")
|
||||
fmt.Fprintf(os.Stderr, "Fix: bd doctor --fix\n\n")
|
||||
// Export clean JSONL from DB (database is source of truth)
|
||||
if exportErr := exportToJSONL(ctx, jsonlPath); exportErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to export for conflict resolution: %v\n", exportErr)
|
||||
fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Mark conflict as resolved
|
||||
addCmd := exec.CommandContext(ctx, "git", "add", jsonlPath)
|
||||
if addErr := addCmd.Run(); addErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to mark conflict resolved: %v\n", addErr)
|
||||
fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Continue rebase
|
||||
if continueErr := runGitRebaseContinue(ctx); continueErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to continue rebase: %v\n", continueErr)
|
||||
fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("✓ Auto-resolved JSONL conflict")
|
||||
} else {
|
||||
// Not an auto-resolvable conflict, fail with original error
|
||||
fmt.Fprintf(os.Stderr, "Error pulling: %v\n", err)
|
||||
|
||||
// Check if this looks like a merge driver failure
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "merge driver") ||
|
||||
strings.Contains(errStr, "no such file or directory") ||
|
||||
strings.Contains(errStr, "MERGE DRIVER INVOKED") {
|
||||
fmt.Fprintf(os.Stderr, "\nThis may be caused by an incorrect merge driver configuration.\n")
|
||||
fmt.Fprintf(os.Stderr, "Fix: bd doctor --fix\n\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Count issues before import for validation
|
||||
@@ -439,6 +469,64 @@ func hasGitRemote(ctx context.Context) bool {
|
||||
return len(strings.TrimSpace(string(output))) > 0
|
||||
}
|
||||
|
||||
// isInRebase checks if we're currently in a git rebase state
|
||||
func isInRebase() bool {
|
||||
// Check for rebase-merge directory (interactive rebase)
|
||||
if _, err := os.Stat(".git/rebase-merge"); err == nil {
|
||||
return true
|
||||
}
|
||||
// Check for rebase-apply directory (non-interactive rebase)
|
||||
if _, err := os.Stat(".git/rebase-apply"); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasJSONLConflict checks if beads.jsonl has a merge conflict
|
||||
// Returns true only if beads.jsonl is the only file in conflict
|
||||
func hasJSONLConflict() bool {
|
||||
cmd := exec.Command("git", "status", "--porcelain")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var hasJSONLConflict bool
|
||||
var hasOtherConflict bool
|
||||
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if len(line) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for unmerged status codes (UU = both modified, AA = both added, etc.)
|
||||
status := line[:2]
|
||||
if status == "UU" || status == "AA" || status == "DD" ||
|
||||
status == "AU" || status == "UA" || status == "DU" || status == "UD" {
|
||||
filepath := strings.TrimSpace(line[3:])
|
||||
|
||||
if strings.HasSuffix(filepath, "beads.jsonl") {
|
||||
hasJSONLConflict = true
|
||||
} else {
|
||||
hasOtherConflict = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only return true if ONLY beads.jsonl has a conflict
|
||||
return hasJSONLConflict && !hasOtherConflict
|
||||
}
|
||||
|
||||
// runGitRebaseContinue continues a rebase after resolving conflicts
|
||||
func runGitRebaseContinue(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, "git", "rebase", "--continue")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git rebase --continue failed: %w\n%s", err, output)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// gitPull pulls from the current branch's upstream
|
||||
// Returns nil if no remote configured (local-only mode)
|
||||
func checkMergeDriverConfig() {
|
||||
|
||||
@@ -441,3 +441,169 @@ func TestGetSyncBranch_EnvOverridesDB(t *testing.T) {
|
||||
t.Errorf("getSyncBranch() = %q, want %q (env override)", branch, "env-branch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInRebase_NotInRebase(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
originalWd, _ := os.Getwd()
|
||||
defer os.Chdir(originalWd)
|
||||
|
||||
// Create a git repo
|
||||
os.Chdir(tmpDir)
|
||||
exec.Command("git", "init").Run()
|
||||
exec.Command("git", "config", "user.email", "test@test.com").Run()
|
||||
exec.Command("git", "config", "user.name", "Test User").Run()
|
||||
|
||||
// Create initial commit
|
||||
os.WriteFile("test.txt", []byte("test"), 0644)
|
||||
exec.Command("git", "add", "test.txt").Run()
|
||||
exec.Command("git", "commit", "-m", "initial").Run()
|
||||
|
||||
// Should not be in rebase
|
||||
if isInRebase() {
|
||||
t.Error("expected false when not in rebase")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInRebase_InRebase(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
originalWd, _ := os.Getwd()
|
||||
defer os.Chdir(originalWd)
|
||||
|
||||
// Create a git repo
|
||||
os.Chdir(tmpDir)
|
||||
exec.Command("git", "init").Run()
|
||||
exec.Command("git", "config", "user.email", "test@test.com").Run()
|
||||
exec.Command("git", "config", "user.name", "Test User").Run()
|
||||
|
||||
// Create initial commit
|
||||
os.WriteFile("test.txt", []byte("test"), 0644)
|
||||
exec.Command("git", "add", "test.txt").Run()
|
||||
exec.Command("git", "commit", "-m", "initial").Run()
|
||||
|
||||
// Simulate rebase by creating rebase-merge directory
|
||||
os.MkdirAll(filepath.Join(tmpDir, ".git", "rebase-merge"), 0755)
|
||||
|
||||
// Should detect rebase
|
||||
if !isInRebase() {
|
||||
t.Error("expected true when .git/rebase-merge exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInRebase_InRebaseApply(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
originalWd, _ := os.Getwd()
|
||||
defer os.Chdir(originalWd)
|
||||
|
||||
// Create a git repo
|
||||
os.Chdir(tmpDir)
|
||||
exec.Command("git", "init").Run()
|
||||
|
||||
// Simulate non-interactive rebase by creating rebase-apply directory
|
||||
os.MkdirAll(filepath.Join(tmpDir, ".git", "rebase-apply"), 0755)
|
||||
|
||||
// Should detect rebase
|
||||
if !isInRebase() {
|
||||
t.Error("expected true when .git/rebase-apply exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasJSONLConflict_NoConflict(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
originalWd, _ := os.Getwd()
|
||||
defer os.Chdir(originalWd)
|
||||
|
||||
// Create a git repo
|
||||
os.Chdir(tmpDir)
|
||||
exec.Command("git", "init").Run()
|
||||
exec.Command("git", "config", "user.email", "test@test.com").Run()
|
||||
exec.Command("git", "config", "user.name", "Test User").Run()
|
||||
|
||||
// Create initial commit
|
||||
os.WriteFile("test.txt", []byte("test"), 0644)
|
||||
exec.Command("git", "add", "test.txt").Run()
|
||||
exec.Command("git", "commit", "-m", "initial").Run()
|
||||
|
||||
// Should not have JSONL conflict
|
||||
if hasJSONLConflict() {
|
||||
t.Error("expected false when no conflicts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasJSONLConflict_OnlyJSONLConflict(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
originalWd, _ := os.Getwd()
|
||||
defer os.Chdir(originalWd)
|
||||
|
||||
// Create a git repo
|
||||
os.Chdir(tmpDir)
|
||||
exec.Command("git", "init", "-b", "main").Run()
|
||||
exec.Command("git", "config", "user.email", "test@test.com").Run()
|
||||
exec.Command("git", "config", "user.name", "Test User").Run()
|
||||
|
||||
// Create initial commit
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
os.MkdirAll(beadsDir, 0755)
|
||||
os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"original"}`), 0644)
|
||||
exec.Command("git", "add", ".").Run()
|
||||
exec.Command("git", "commit", "-m", "initial").Run()
|
||||
|
||||
// Create a second commit on main (modify same issue)
|
||||
os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"main-version"}`), 0644)
|
||||
exec.Command("git", "add", ".").Run()
|
||||
exec.Command("git", "commit", "-m", "main change").Run()
|
||||
|
||||
// Create a branch from the first commit
|
||||
exec.Command("git", "checkout", "-b", "feature", "HEAD~1").Run()
|
||||
os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"feature-version"}`), 0644)
|
||||
exec.Command("git", "add", ".").Run()
|
||||
exec.Command("git", "commit", "-m", "feature change").Run()
|
||||
|
||||
// Attempt rebase onto main (will conflict)
|
||||
exec.Command("git", "rebase", "main").Run()
|
||||
|
||||
// Should detect JSONL conflict during rebase
|
||||
if !hasJSONLConflict() {
|
||||
t.Error("expected true when only beads.jsonl has conflict during rebase")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasJSONLConflict_MultipleConflicts(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
originalWd, _ := os.Getwd()
|
||||
defer os.Chdir(originalWd)
|
||||
|
||||
// Create a git repo
|
||||
os.Chdir(tmpDir)
|
||||
exec.Command("git", "init", "-b", "main").Run()
|
||||
exec.Command("git", "config", "user.email", "test@test.com").Run()
|
||||
exec.Command("git", "config", "user.name", "Test User").Run()
|
||||
|
||||
// Create initial commit with beads.jsonl and another file
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
os.MkdirAll(beadsDir, 0755)
|
||||
os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"original"}`), 0644)
|
||||
os.WriteFile("other.txt", []byte("line1\nline2\nline3"), 0644)
|
||||
exec.Command("git", "add", ".").Run()
|
||||
exec.Command("git", "commit", "-m", "initial").Run()
|
||||
|
||||
// Create a second commit on main (modify both files)
|
||||
os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"main-version"}`), 0644)
|
||||
os.WriteFile("other.txt", []byte("line1\nmain-version\nline3"), 0644)
|
||||
exec.Command("git", "add", ".").Run()
|
||||
exec.Command("git", "commit", "-m", "main change").Run()
|
||||
|
||||
// Create a branch from the first commit
|
||||
exec.Command("git", "checkout", "-b", "feature", "HEAD~1").Run()
|
||||
os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"feature-version"}`), 0644)
|
||||
os.WriteFile("other.txt", []byte("line1\nfeature-version\nline3"), 0644)
|
||||
exec.Command("git", "add", ".").Run()
|
||||
exec.Command("git", "commit", "-m", "feature change").Run()
|
||||
|
||||
// Attempt rebase (will conflict on both files)
|
||||
exec.Command("git", "rebase", "main").Run()
|
||||
|
||||
// Should NOT auto-resolve when multiple files conflict
|
||||
if hasJSONLConflict() {
|
||||
t.Error("expected false when multiple files have conflicts (should not auto-resolve)")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user