diff --git a/cmd/bd/sync_branch.go b/cmd/bd/sync_branch.go index a82d3afa..ffd376e2 100644 --- a/cmd/bd/sync_branch.go +++ b/cmd/bd/sync_branch.go @@ -22,6 +22,18 @@ func getCurrentBranch(ctx context.Context) (string, error) { return strings.TrimSpace(string(output)), nil } +// getCurrentBranchOrHEAD returns the current branch name, or "HEAD" if in detached HEAD state. +// This is useful for jj/jujutsu compatibility where HEAD is always detached but we still +// need a reference for git operations like log and diff. +func getCurrentBranchOrHEAD(ctx context.Context) (string, error) { + branch, err := getCurrentBranch(ctx) + if err != nil { + // Detached HEAD - return "HEAD" as the reference + return "HEAD", nil + } + return branch, nil +} + // getSyncBranch returns the configured sync branch name func getSyncBranch(ctx context.Context) (string, error) { // Ensure store is initialized @@ -47,7 +59,7 @@ func showSyncStatus(ctx context.Context) error { return fmt.Errorf("not in a git repository") } - currentBranch, err := getCurrentBranch(ctx) + currentBranch, err := getCurrentBranchOrHEAD(ctx) if err != nil { return err } diff --git a/cmd/bd/sync_git_test.go b/cmd/bd/sync_git_test.go index 3a0e305a..2bbecce9 100644 --- a/cmd/bd/sync_git_test.go +++ b/cmd/bd/sync_git_test.go @@ -554,3 +554,83 @@ func TestGitBranchHasUpstream(t *testing.T) { } }) } + +// TestGetCurrentBranchOrHEAD tests getCurrentBranchOrHEAD which returns "HEAD" +// when in detached HEAD state (e.g., jj/jujutsu) instead of failing. +func TestGetCurrentBranchOrHEAD(t *testing.T) { + ctx := context.Background() + + // Create temp directory for test repo + tmpDir, err := os.MkdirTemp("", "beads-branch-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Initialize git repo + cmds := [][]string{ + {"git", "init", "-b", "main"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + } + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = tmpDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("Failed to run %v: %v\n%s", args, err, out) + } + } + + // Create initial commit + testFile := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + cmds = [][]string{ + {"git", "add", "test.txt"}, + {"git", "commit", "-m", "initial"}, + } + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = tmpDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("Failed to run %v: %v\n%s", args, err, out) + } + } + + // Save current dir and change to test repo + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + defer os.Chdir(origDir) + + // Test 1: Normal branch returns branch name + t.Run("returns branch name when on branch", func(t *testing.T) { + branch, err := getCurrentBranchOrHEAD(ctx) + if err != nil { + t.Errorf("getCurrentBranchOrHEAD() error = %v", err) + } + if branch != "main" { + t.Errorf("getCurrentBranchOrHEAD() = %q, want %q", branch, "main") + } + }) + + // Test 2: Detached HEAD returns "HEAD" + t.Run("returns HEAD when detached", func(t *testing.T) { + // Detach HEAD + cmd := exec.Command("git", "checkout", "--detach", "HEAD") + cmd.Dir = tmpDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("Failed to detach HEAD: %v\n%s", err, out) + } + + branch, err := getCurrentBranchOrHEAD(ctx) + if err != nil { + t.Errorf("getCurrentBranchOrHEAD() error = %v", err) + } + if branch != "HEAD" { + t.Errorf("getCurrentBranchOrHEAD() = %q, want %q", branch, "HEAD") + } + }) +}