From 0d2dc53c67a7d2242fe935e3d566148260565d37 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 5 Dec 2025 14:47:02 -0800 Subject: [PATCH] fix(init): bootstrap from sync-branch when configured (bd-0is) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When sync-branch is configured in config.yaml, bd init now reads from that branch (origin/ first, then local ) instead of HEAD. This ensures fresh clones correctly import issues from the sync branch. Key changes: - checkGitForIssues() now returns gitRef (third return value) - New getLocalSyncBranch() reads sync-branch directly from config.yaml (not cached global config) to handle test environments where CWD changes - importFromGit() accepts gitRef parameter to read from correct branch - Added readFirstIssueFromGit() for prefix auto-detection from git - Fixed macOS symlink issue: filepath.EvalSymlinks() ensures /var and /private/var paths are normalized before filepath.Rel() Part of GitHub issue #464 (beads deletes issues in multi-clone environments) šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- cmd/bd/autoimport.go | 97 +++++++++++++++++++++++++++++++++------ cmd/bd/autoimport_test.go | 7 ++- cmd/bd/init.go | 46 +++++++++++++++++-- cmd/bd/reinit_test.go | 16 +++---- 4 files changed, 136 insertions(+), 30 deletions(-) diff --git a/cmd/bd/autoimport.go b/cmd/bd/autoimport.go index 7f04ae37..729cb933 100644 --- a/cmd/bd/autoimport.go +++ b/cmd/bd/autoimport.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/steveyegge/beads/internal/storage" + "github.com/steveyegge/beads/internal/syncbranch" "github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/utils" ) @@ -34,7 +35,7 @@ func checkAndAutoImport(ctx context.Context, store storage.Storage) bool { } // Database is empty - check if git has issues - issueCount, jsonlPath := checkGitForIssues() + issueCount, jsonlPath, gitRef := checkGitForIssues() if issueCount == 0 { // No issues in git either return false @@ -46,7 +47,7 @@ func checkAndAutoImport(ctx context.Context, store storage.Storage) bool { } // Import from git - if err := importFromGit(ctx, dbPath, store, jsonlPath); err != nil { + if err := importFromGit(ctx, dbPath, store, jsonlPath, gitRef); err != nil { if !jsonOutput { fmt.Fprintf(os.Stderr, "Warning: auto-import failed: %v\n", err) fmt.Fprintf(os.Stderr, "Try manually: git show HEAD:%s | bd import -i /dev/stdin\n", jsonlPath) @@ -61,28 +62,62 @@ func checkAndAutoImport(ctx context.Context, store storage.Storage) bool { return true } -// checkGitForIssues checks if git has issues in HEAD:.beads/beads.jsonl or issues.jsonl -// Returns (issue_count, relative_jsonl_path) -func checkGitForIssues() (int, string) { +// checkGitForIssues checks if git has issues in .beads/beads.jsonl or issues.jsonl +// When sync-branch is configured, reads from that branch; otherwise reads from HEAD. +// Returns (issue_count, relative_jsonl_path, git_ref) +func checkGitForIssues() (int, string, string) { // Try to find .beads directory beadsDir := findBeadsDir() if beadsDir == "" { - return 0, "" + return 0, "", "" } // Construct relative path from git root gitRoot := findGitRoot() if gitRoot == "" { - return 0, "" + return 0, "", "" } + // Resolve symlinks to ensure consistent paths for filepath.Rel() + // This is necessary because on macOS, /var is a symlink to /private/var, + // and git rev-parse returns the resolved path while os.Getwd() may not. + resolvedBeadsDir, err := filepath.EvalSymlinks(beadsDir) + if err != nil { + return 0, "", "" + } + beadsDir = resolvedBeadsDir + resolvedGitRoot, err := filepath.EvalSymlinks(gitRoot) + if err != nil { + return 0, "", "" + } + gitRoot = resolvedGitRoot + // Clean paths to ensure consistent separators beadsDir = filepath.Clean(beadsDir) gitRoot = filepath.Clean(gitRoot) - + relBeads, err := filepath.Rel(gitRoot, beadsDir) if err != nil { - return 0, "" + return 0, "", "" + } + + // Determine which branch to read from (bd-0is fix) + // If sync-branch is configured in local config.yaml, use it; otherwise fall back to HEAD + // We read sync-branch directly from local config file rather than using cached global config + // to handle cases where CWD has changed since config initialization (e.g., in tests) + gitRef := "HEAD" + syncBranch := getLocalSyncBranch(beadsDir) + if syncBranch != "" { + // Check if the sync branch exists (locally or on remote) + // Try origin/ first (more likely to exist in fresh clones), + // then local + for _, ref := range []string{"origin/" + syncBranch, syncBranch} { + cmd := exec.Command("git", "rev-parse", "--verify", "--quiet", ref) // #nosec G204 + if err := cmd.Run(); err == nil { + gitRef = ref + break + } + } } // Try canonical JSONL filenames in precedence order (issues.jsonl is canonical) @@ -94,17 +129,49 @@ func checkGitForIssues() (int, string) { for _, relPath := range candidates { // Use ToSlash for git path compatibility on Windows gitPath := filepath.ToSlash(relPath) - cmd := exec.Command("git", "show", fmt.Sprintf("HEAD:%s", gitPath)) // #nosec G204 - git command with safe args + cmd := exec.Command("git", "show", fmt.Sprintf("%s:%s", gitRef, gitPath)) // #nosec G204 - git command with safe args output, err := cmd.Output() if err == nil && len(output) > 0 { lines := bytes.Count(output, []byte("\n")) if lines > 0 { - return lines, relPath + return lines, relPath, gitRef } } } - return 0, "" + return 0, "", "" +} + +// getLocalSyncBranch reads sync-branch from the local config.yaml file. +// This reads directly from the file rather than using cached config to handle +// cases where CWD has changed since config initialization. +func getLocalSyncBranch(beadsDir string) string { + // First check environment variable (highest priority) + if envBranch := os.Getenv(syncbranch.EnvVar); envBranch != "" { + return envBranch + } + + // Read config.yaml directly from the .beads directory + configPath := filepath.Join(beadsDir, "config.yaml") + data, err := os.ReadFile(configPath) // #nosec G304 - config file path from findBeadsDir + if err != nil { + return "" + } + + // Simple YAML parsing for sync-branch key + // Format: "sync-branch: value" or "sync-branch: \"value\"" + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "sync-branch:") { + value := strings.TrimPrefix(line, "sync-branch:") + value = strings.TrimSpace(value) + // Remove quotes if present + value = strings.Trim(value, "\"'") + return value + } + } + + return "" } // findBeadsDir finds the .beads directory in current or parent directories @@ -155,11 +222,11 @@ func findGitRoot() string { return root } -// importFromGit imports issues from git HEAD -func importFromGit(ctx context.Context, dbFilePath string, store storage.Storage, jsonlPath string) error { +// importFromGit imports issues from git at the specified ref (bd-0is: supports sync-branch) +func importFromGit(ctx context.Context, dbFilePath string, store storage.Storage, jsonlPath, gitRef string) error { // Get content from git (use ToSlash for Windows compatibility) gitPath := filepath.ToSlash(jsonlPath) - cmd := exec.Command("git", "show", fmt.Sprintf("HEAD:%s", gitPath)) // #nosec G204 - git command with safe args + cmd := exec.Command("git", "show", fmt.Sprintf("%s:%s", gitRef, gitPath)) // #nosec G204 - git command with safe args jsonlData, err := cmd.Output() if err != nil { return fmt.Errorf("failed to read from git: %w", err) diff --git a/cmd/bd/autoimport_test.go b/cmd/bd/autoimport_test.go index 71152331..b7736374 100644 --- a/cmd/bd/autoimport_test.go +++ b/cmd/bd/autoimport_test.go @@ -250,13 +250,16 @@ func TestCheckGitForIssues_NoGitRepo(t *testing.T) { tmpDir := t.TempDir() t.Chdir(tmpDir) - count, path := checkGitForIssues() + count, path, gitRef := checkGitForIssues() if count != 0 { t.Errorf("Expected 0 issues, got %d", count) } if path != "" { t.Errorf("Expected empty path, got %s", path) } + if gitRef != "" { + t.Errorf("Expected empty gitRef, got %s", gitRef) + } } func TestCheckGitForIssues_NoBeadsDir(t *testing.T) { @@ -264,7 +267,7 @@ func TestCheckGitForIssues_NoBeadsDir(t *testing.T) { tmpDir := t.TempDir() t.Chdir(tmpDir) - count, path := checkGitForIssues() + count, path, _ := checkGitForIssues() if count != 0 || path != "" { t.Logf("No .beads dir: count=%d, path=%s (expected 0, empty)", count, path) } diff --git a/cmd/bd/init.go b/cmd/bd/init.go index eb48c68d..47e7d5fd 100644 --- a/cmd/bd/init.go +++ b/cmd/bd/init.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "bytes" "encoding/json" "fmt" "os" @@ -89,9 +90,9 @@ With --stealth: configures global git settings for invisible beads usage: // auto-detect prefix from first issue in JSONL file if prefix == "" { - issueCount, jsonlPath := checkGitForIssues() + issueCount, jsonlPath, gitRef := checkGitForIssues() if issueCount > 0 { - firstIssue, err := readFirstIssueFromJSONL(jsonlPath) + firstIssue, err := readFirstIssueFromGit(jsonlPath, gitRef) if firstIssue != nil && err == nil { prefix = utils.ExtractIssuePrefix(firstIssue.ID) } @@ -347,16 +348,16 @@ With --stealth: configures global git settings for invisible beads usage: } // Check if git has existing issues to import (fresh clone scenario) - issueCount, jsonlPath := checkGitForIssues() + issueCount, jsonlPath, gitRef := checkGitForIssues() if issueCount > 0 { if !quiet { fmt.Fprintf(os.Stderr, "\nāœ“ Database initialized. Found %d issues in git, importing...\n", issueCount) } - if err := importFromGit(ctx, initDBPath, store, jsonlPath); err != nil { + if err := importFromGit(ctx, initDBPath, store, jsonlPath, gitRef); err != nil { if !quiet { fmt.Fprintf(os.Stderr, "Warning: auto-import failed: %v\n", err) - fmt.Fprintf(os.Stderr, "Try manually: git show HEAD:%s | bd import -i /dev/stdin\n", jsonlPath) + fmt.Fprintf(os.Stderr, "Try manually: git show %s:%s | bd import -i /dev/stdin\n", gitRef, jsonlPath) } // Non-fatal - continue with empty database } else if !quiet { @@ -1208,6 +1209,41 @@ func readFirstIssueFromJSONL(path string) (*types.Issue, error) { return nil, nil } +// readFirstIssueFromGit reads the first issue from a git ref (bd-0is: supports sync-branch) +func readFirstIssueFromGit(jsonlPath, gitRef string) (*types.Issue, error) { + // Get content from git (use ToSlash for Windows compatibility) + gitPath := filepath.ToSlash(jsonlPath) + cmd := exec.Command("git", "show", fmt.Sprintf("%s:%s", gitRef, gitPath)) // #nosec G204 + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to read from git: %w", err) + } + + scanner := bufio.NewScanner(bytes.NewReader(output)) + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := scanner.Text() + + // skip empty lines + if line == "" { + continue + } + + var issue types.Issue + if err := json.Unmarshal([]byte(line), &issue); err == nil { + return &issue, nil + } + // Skip malformed lines silently (called during auto-detection) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error scanning git content: %w", err) + } + + return nil, nil +} + // setupStealthMode configures global git settings for stealth operation func setupStealthMode(verbose bool) error { homeDir, err := os.UserHomeDir() diff --git a/cmd/bd/reinit_test.go b/cmd/bd/reinit_test.go index 1df0b655..d844cadb 100644 --- a/cmd/bd/reinit_test.go +++ b/cmd/bd/reinit_test.go @@ -91,7 +91,7 @@ func testFreshCloneAutoImport(t *testing.T) { // Test checkGitForIssues detects issues.jsonl t.Chdir(dir) - count, path := checkGitForIssues() + count, path, gitRef := checkGitForIssues() if count != 1 { t.Errorf("Expected 1 issue in git, got %d", count) } @@ -102,7 +102,7 @@ func testFreshCloneAutoImport(t *testing.T) { } // Import from git - if err := importFromGit(ctx, dbPath, store, path); err != nil { + if err := importFromGit(ctx, dbPath, store, path, gitRef); err != nil { t.Fatalf("Import failed: %v", err) } @@ -166,7 +166,7 @@ func testDatabaseRemovalScenario(t *testing.T) { t.Chdir(dir) // Test checkGitForIssues finds issues.jsonl (canonical name) - count, path := checkGitForIssues() + count, path, gitRef := checkGitForIssues() if count != 2 { t.Errorf("Expected 2 issues in git, got %d", count) } @@ -188,7 +188,7 @@ func testDatabaseRemovalScenario(t *testing.T) { t.Fatalf("Failed to set prefix: %v", err) } - if err := importFromGit(ctx, dbPath, store, path); err != nil { + if err := importFromGit(ctx, dbPath, store, path, gitRef); err != nil { t.Fatalf("Import failed: %v", err) } @@ -244,7 +244,7 @@ func testLegacyFilenameSupport(t *testing.T) { t.Chdir(dir) // Test checkGitForIssues finds issues.jsonl - count, path := checkGitForIssues() + count, path, gitRef := checkGitForIssues() if count != 1 { t.Errorf("Expected 1 issue in git, got %d", count) } @@ -266,7 +266,7 @@ func testLegacyFilenameSupport(t *testing.T) { t.Fatalf("Failed to set prefix: %v", err) } - if err := importFromGit(ctx, dbPath, store, path); err != nil { + if err := importFromGit(ctx, dbPath, store, path, gitRef); err != nil { t.Fatalf("Import failed: %v", err) } @@ -320,7 +320,7 @@ func testPrecedenceTest(t *testing.T) { t.Chdir(dir) // Test checkGitForIssues prefers issues.jsonl - count, path := checkGitForIssues() + count, path, _ := checkGitForIssues() if count != 2 { t.Errorf("Expected 2 issues (from issues.jsonl), got %d", count) } @@ -384,7 +384,7 @@ func testInitSafetyCheck(t *testing.T) { if stats.TotalIssues == 0 { // Database is empty - check if git has issues - recheck, recheckPath := checkGitForIssues() + recheck, recheckPath, _ := checkGitForIssues() if recheck == 0 { t.Error("Safety check should have detected issues in git") }