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:
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user