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:
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/steveyegge/beads/internal/git"
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
"github.com/steveyegge/beads/internal/syncbranch"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Get sync.branch config
|
||||
syncBranch, err := store.GetConfig(ctx, "sync.branch")
|
||||
// Get sync branch configuration (supports BEADS_SYNC_BRANCH override)
|
||||
syncBranch, err := syncbranch.Get(ctx, store)
|
||||
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 == "" {
|
||||
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
|
||||
}
|
||||
|
||||
// Get sync.branch config
|
||||
syncBranch, err := store.GetConfig(ctx, "sync.branch")
|
||||
// Get sync branch configuration (supports BEADS_SYNC_BRANCH override)
|
||||
syncBranch, err := syncbranch.Get(ctx, store)
|
||||
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 == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/syncbranch"
|
||||
"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
|
||||
func TestSyncBranchCommitAndPush_NoChanges(t *testing.T) {
|
||||
if testing.Short() {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
"github.com/steveyegge/beads/internal/syncbranch"
|
||||
"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)
|
||||
}
|
||||
|
||||
syncBranch, err := store.GetConfig(ctx, "sync.branch")
|
||||
syncBranch, err := syncbranch.Get(ctx, store)
|
||||
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 == "" {
|
||||
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/syncbranch"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user