Files
beads/cmd/bd/sync_branch_priority_test.go
Steve Yegge cf4aff1cb4 fix: bypass gitHasBeadsChanges when using sync-branch (#812)
When using sync-branch with aggressive gitignore on code branches
(.beads/* ignored), gitHasBeadsChanges() returns false because git
does not track the files. This caused bd sync to skip CommitToSyncBranch
entirely, even though CommitToSyncBranch has its own internal change
detection that checks the worktree where gitignore is different.

The fix bypasses gitHasBeadsChanges when useSyncBranch is true, letting
CommitToSyncBranch determine if there are actual changes in the worktree.

Closes #812

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 18:04:26 -08:00

252 lines
8.6 KiB
Go

package main
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/syncbranch"
)
// TestSyncBranchConfigPriorityOverUpstream tests that when sync.branch is configured,
// bd sync should NOT fall back to --from-main mode even if the current branch has no upstream.
// This is the regression test for GH#638.
func TestSyncBranchConfigPriorityOverUpstream(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ctx := context.Background()
t.Run("sync.branch configured without upstream should not fallback to from-main", func(t *testing.T) {
// Setup: Create a git repo with no upstream tracking
tmpDir, cleanup := setupGitRepo(t)
defer cleanup()
// Create beads database and configure sync.branch
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
dbPath := filepath.Join(beadsDir, "beads.db")
testStore, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
defer testStore.Close()
// Configure sync.branch
if err := syncbranch.Set(ctx, testStore, "beads-sync"); err != nil {
t.Fatalf("Failed to set sync.branch: %v", err)
}
// Verify sync.branch is configured
syncBranch, err := syncbranch.Get(ctx, testStore)
if err != nil {
t.Fatalf("Failed to get sync.branch: %v", err)
}
if syncBranch != "beads-sync" {
t.Errorf("Expected sync.branch='beads-sync', got %q", syncBranch)
}
// Verify we have no upstream
if gitHasUpstream() {
t.Skip("Test requires no upstream tracking")
}
// The key assertion: hasSyncBranchConfig should be true
// which prevents fallback to from-main mode
var hasSyncBranchConfig bool
if syncBranch != "" {
hasSyncBranchConfig = true
}
if !hasSyncBranchConfig {
t.Error("hasSyncBranchConfig should be true when sync.branch is configured")
}
// With the fix, this condition should be false (should NOT fallback)
shouldFallbackToFromMain := !gitHasUpstream() && !hasSyncBranchConfig
if shouldFallbackToFromMain {
t.Error("Should NOT fallback to from-main when sync.branch is configured")
}
})
t.Run("no sync.branch and no upstream should fallback to from-main", func(t *testing.T) {
// Setup: Create a git repo with no upstream tracking
_, cleanup := setupGitRepo(t)
defer cleanup()
// No sync.branch configured, no upstream
hasSyncBranchConfig := false
// Verify we have no upstream
if gitHasUpstream() {
t.Skip("Test requires no upstream tracking")
}
// With no sync.branch, should fallback to from-main
shouldFallbackToFromMain := !gitHasUpstream() && !hasSyncBranchConfig
if !shouldFallbackToFromMain {
t.Error("Should fallback to from-main when no sync.branch and no upstream")
}
})
t.Run("detached HEAD with sync.branch should not fallback", func(t *testing.T) {
// Setup: Create a git repo and detach HEAD (simulating jj workflow)
tmpDir, cleanup := setupGitRepo(t)
defer cleanup()
// Get current commit hash
cmd := exec.Command("git", "rev-parse", "HEAD")
output, err := cmd.Output()
if err != nil {
t.Fatalf("Failed to get HEAD: %v", err)
}
commitHash := string(output[:len(output)-1]) // trim newline
// Detach HEAD
cmd = exec.Command("git", "checkout", "--detach", commitHash)
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to detach HEAD: %v", err)
}
// Create beads database and configure sync.branch
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
dbPath := filepath.Join(beadsDir, "beads.db")
testStore, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
defer testStore.Close()
// Configure sync.branch
if err := syncbranch.Set(ctx, testStore, "beads-sync"); err != nil {
t.Fatalf("Failed to set sync.branch: %v", err)
}
// Verify detached HEAD has no upstream
if gitHasUpstream() {
t.Error("Detached HEAD should not have upstream")
}
// With sync.branch configured, should NOT fallback
hasSyncBranchConfig := true
shouldFallbackToFromMain := !gitHasUpstream() && !hasSyncBranchConfig
if shouldFallbackToFromMain {
t.Error("Detached HEAD with sync.branch should NOT fallback to from-main")
}
})
}
// TestSyncBranchBypassesGitHasBeadsChanges tests that when sync.branch is configured,
// bd sync bypasses gitHasBeadsChanges and always calls CommitToSyncBranch.
// This is the regression test for GH#812: when .beads/ is gitignored on code branches
// (but tracked on the sync branch), gitHasBeadsChanges would return false, causing
// sync to skip CommitToSyncBranch entirely - even though CommitToSyncBranch has its
// own internal change detection that checks the worktree where gitignore is different.
func TestSyncBranchBypassesGitHasBeadsChanges(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ctx := context.Background()
t.Run("CommitToSyncBranch detects changes even when main repo has gitignored beads", func(t *testing.T) {
// Setup: Create a git repo with sync-branch configured
tmpDir, cleanup := setupGitRepo(t)
defer cleanup()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
// Create a sync branch with initial content
syncBranch := "beads-sync"
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
// Write initial content
if err := os.WriteFile(jsonlPath, []byte(`{"id":"test-1","title":"Initial"}`+"\n"), 0600); err != nil {
t.Fatalf("Failed to write initial JSONL: %v", err)
}
// Create sync branch and commit the initial content
if err := exec.Command("git", "checkout", "-b", syncBranch).Run(); err != nil {
t.Fatalf("Failed to create sync branch: %v", err)
}
if err := exec.Command("git", "add", ".").Run(); err != nil {
t.Fatalf("Failed to add files: %v", err)
}
if err := exec.Command("git", "commit", "-m", "initial sync").Run(); err != nil {
t.Fatalf("Failed to commit: %v", err)
}
if err := exec.Command("git", "checkout", "main").Run(); err != nil {
t.Fatalf("Failed to checkout main: %v", err)
}
// Recreate .beads directory on main branch (it may not exist after checkout)
if err := os.MkdirAll(beadsDir, 0750); err != nil {
t.Fatalf("Failed to recreate .beads dir: %v", err)
}
// Now add a gitignore that ignores all beads files on the code branch
// (simulating the pattern from GH#812)
gitignorePath := filepath.Join(beadsDir, ".gitignore")
gitignoreContent := `# On code branches, ignore all data files.
# The beads-sync branch tracks issues.jsonl, etc.
*
!.gitignore
`
if err := os.WriteFile(gitignorePath, []byte(gitignoreContent), 0600); err != nil {
t.Fatalf("Failed to write .gitignore: %v", err)
}
if err := exec.Command("git", "add", gitignorePath).Run(); err != nil {
t.Fatalf("Failed to add .gitignore: %v", err)
}
if err := exec.Command("git", "commit", "-m", "add gitignore").Run(); err != nil {
t.Fatalf("Failed to commit .gitignore: %v", err)
}
// Now update the JSONL file (this change is gitignored on main branch)
newContent := `{"id":"test-1","title":"Initial"}` + "\n" + `{"id":"test-2","title":"New issue"}` + "\n"
if err := os.WriteFile(jsonlPath, []byte(newContent), 0600); err != nil {
t.Fatalf("Failed to update JSONL: %v", err)
}
// Verify gitHasBeadsChanges returns false (file is gitignored)
hasChanges, err := gitHasBeadsChanges(ctx)
if err != nil {
t.Fatalf("gitHasBeadsChanges error: %v", err)
}
if hasChanges {
t.Log("Note: gitHasBeadsChanges returned true (gitignore may not be working as expected in test env)")
// Continue test anyway - the key assertion is that CommitToSyncBranch works
} else {
t.Log("gitHasBeadsChanges correctly returned false (file is gitignored)")
}
// The key assertion: CommitToSyncBranch should still detect and commit changes
// because it checks the worktree where the gitignore is different
result, err := syncbranch.CommitToSyncBranch(ctx, tmpDir, syncBranch, jsonlPath, false)
if err != nil {
t.Fatalf("CommitToSyncBranch error: %v", err)
}
if !result.Committed {
t.Error("CommitToSyncBranch() Committed = false, want true")
t.Error("This indicates the GH#812 fix regression: sync branch worktree should detect changes even when main repo has files gitignored")
} else {
t.Log("CommitToSyncBranch correctly detected and committed changes to worktree")
}
})
}