fix(init): bootstrap from sync-branch when configured (bd-0is)
When sync-branch is configured in config.yaml, bd init now reads from that branch (origin/<branch> first, then local <branch>) 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 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/steveyegge/beads/internal/storage"
|
"github.com/steveyegge/beads/internal/storage"
|
||||||
|
"github.com/steveyegge/beads/internal/syncbranch"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
"github.com/steveyegge/beads/internal/utils"
|
"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
|
// Database is empty - check if git has issues
|
||||||
issueCount, jsonlPath := checkGitForIssues()
|
issueCount, jsonlPath, gitRef := checkGitForIssues()
|
||||||
if issueCount == 0 {
|
if issueCount == 0 {
|
||||||
// No issues in git either
|
// No issues in git either
|
||||||
return false
|
return false
|
||||||
@@ -46,7 +47,7 @@ func checkAndAutoImport(ctx context.Context, store storage.Storage) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Import from git
|
// Import from git
|
||||||
if err := importFromGit(ctx, dbPath, store, jsonlPath); err != nil {
|
if err := importFromGit(ctx, dbPath, store, jsonlPath, gitRef); err != nil {
|
||||||
if !jsonOutput {
|
if !jsonOutput {
|
||||||
fmt.Fprintf(os.Stderr, "Warning: auto-import failed: %v\n", err)
|
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 HEAD:%s | bd import -i /dev/stdin\n", jsonlPath)
|
||||||
@@ -61,28 +62,62 @@ func checkAndAutoImport(ctx context.Context, store storage.Storage) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkGitForIssues checks if git has issues in HEAD:.beads/beads.jsonl or issues.jsonl
|
// checkGitForIssues checks if git has issues in .beads/beads.jsonl or issues.jsonl
|
||||||
// Returns (issue_count, relative_jsonl_path)
|
// When sync-branch is configured, reads from that branch; otherwise reads from HEAD.
|
||||||
func checkGitForIssues() (int, string) {
|
// Returns (issue_count, relative_jsonl_path, git_ref)
|
||||||
|
func checkGitForIssues() (int, string, string) {
|
||||||
// Try to find .beads directory
|
// Try to find .beads directory
|
||||||
beadsDir := findBeadsDir()
|
beadsDir := findBeadsDir()
|
||||||
if beadsDir == "" {
|
if beadsDir == "" {
|
||||||
return 0, ""
|
return 0, "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct relative path from git root
|
// Construct relative path from git root
|
||||||
gitRoot := findGitRoot()
|
gitRoot := findGitRoot()
|
||||||
if gitRoot == "" {
|
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
|
// Clean paths to ensure consistent separators
|
||||||
beadsDir = filepath.Clean(beadsDir)
|
beadsDir = filepath.Clean(beadsDir)
|
||||||
gitRoot = filepath.Clean(gitRoot)
|
gitRoot = filepath.Clean(gitRoot)
|
||||||
|
|
||||||
relBeads, err := filepath.Rel(gitRoot, beadsDir)
|
relBeads, err := filepath.Rel(gitRoot, beadsDir)
|
||||||
if err != nil {
|
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/<branch> first (more likely to exist in fresh clones),
|
||||||
|
// then local <branch>
|
||||||
|
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)
|
// Try canonical JSONL filenames in precedence order (issues.jsonl is canonical)
|
||||||
@@ -94,17 +129,49 @@ func checkGitForIssues() (int, string) {
|
|||||||
for _, relPath := range candidates {
|
for _, relPath := range candidates {
|
||||||
// Use ToSlash for git path compatibility on Windows
|
// Use ToSlash for git path compatibility on Windows
|
||||||
gitPath := filepath.ToSlash(relPath)
|
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()
|
output, err := cmd.Output()
|
||||||
if err == nil && len(output) > 0 {
|
if err == nil && len(output) > 0 {
|
||||||
lines := bytes.Count(output, []byte("\n"))
|
lines := bytes.Count(output, []byte("\n"))
|
||||||
if lines > 0 {
|
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
|
// findBeadsDir finds the .beads directory in current or parent directories
|
||||||
@@ -155,11 +222,11 @@ func findGitRoot() string {
|
|||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
// importFromGit imports issues from git HEAD
|
// 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 string) error {
|
func importFromGit(ctx context.Context, dbFilePath string, store storage.Storage, jsonlPath, gitRef string) error {
|
||||||
// Get content from git (use ToSlash for Windows compatibility)
|
// Get content from git (use ToSlash for Windows compatibility)
|
||||||
gitPath := filepath.ToSlash(jsonlPath)
|
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()
|
jsonlData, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read from git: %w", err)
|
return fmt.Errorf("failed to read from git: %w", err)
|
||||||
|
|||||||
@@ -250,13 +250,16 @@ func TestCheckGitForIssues_NoGitRepo(t *testing.T) {
|
|||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Chdir(tmpDir)
|
t.Chdir(tmpDir)
|
||||||
|
|
||||||
count, path := checkGitForIssues()
|
count, path, gitRef := checkGitForIssues()
|
||||||
if count != 0 {
|
if count != 0 {
|
||||||
t.Errorf("Expected 0 issues, got %d", count)
|
t.Errorf("Expected 0 issues, got %d", count)
|
||||||
}
|
}
|
||||||
if path != "" {
|
if path != "" {
|
||||||
t.Errorf("Expected empty path, got %s", 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) {
|
func TestCheckGitForIssues_NoBeadsDir(t *testing.T) {
|
||||||
@@ -264,7 +267,7 @@ func TestCheckGitForIssues_NoBeadsDir(t *testing.T) {
|
|||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Chdir(tmpDir)
|
t.Chdir(tmpDir)
|
||||||
|
|
||||||
count, path := checkGitForIssues()
|
count, path, _ := checkGitForIssues()
|
||||||
if count != 0 || path != "" {
|
if count != 0 || path != "" {
|
||||||
t.Logf("No .beads dir: count=%d, path=%s (expected 0, empty)", count, path)
|
t.Logf("No .beads dir: count=%d, path=%s (expected 0, empty)", count, path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -89,9 +90,9 @@ With --stealth: configures global git settings for invisible beads usage:
|
|||||||
|
|
||||||
// auto-detect prefix from first issue in JSONL file
|
// auto-detect prefix from first issue in JSONL file
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
issueCount, jsonlPath := checkGitForIssues()
|
issueCount, jsonlPath, gitRef := checkGitForIssues()
|
||||||
if issueCount > 0 {
|
if issueCount > 0 {
|
||||||
firstIssue, err := readFirstIssueFromJSONL(jsonlPath)
|
firstIssue, err := readFirstIssueFromGit(jsonlPath, gitRef)
|
||||||
if firstIssue != nil && err == nil {
|
if firstIssue != nil && err == nil {
|
||||||
prefix = utils.ExtractIssuePrefix(firstIssue.ID)
|
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)
|
// Check if git has existing issues to import (fresh clone scenario)
|
||||||
issueCount, jsonlPath := checkGitForIssues()
|
issueCount, jsonlPath, gitRef := checkGitForIssues()
|
||||||
if issueCount > 0 {
|
if issueCount > 0 {
|
||||||
if !quiet {
|
if !quiet {
|
||||||
fmt.Fprintf(os.Stderr, "\n✓ Database initialized. Found %d issues in git, importing...\n", issueCount)
|
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 {
|
if !quiet {
|
||||||
fmt.Fprintf(os.Stderr, "Warning: auto-import failed: %v\n", err)
|
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
|
// Non-fatal - continue with empty database
|
||||||
} else if !quiet {
|
} else if !quiet {
|
||||||
@@ -1208,6 +1209,41 @@ func readFirstIssueFromJSONL(path string) (*types.Issue, error) {
|
|||||||
return nil, nil
|
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
|
// setupStealthMode configures global git settings for stealth operation
|
||||||
func setupStealthMode(verbose bool) error {
|
func setupStealthMode(verbose bool) error {
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func testFreshCloneAutoImport(t *testing.T) {
|
|||||||
// Test checkGitForIssues detects issues.jsonl
|
// Test checkGitForIssues detects issues.jsonl
|
||||||
t.Chdir(dir)
|
t.Chdir(dir)
|
||||||
|
|
||||||
count, path := checkGitForIssues()
|
count, path, gitRef := checkGitForIssues()
|
||||||
if count != 1 {
|
if count != 1 {
|
||||||
t.Errorf("Expected 1 issue in git, got %d", count)
|
t.Errorf("Expected 1 issue in git, got %d", count)
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,7 @@ func testFreshCloneAutoImport(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Import from git
|
// 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)
|
t.Fatalf("Import failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ func testDatabaseRemovalScenario(t *testing.T) {
|
|||||||
t.Chdir(dir)
|
t.Chdir(dir)
|
||||||
|
|
||||||
// Test checkGitForIssues finds issues.jsonl (canonical name)
|
// Test checkGitForIssues finds issues.jsonl (canonical name)
|
||||||
count, path := checkGitForIssues()
|
count, path, gitRef := checkGitForIssues()
|
||||||
if count != 2 {
|
if count != 2 {
|
||||||
t.Errorf("Expected 2 issues in git, got %d", count)
|
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)
|
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)
|
t.Fatalf("Import failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@ func testLegacyFilenameSupport(t *testing.T) {
|
|||||||
t.Chdir(dir)
|
t.Chdir(dir)
|
||||||
|
|
||||||
// Test checkGitForIssues finds issues.jsonl
|
// Test checkGitForIssues finds issues.jsonl
|
||||||
count, path := checkGitForIssues()
|
count, path, gitRef := checkGitForIssues()
|
||||||
if count != 1 {
|
if count != 1 {
|
||||||
t.Errorf("Expected 1 issue in git, got %d", count)
|
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)
|
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)
|
t.Fatalf("Import failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +320,7 @@ func testPrecedenceTest(t *testing.T) {
|
|||||||
t.Chdir(dir)
|
t.Chdir(dir)
|
||||||
|
|
||||||
// Test checkGitForIssues prefers issues.jsonl
|
// Test checkGitForIssues prefers issues.jsonl
|
||||||
count, path := checkGitForIssues()
|
count, path, _ := checkGitForIssues()
|
||||||
if count != 2 {
|
if count != 2 {
|
||||||
t.Errorf("Expected 2 issues (from issues.jsonl), got %d", count)
|
t.Errorf("Expected 2 issues (from issues.jsonl), got %d", count)
|
||||||
}
|
}
|
||||||
@@ -384,7 +384,7 @@ func testInitSafetyCheck(t *testing.T) {
|
|||||||
|
|
||||||
if stats.TotalIssues == 0 {
|
if stats.TotalIssues == 0 {
|
||||||
// Database is empty - check if git has issues
|
// Database is empty - check if git has issues
|
||||||
recheck, recheckPath := checkGitForIssues()
|
recheck, recheckPath, _ := checkGitForIssues()
|
||||||
if recheck == 0 {
|
if recheck == 0 {
|
||||||
t.Error("Safety check should have detected issues in git")
|
t.Error("Safety check should have detected issues in git")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user