feat: Respect BEADS_SYNC_BRANCH environment variable

Fixes daemon and bd sync to honor BEADS_SYNC_BRANCH environment variable
as documented in PROTECTED_BRANCHES.md for CI/CD temporary overrides.

Changes:
- Updated internal/syncbranch.Get() to prioritize env var over DB config
- Both daemon sync and bd sync CLI now use syncbranch.Get()
- Added comprehensive tests for env var override behavior
- Validates branch names using git-style rules

This enables CI/CD workflows to override sync branch per-job without
mutating database config.

Based on PR #364 by Charles P. Cross <cpdata@users.noreply.github.com>
Co-authored-by: Charles P. Cross <cpdata@users.noreply.github.com>
This commit is contained in:
Steve Yegge
2025-11-22 18:17:19 -08:00
parent 72aa0d1097
commit 0dc8452c56
6 changed files with 220 additions and 69 deletions

File diff suppressed because one or more lines are too long

View File

@@ -11,6 +11,7 @@ import (
"github.com/steveyegge/beads/internal/git" "github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/syncbranch"
) )
// syncBranchCommitAndPush commits JSONL to the sync branch using a worktree // syncBranchCommitAndPush commits JSONL to the sync branch using a worktree
@@ -21,13 +22,13 @@ func syncBranchCommitAndPush(ctx context.Context, store storage.Storage, autoPus
return true, nil // Skip sync branch commit/push in local-only mode return true, nil // Skip sync branch commit/push in local-only mode
} }
// Get sync.branch config // Get sync branch configuration (supports BEADS_SYNC_BRANCH override)
syncBranch, err := store.GetConfig(ctx, "sync.branch") syncBranch, err := syncbranch.Get(ctx, store)
if err != nil { if err != nil {
return false, fmt.Errorf("failed to get sync.branch config: %w", err) return false, fmt.Errorf("failed to get sync branch: %w", err)
} }
// If no sync.branch configured, caller should use regular commit logic // If no sync branch configured, caller should use regular commit logic
if syncBranch == "" { if syncBranch == "" {
return false, nil return false, nil
} }
@@ -189,13 +190,13 @@ func syncBranchPull(ctx context.Context, store storage.Storage, log daemonLogger
return true, nil // Skip sync branch pull in local-only mode return true, nil // Skip sync branch pull in local-only mode
} }
// Get sync.branch config // Get sync branch configuration (supports BEADS_SYNC_BRANCH override)
syncBranch, err := store.GetConfig(ctx, "sync.branch") syncBranch, err := syncbranch.Get(ctx, store)
if err != nil { if err != nil {
return false, fmt.Errorf("failed to get sync.branch config: %w", err) return false, fmt.Errorf("failed to get sync branch: %w", err)
} }
// If no sync.branch configured, caller should use regular pull logic // If no sync branch configured, caller should use regular pull logic
if syncBranch == "" { if syncBranch == "" {
return false, nil return false, nil
} }

View File

@@ -14,6 +14,7 @@ import (
"time" "time"
"github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/syncbranch"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
) )
@@ -200,6 +201,99 @@ func TestSyncBranchCommitAndPush_Success(t *testing.T) {
} }
} }
// TestSyncBranchCommitAndPush_EnvOverridesDB verifies that BEADS_SYNC_BRANCH
// takes precedence over the sync.branch database config for daemon commits.
func TestSyncBranchCommitAndPush_EnvOverridesDB(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
tmpDir := t.TempDir()
initTestGitRepo(t, tmpDir)
// Setup test store
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
dbPath := filepath.Join(beadsDir, "test.db")
store, err := sqlite.New(context.Background(), dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set prefix: %v", err)
}
// Configure DB sync.branch to one value
if err := store.SetConfig(ctx, "sync.branch", "db-branch"); err != nil {
t.Fatalf("Failed to set sync.branch: %v", err)
}
// Set BEADS_SYNC_BRANCH to a different value and ensure it takes precedence.
t.Setenv(syncbranch.EnvVar, "env-branch")
// Initial commit on main branch
oldWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer os.Chdir(oldWd)
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change directory: %v", err)
}
initMainBranch(t, tmpDir)
// Create test issue and export JSONL
issue := &types.Issue{
Title: "Env override issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Failed to export: %v", err)
}
log, _ := newTestSyncBranchLogger()
committed, err := syncBranchCommitAndPush(ctx, store, false, log)
if err != nil {
t.Fatalf("syncBranchCommitAndPush failed: %v", err)
}
if !committed {
t.Fatal("Expected committed=true with env override")
}
// Verify that the worktree and branch are created using the env branch.
worktreePath := filepath.Join(tmpDir, ".git", "beads-worktrees", "env-branch")
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
t.Fatalf("Env sync branch worktree not created at %s", worktreePath)
}
cmd := exec.Command("git", "branch", "--list", "env-branch")
cmd.Dir = tmpDir
output, err := cmd.Output()
if err != nil {
t.Fatalf("Failed to list branches: %v", err)
}
if !strings.Contains(string(output), "env-branch") {
t.Errorf("Env sync branch not created, branches: %s", string(output))
}
}
// TestSyncBranchCommitAndPush_NoChanges tests behavior when no changes to commit // TestSyncBranchCommitAndPush_NoChanges tests behavior when no changes to commit
func TestSyncBranchCommitAndPush_NoChanges(t *testing.T) { func TestSyncBranchCommitAndPush_NoChanges(t *testing.T) {
if testing.Short() { if testing.Short() {

View File

@@ -13,6 +13,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/syncbranch"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
) )
@@ -693,9 +694,9 @@ func getSyncBranch(ctx context.Context) (string, error) {
return "", fmt.Errorf("failed to initialize store: %w", err) return "", fmt.Errorf("failed to initialize store: %w", err)
} }
syncBranch, err := store.GetConfig(ctx, "sync.branch") syncBranch, err := syncbranch.Get(ctx, store)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get sync.branch config: %w", err) return "", fmt.Errorf("failed to get sync branch config: %w", err)
} }
if syncBranch == "" { if syncBranch == "" {

View File

@@ -7,6 +7,9 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/syncbranch"
) )
func TestIsGitRepo_InGitRepo(t *testing.T) { func TestIsGitRepo_InGitRepo(t *testing.T) {
@@ -386,3 +389,55 @@ func TestMergeSyncBranch_DirtyWorkingTree(t *testing.T) {
t.Error("expected dirty working tree for test setup") t.Error("expected dirty working tree for test setup")
} }
} }
func TestGetSyncBranch_EnvOverridesDB(t *testing.T) {
ctx := context.Background()
// Save and restore global store state
oldStore := store
storeMutex.Lock()
oldStoreActive := storeActive
storeMutex.Unlock()
oldDBPath := dbPath
// Use an in-memory SQLite store for testing
testStore, err := sqlite.New(context.Background(), "file::memory:?mode=memory&cache=private")
if err != nil {
t.Fatalf("failed to create test store: %v", err)
}
defer testStore.Close()
// Seed DB config and globals
if err := testStore.SetConfig(ctx, "sync.branch", "db-branch"); err != nil {
t.Fatalf("failed to set sync.branch in db: %v", err)
}
storeMutex.Lock()
store = testStore
storeActive = true
storeMutex.Unlock()
dbPath = "" // avoid FindDatabasePath in ensureStoreActive
// Set environment override
if err := os.Setenv(syncbranch.EnvVar, "env-branch"); err != nil {
t.Fatalf("failed to set %s: %v", syncbranch.EnvVar, err)
}
defer os.Unsetenv(syncbranch.EnvVar)
// Ensure we restore globals after the test
defer func() {
storeMutex.Lock()
store = oldStore
storeActive = oldStoreActive
storeMutex.Unlock()
dbPath = oldDBPath
}()
branch, err := getSyncBranch(ctx)
if err != nil {
t.Fatalf("getSyncBranch() error = %v", err)
}
if branch != "env-branch" {
t.Errorf("getSyncBranch() = %q, want %q (env override)", branch, "env-branch")
}
}

View File

@@ -12,7 +12,7 @@ import (
const ( const (
// ConfigKey is the database config key for sync branch // ConfigKey is the database config key for sync branch
ConfigKey = "sync.branch" ConfigKey = "sync.branch"
// EnvVar is the environment variable for sync branch // EnvVar is the environment variable for sync branch
EnvVar = "BEADS_SYNC_BRANCH" EnvVar = "BEADS_SYNC_BRANCH"
) )
@@ -26,32 +26,32 @@ func ValidateBranchName(name string) error {
if name == "" { if name == "" {
return nil // Empty is valid (means use current branch) return nil // Empty is valid (means use current branch)
} }
// Basic length check // Basic length check
if len(name) > 255 { if len(name) > 255 {
return fmt.Errorf("branch name too long (max 255 characters)") return fmt.Errorf("branch name too long (max 255 characters)")
} }
// Check pattern // Check pattern
if !branchNamePattern.MatchString(name) { if !branchNamePattern.MatchString(name) {
return fmt.Errorf("invalid branch name: must start and end with alphanumeric, can contain .-_/ in middle") return fmt.Errorf("invalid branch name: must start and end with alphanumeric, can contain .-_/ in middle")
} }
// Disallow certain patterns // Disallow certain patterns
if name == "HEAD" || name == "." || name == ".." { if name == "HEAD" || name == "." || name == ".." {
return fmt.Errorf("invalid branch name: %s is reserved", name) return fmt.Errorf("invalid branch name: %s is reserved", name)
} }
// No consecutive dots // No consecutive dots
if regexp.MustCompile(`\.\.`).MatchString(name) { if regexp.MustCompile(`\.\.`).MatchString(name) {
return fmt.Errorf("invalid branch name: cannot contain '..'") return fmt.Errorf("invalid branch name: cannot contain '..'")
} }
// No leading/trailing slashes // No leading/trailing slashes
if name[0] == '/' || name[len(name)-1] == '/' { if name[0] == '/' || name[len(name)-1] == '/' {
return fmt.Errorf("invalid branch name: cannot start or end with '/'") return fmt.Errorf("invalid branch name: cannot start or end with '/'")
} }
return nil return nil
} }
@@ -67,19 +67,19 @@ func Get(ctx context.Context, store storage.Storage) (string, error) {
} }
return envBranch, nil return envBranch, nil
} }
// Check database config // Check database config
dbBranch, err := store.GetConfig(ctx, ConfigKey) dbBranch, err := store.GetConfig(ctx, ConfigKey)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get %s from config: %w", ConfigKey, err) return "", fmt.Errorf("failed to get %s from config: %w", ConfigKey, err)
} }
if dbBranch != "" { if dbBranch != "" {
if err := ValidateBranchName(dbBranch); err != nil { if err := ValidateBranchName(dbBranch); err != nil {
return "", fmt.Errorf("invalid %s in database: %w", ConfigKey, err) return "", fmt.Errorf("invalid %s in database: %w", ConfigKey, err)
} }
} }
return dbBranch, nil return dbBranch, nil
} }
@@ -88,7 +88,7 @@ func Set(ctx context.Context, store storage.Storage, branch string) error {
if err := ValidateBranchName(branch); err != nil { if err := ValidateBranchName(branch); err != nil {
return err return err
} }
return store.SetConfig(ctx, ConfigKey, branch) return store.SetConfig(ctx, ConfigKey, branch)
} }