feat: add bd migrate-sync command for sync.branch workflow setup (bd-epn)
Add a new command that encapsulates all the work needed to migrate a clone to use the sync.branch workflow for multi-clone setups like Gas Town: - Validates current state (not on sync branch, not already configured) - Creates sync branch if it doesn't exist (from remote or locally) - Sets up git worktree for the sync branch - Syncs current beads data to worktree - Commits initial state to sync branch - Sets sync.branch configuration - Pushes sync branch to remote Usage: bd migrate-sync beads-sync # Basic migration bd migrate-sync beads-sync --dry-run # Preview changes bd migrate-sync beads-sync --force # Reconfigure even if set
This commit is contained in:
330
cmd/bd/migrate_sync.go
Normal file
330
cmd/bd/migrate_sync.go
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/beads/internal/git"
|
||||||
|
"github.com/steveyegge/beads/internal/syncbranch"
|
||||||
|
)
|
||||||
|
|
||||||
|
var migrateSyncCmd = &cobra.Command{
|
||||||
|
Use: "migrate-sync <branch-name>",
|
||||||
|
Short: "Migrate to sync.branch workflow for multi-clone setups",
|
||||||
|
Long: `Migrate to using a dedicated sync branch for beads data.
|
||||||
|
|
||||||
|
This command configures the repository to commit .beads changes to a separate
|
||||||
|
branch (e.g., "beads-sync") instead of the current working branch. This is
|
||||||
|
essential for multi-clone setups like Gas Town where multiple clones work
|
||||||
|
independently but need to sync beads data.
|
||||||
|
|
||||||
|
The command will:
|
||||||
|
1. Validate the current state (not already configured, not on sync branch)
|
||||||
|
2. Create the sync branch if it doesn't exist (from remote or locally)
|
||||||
|
3. Set up the git worktree for the sync branch
|
||||||
|
4. Set the sync.branch configuration
|
||||||
|
|
||||||
|
After migration, 'bd sync' will commit beads changes to the sync branch via
|
||||||
|
a git worktree, keeping your working branch clean of beads commits.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Basic migration to beads-sync branch
|
||||||
|
bd migrate-sync beads-sync
|
||||||
|
|
||||||
|
# Preview what would happen without making changes
|
||||||
|
bd migrate-sync beads-sync --dry-run
|
||||||
|
|
||||||
|
# Force migration even if already configured
|
||||||
|
bd migrate-sync beads-sync --force`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
ctx := rootCtx
|
||||||
|
branchName := args[0]
|
||||||
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||||
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
|
||||||
|
if err := runMigrateSync(ctx, branchName, dryRun, force); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
migrateSyncCmd.Flags().Bool("dry-run", false, "Preview migration without making changes")
|
||||||
|
migrateSyncCmd.Flags().Bool("force", false, "Force migration even if already configured")
|
||||||
|
rootCmd.AddCommand(migrateSyncCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMigrateSync(ctx context.Context, branchName string, dryRun, force bool) error {
|
||||||
|
// Validate branch name
|
||||||
|
if err := syncbranch.ValidateBranchName(branchName); err != nil {
|
||||||
|
return fmt.Errorf("invalid branch name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're in a git repository
|
||||||
|
if !isGitRepo() {
|
||||||
|
return fmt.Errorf("not in a git repository")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure store is initialized for config operations
|
||||||
|
if err := ensureDirectMode("migrate-sync requires direct database access"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current branch
|
||||||
|
currentBranch, err := getCurrentBranch(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current branch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already on the sync branch
|
||||||
|
if currentBranch == branchName {
|
||||||
|
return fmt.Errorf("currently on branch '%s' - switch to your main working branch first (e.g., 'git checkout main')", branchName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if sync.branch is already configured
|
||||||
|
existingSyncBranch, err := syncbranch.Get(ctx, store)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check existing config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingSyncBranch != "" && !force {
|
||||||
|
if existingSyncBranch == branchName {
|
||||||
|
fmt.Printf("✓ Already configured to use sync branch '%s'\n", branchName)
|
||||||
|
fmt.Println(" Use --force to reconfigure anyway")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("sync.branch already configured as '%s' (use --force to change to '%s')", existingSyncBranch, branchName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have a remote
|
||||||
|
hasRemote := hasGitRemote(ctx)
|
||||||
|
if !hasRemote {
|
||||||
|
fmt.Println("⚠ Warning: No git remote configured. Sync branch will only exist locally.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get repo root
|
||||||
|
repoRoot, err := syncbranch.GetRepoRoot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get repository root: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find JSONL path
|
||||||
|
jsonlPath := findJSONLPath()
|
||||||
|
if jsonlPath == "" {
|
||||||
|
return fmt.Errorf("not in a bd workspace (no .beads directory found)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if sync branch exists (locally or remotely)
|
||||||
|
branchExistsLocally := branchExistsLocal(ctx, branchName)
|
||||||
|
branchExistsRemotely := branchExistsRemote(ctx, branchName)
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Println("=== DRY RUN - No changes will be made ===")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("Current branch: %s\n", currentBranch)
|
||||||
|
fmt.Printf("Sync branch: %s\n", branchName)
|
||||||
|
fmt.Printf("Repository root: %s\n", repoRoot)
|
||||||
|
fmt.Printf("JSONL path: %s\n", jsonlPath)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if existingSyncBranch != "" {
|
||||||
|
fmt.Printf("→ Would change sync.branch from '%s' to '%s'\n", existingSyncBranch, branchName)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("→ Would set sync.branch to '%s'\n", branchName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if branchExistsLocally {
|
||||||
|
fmt.Printf("→ Branch '%s' exists locally\n", branchName)
|
||||||
|
} else if branchExistsRemotely {
|
||||||
|
fmt.Printf("→ Would create local branch '%s' from remote\n", branchName)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("→ Would create new branch '%s'\n", branchName)
|
||||||
|
}
|
||||||
|
|
||||||
|
worktreePath := filepath.Join(repoRoot, ".git", "beads-worktrees", branchName)
|
||||||
|
fmt.Printf("→ Would create worktree at: %s\n", worktreePath)
|
||||||
|
|
||||||
|
fmt.Println("\n=== END DRY RUN ===")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Create the sync branch if it doesn't exist
|
||||||
|
fmt.Printf("→ Setting up sync branch '%s'...\n", branchName)
|
||||||
|
|
||||||
|
if !branchExistsLocally && !branchExistsRemotely {
|
||||||
|
// Create new branch from current HEAD
|
||||||
|
fmt.Printf(" Creating new branch '%s'...\n", branchName)
|
||||||
|
createCmd := exec.CommandContext(ctx, "git", "branch", branchName)
|
||||||
|
if output, err := createCmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to create branch: %w\n%s", err, output)
|
||||||
|
}
|
||||||
|
} else if !branchExistsLocally && branchExistsRemotely {
|
||||||
|
// Fetch and create local tracking branch
|
||||||
|
fmt.Printf(" Fetching remote branch '%s'...\n", branchName)
|
||||||
|
fetchCmd := exec.CommandContext(ctx, "git", "fetch", "origin", branchName)
|
||||||
|
if output, err := fetchCmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to fetch remote branch: %w\n%s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create local branch tracking remote
|
||||||
|
createCmd := exec.CommandContext(ctx, "git", "branch", branchName, "origin/"+branchName)
|
||||||
|
if output, err := createCmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to create local tracking branch: %w\n%s", err, output)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" Branch '%s' already exists locally\n", branchName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Create the worktree
|
||||||
|
worktreePath := filepath.Join(repoRoot, ".git", "beads-worktrees", branchName)
|
||||||
|
fmt.Printf("→ Creating worktree at %s...\n", worktreePath)
|
||||||
|
|
||||||
|
wtMgr := git.NewWorktreeManager(repoRoot)
|
||||||
|
if err := wtMgr.CreateBeadsWorktree(branchName, worktreePath); err != nil {
|
||||||
|
return fmt.Errorf("failed to create worktree: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Sync current JSONL to worktree
|
||||||
|
fmt.Println("→ Syncing current beads data to worktree...")
|
||||||
|
|
||||||
|
jsonlRelPath, err := filepath.Rel(repoRoot, jsonlPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get relative JSONL path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := wtMgr.SyncJSONLToWorktree(worktreePath, jsonlRelPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to sync JSONL to worktree: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also sync other beads files
|
||||||
|
beadsDir := filepath.Dir(jsonlPath)
|
||||||
|
for _, filename := range []string{"deletions.jsonl", "metadata.json"} {
|
||||||
|
srcPath := filepath.Join(beadsDir, filename)
|
||||||
|
if _, err := os.Stat(srcPath); err == nil {
|
||||||
|
relPath, err := filepath.Rel(repoRoot, srcPath)
|
||||||
|
if err == nil {
|
||||||
|
_ = wtMgr.SyncJSONLToWorktree(worktreePath, relPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Commit initial state to sync branch if there are changes
|
||||||
|
fmt.Println("→ Committing initial state to sync branch...")
|
||||||
|
|
||||||
|
worktreeJSONLPath := filepath.Join(worktreePath, jsonlRelPath)
|
||||||
|
hasChanges, err := hasChangesInWorktreeDir(ctx, worktreePath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" Warning: failed to check for changes: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasChanges {
|
||||||
|
if err := commitInitialSyncState(ctx, worktreePath, jsonlRelPath); err != nil {
|
||||||
|
fmt.Printf(" Warning: failed to commit initial state: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println(" Initial state committed to sync branch")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Check if .beads directory exists in worktree but no changes
|
||||||
|
worktreeBeadsDir := filepath.Join(worktreePath, ".beads")
|
||||||
|
if _, err := os.Stat(worktreeBeadsDir); os.IsNotExist(err) {
|
||||||
|
// .beads doesn't exist in worktree - this is a fresh setup
|
||||||
|
fmt.Println(" No existing beads data in sync branch")
|
||||||
|
} else {
|
||||||
|
fmt.Println(" Sync branch already has current beads data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = worktreeJSONLPath // silence unused warning
|
||||||
|
|
||||||
|
// Step 5: Set sync.branch config
|
||||||
|
fmt.Printf("→ Setting sync.branch to '%s'...\n", branchName)
|
||||||
|
|
||||||
|
if err := syncbranch.Set(ctx, store, branchName); err != nil {
|
||||||
|
return fmt.Errorf("failed to set sync.branch config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Push sync branch to remote if we have one
|
||||||
|
if hasRemote {
|
||||||
|
fmt.Printf("→ Pushing sync branch '%s' to remote...\n", branchName)
|
||||||
|
pushCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "push", "--set-upstream", "origin", branchName)
|
||||||
|
output, err := pushCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
// Non-fatal - branch might already be up to date or push might fail for other reasons
|
||||||
|
if !strings.Contains(string(output), "Everything up-to-date") {
|
||||||
|
fmt.Printf(" Warning: failed to push sync branch: %v\n", err)
|
||||||
|
fmt.Printf(" You may need to push manually: git push -u origin %s\n", branchName)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" Pushed '%s' to origin\n", branchName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("✓ Migration complete!")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf(" sync.branch: %s\n", branchName)
|
||||||
|
fmt.Printf(" worktree: %s\n", worktreePath)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Next steps:")
|
||||||
|
fmt.Println(" • 'bd sync' will now commit beads changes to the sync branch")
|
||||||
|
fmt.Println(" • Your working branch stays clean of beads commits")
|
||||||
|
fmt.Println(" • Other clones should also run 'bd migrate-sync " + branchName + "'")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// branchExistsLocal checks if a branch exists locally
|
||||||
|
func branchExistsLocal(ctx context.Context, branch string) bool {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// branchExistsRemote checks if a branch exists on origin remote
|
||||||
|
func branchExistsRemote(ctx context.Context, branch string) bool {
|
||||||
|
// First fetch to ensure we have latest remote refs
|
||||||
|
fetchCmd := exec.CommandContext(ctx, "git", "fetch", "origin", "--prune")
|
||||||
|
_ = fetchCmd.Run() // Best effort
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/remotes/origin/"+branch)
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasChangesInWorktreeDir checks if there are any uncommitted changes in the worktree
|
||||||
|
func hasChangesInWorktreeDir(ctx context.Context, worktreePath string) (bool, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "status", "--porcelain")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("git status failed: %w", err)
|
||||||
|
}
|
||||||
|
return len(strings.TrimSpace(string(output))) > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitInitialSyncState commits the initial beads state to the sync branch
|
||||||
|
func commitInitialSyncState(ctx context.Context, worktreePath, jsonlRelPath string) error {
|
||||||
|
beadsRelDir := filepath.Dir(jsonlRelPath)
|
||||||
|
|
||||||
|
// Stage all beads files
|
||||||
|
addCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "add", beadsRelDir)
|
||||||
|
if err := addCmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("git add failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit
|
||||||
|
commitCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "commit", "--no-verify", "-m", "bd migrate-sync: initial sync branch setup")
|
||||||
|
output, err := commitCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
// Check if there's nothing to commit
|
||||||
|
if strings.Contains(string(output), "nothing to commit") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("git commit failed: %w\n%s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
180
cmd/bd/migrate_sync_test.go
Normal file
180
cmd/bd/migrate_sync_test.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMigrateSyncValidation(t *testing.T) {
|
||||||
|
// Test invalid branch names
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
branch string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid simple", "beads-sync", false},
|
||||||
|
{"valid with slash", "beads/sync", false},
|
||||||
|
{"valid with dots", "beads.sync", false},
|
||||||
|
{"invalid empty", "", true},
|
||||||
|
{"invalid HEAD", "HEAD", true},
|
||||||
|
{"invalid dots", "..", true},
|
||||||
|
{"invalid leading slash", "/beads", true},
|
||||||
|
{"invalid trailing slash", "beads/", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// We can't easily test the full command without a git repo,
|
||||||
|
// but we can test branch validation indirectly
|
||||||
|
if tt.branch == "" {
|
||||||
|
// Empty branch should fail at args validation level
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateSyncDryRun(t *testing.T) {
|
||||||
|
// Create a temp directory with a git repo
|
||||||
|
tmpDir, err := os.MkdirTemp("", "bd-migrate-sync-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
// Initialize git repo
|
||||||
|
cmd := exec.Command("git", "init")
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("failed to init git: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure git user for commits
|
||||||
|
cmd = exec.Command("git", "config", "user.email", "test@test.com")
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("failed to config git email: %v", err)
|
||||||
|
}
|
||||||
|
cmd = exec.Command("git", "config", "user.name", "Test User")
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("failed to config git name: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
cmd = exec.Command("git", "add", ".")
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("failed to git add: %v", err)
|
||||||
|
}
|
||||||
|
cmd = exec.Command("git", "commit", "-m", "initial")
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("failed to git commit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create .beads directory and initialize
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||||
|
t.Fatalf("failed to create .beads dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create minimal issues.jsonl
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
if err := os.WriteFile(jsonlPath, []byte(""), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create issues.jsonl: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that branchExistsLocal returns false for non-existent branch
|
||||||
|
// Note: We need to run this from tmpDir context since branchExistsLocal uses git in cwd
|
||||||
|
ctx := context.Background()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatalf("failed to chdir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Chdir(origDir)
|
||||||
|
|
||||||
|
if branchExistsLocal(ctx, "beads-sync") {
|
||||||
|
t.Error("branchExistsLocal should return false for non-existent branch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the branch
|
||||||
|
cmd = exec.Command("git", "branch", "beads-sync")
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("failed to create branch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now it should exist
|
||||||
|
if !branchExistsLocal(ctx, "beads-sync") {
|
||||||
|
t.Error("branchExistsLocal should return true for existing branch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasChangesInWorktreeDir(t *testing.T) {
|
||||||
|
// Create a temp directory with a git repo
|
||||||
|
tmpDir, err := os.MkdirTemp("", "bd-worktree-changes-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
// Initialize git repo
|
||||||
|
cmd := exec.Command("git", "init")
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("failed to init git: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure git user
|
||||||
|
cmd = exec.Command("git", "config", "user.email", "test@test.com")
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
_ = cmd.Run()
|
||||||
|
cmd = exec.Command("git", "config", "user.name", "Test User")
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
_ = cmd.Run()
|
||||||
|
|
||||||
|
// Create and commit initial file
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
cmd = exec.Command("git", "add", ".")
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
_ = cmd.Run()
|
||||||
|
cmd = exec.Command("git", "commit", "-m", "initial")
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
_ = cmd.Run()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// No changes initially
|
||||||
|
hasChanges, err := hasChangesInWorktreeDir(ctx, tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hasChangesInWorktreeDir failed: %v", err)
|
||||||
|
}
|
||||||
|
if hasChanges {
|
||||||
|
t.Error("should have no changes initially")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add uncommitted file
|
||||||
|
newFile := filepath.Join(tmpDir, "new.txt")
|
||||||
|
if err := os.WriteFile(newFile, []byte("new"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create new file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasChanges, err = hasChangesInWorktreeDir(ctx, tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hasChangesInWorktreeDir failed: %v", err)
|
||||||
|
}
|
||||||
|
if !hasChanges {
|
||||||
|
t.Error("should have changes after adding file")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user