Complete sync branch daemon tests (bd-7bd2, bd-502e)
- Add daemon_sync_branch.go with syncBranchCommitAndPush/Pull functions - Add daemon_sync_branch_test.go with 7 comprehensive tests - All tests passing: NotConfigured, Success, NoChanges, WorktreeHealthCheck, Pull, EndToEnd - Key fix: initMainBranch must run BEFORE creating issues/JSONL - Close bd-7bd2 and bd-502e Amp-Thread-ID: https://ampcode.com/threads/T-e3d7ba22-99d1-4210-a6db-1dcc3bdd622b Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -126,21 +126,21 @@ func importToJSONLWithStore(ctx context.Context, store storage.Storage, jsonlPat
|
||||
return fmt.Errorf("failed to open JSONL: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
|
||||
// Parse all issues
|
||||
var issues []*types.Issue
|
||||
scanner := bufio.NewScanner(file)
|
||||
lineNum := 0
|
||||
|
||||
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Text()
|
||||
|
||||
|
||||
// Skip empty lines
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// Parse JSON
|
||||
var issue types.Issue
|
||||
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
||||
@@ -148,22 +148,22 @@ func importToJSONLWithStore(ctx context.Context, store storage.Storage, jsonlPat
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to parse JSONL line %d: %v\n", lineNum, err)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
issues = append(issues, &issue)
|
||||
}
|
||||
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("failed to read JSONL: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Use existing import logic with auto-conflict resolution
|
||||
opts := ImportOptions{
|
||||
DryRun: false,
|
||||
SkipUpdate: false,
|
||||
Strict: false,
|
||||
SkipPrefixValidation: true, // Skip prefix validation for auto-import
|
||||
DryRun: false,
|
||||
SkipUpdate: false,
|
||||
Strict: false,
|
||||
SkipPrefixValidation: true, // Skip prefix validation for auto-import
|
||||
}
|
||||
|
||||
|
||||
_, err = importIssuesCore(ctx, "", store, issues, opts)
|
||||
return err
|
||||
}
|
||||
@@ -278,27 +278,37 @@ func createExportFunc(ctx context.Context, store storage.Storage, autoCommit, au
|
||||
|
||||
// Auto-commit if enabled
|
||||
if autoCommit {
|
||||
hasChanges, err := gitHasChanges(exportCtx, jsonlPath)
|
||||
// Try sync branch commit first
|
||||
committed, err := syncBranchCommitAndPush(exportCtx, store, jsonlPath, autoPush, log)
|
||||
if err != nil {
|
||||
log.log("Error checking git status: %v", err)
|
||||
log.log("Sync branch commit failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
message := fmt.Sprintf("bd daemon export: %s", time.Now().Format("2006-01-02 15:04:05"))
|
||||
if err := gitCommit(exportCtx, jsonlPath, message); err != nil {
|
||||
log.log("Commit failed: %v", err)
|
||||
// If sync branch not configured, use regular commit
|
||||
if !committed {
|
||||
hasChanges, err := gitHasChanges(exportCtx, jsonlPath)
|
||||
if err != nil {
|
||||
log.log("Error checking git status: %v", err)
|
||||
return
|
||||
}
|
||||
log.log("Committed changes")
|
||||
|
||||
// Auto-push if enabled
|
||||
if autoPush {
|
||||
if err := gitPush(exportCtx); err != nil {
|
||||
log.log("Push failed: %v", err)
|
||||
if hasChanges {
|
||||
message := fmt.Sprintf("bd daemon export: %s", time.Now().Format("2006-01-02 15:04:05"))
|
||||
if err := gitCommit(exportCtx, jsonlPath, message); err != nil {
|
||||
log.log("Commit failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.log("Pushed to remote")
|
||||
log.log("Committed changes")
|
||||
|
||||
// Auto-push if enabled
|
||||
if autoPush {
|
||||
if err := gitPush(exportCtx); err != nil {
|
||||
log.log("Push failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.log("Pushed to remote")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -340,32 +350,41 @@ func createAutoImportFunc(ctx context.Context, store storage.Storage, log daemon
|
||||
// Check JSONL modification time to avoid redundant imports
|
||||
jsonlInfo, err := os.Stat(jsonlPath)
|
||||
if err != nil {
|
||||
log.log("Failed to stat JSONL: %v", err)
|
||||
return
|
||||
}
|
||||
log.log("Failed to stat JSONL: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get database modification time
|
||||
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||
dbInfo, err := os.Stat(dbPath)
|
||||
if err != nil {
|
||||
log.log("Failed to stat database: %v", err)
|
||||
return
|
||||
}
|
||||
// Get database modification time
|
||||
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||
dbInfo, err := os.Stat(dbPath)
|
||||
if err != nil {
|
||||
log.log("Failed to stat database: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if JSONL is older than database (nothing new to import)
|
||||
if !jsonlInfo.ModTime().After(dbInfo.ModTime()) {
|
||||
log.log("Skipping import: JSONL not newer than database")
|
||||
return
|
||||
}
|
||||
// Skip if JSONL is older than database (nothing new to import)
|
||||
if !jsonlInfo.ModTime().After(dbInfo.ModTime()) {
|
||||
log.log("Skipping import: JSONL not newer than database")
|
||||
return
|
||||
}
|
||||
|
||||
// Pull from git
|
||||
if err := gitPull(importCtx); err != nil {
|
||||
log.log("Pull failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.log("Pulled from remote")
|
||||
// Pull from git (try sync branch first)
|
||||
pulled, err := syncBranchPull(importCtx, store, log)
|
||||
if err != nil {
|
||||
log.log("Sync branch pull failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Count issues before import
|
||||
// If sync branch not configured, use regular pull
|
||||
if !pulled {
|
||||
if err := gitPull(importCtx); err != nil {
|
||||
log.log("Pull failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.log("Pulled from remote")
|
||||
}
|
||||
|
||||
// Count issues before import
|
||||
beforeCount, err := countDBIssues(importCtx, store)
|
||||
if err != nil {
|
||||
log.log("Failed to count issues before import: %v", err)
|
||||
@@ -450,52 +469,72 @@ func createSyncFunc(ctx context.Context, store storage.Storage, autoCommit, auto
|
||||
log.log("Exported to JSONL")
|
||||
|
||||
if autoCommit {
|
||||
hasChanges, err := gitHasChanges(syncCtx, jsonlPath)
|
||||
// Try sync branch commit first
|
||||
committed, err := syncBranchCommitAndPush(syncCtx, store, jsonlPath, autoPush, log)
|
||||
if err != nil {
|
||||
log.log("Error checking git status: %v", err)
|
||||
log.log("Sync branch commit failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
message := fmt.Sprintf("bd daemon sync: %s", time.Now().Format("2006-01-02 15:04:05"))
|
||||
if err := gitCommit(syncCtx, jsonlPath, message); err != nil {
|
||||
log.log("Commit failed: %v", err)
|
||||
// If sync branch not configured, use regular commit
|
||||
if !committed {
|
||||
hasChanges, err := gitHasChanges(syncCtx, jsonlPath)
|
||||
if err != nil {
|
||||
log.log("Error checking git status: %v", err)
|
||||
return
|
||||
}
|
||||
log.log("Committed changes")
|
||||
|
||||
if hasChanges {
|
||||
message := fmt.Sprintf("bd daemon sync: %s", time.Now().Format("2006-01-02 15:04:05"))
|
||||
if err := gitCommit(syncCtx, jsonlPath, message); err != nil {
|
||||
log.log("Commit failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.log("Committed changes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := gitPull(syncCtx); err != nil {
|
||||
log.log("Pull failed: %v", err)
|
||||
return
|
||||
// Pull (try sync branch first)
|
||||
pulled, err := syncBranchPull(syncCtx, store, log)
|
||||
if err != nil {
|
||||
log.log("Sync branch pull failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// If sync branch not configured, use regular pull
|
||||
if !pulled {
|
||||
if err := gitPull(syncCtx); err != nil {
|
||||
log.log("Pull failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.log("Pulled from remote")
|
||||
}
|
||||
log.log("Pulled from remote")
|
||||
|
||||
// Count issues before import for validation
|
||||
beforeCount, err := countDBIssues(syncCtx, store)
|
||||
if err != nil {
|
||||
log.log("Failed to count issues before import: %v", err)
|
||||
return
|
||||
}
|
||||
beforeCount, err := countDBIssues(syncCtx, store)
|
||||
if err != nil {
|
||||
log.log("Failed to count issues before import: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := importToJSONLWithStore(syncCtx, store, jsonlPath); err != nil {
|
||||
log.log("Import failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.log("Imported from JSONL")
|
||||
if err := importToJSONLWithStore(syncCtx, store, jsonlPath); err != nil {
|
||||
log.log("Import failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.log("Imported from JSONL")
|
||||
|
||||
// Validate import didn't cause data loss
|
||||
afterCount, err := countDBIssues(syncCtx, store)
|
||||
if err != nil {
|
||||
log.log("Failed to count issues after import: %v", err)
|
||||
return
|
||||
}
|
||||
// Validate import didn't cause data loss
|
||||
afterCount, err := countDBIssues(syncCtx, store)
|
||||
if err != nil {
|
||||
log.log("Failed to count issues after import: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validatePostImport(beforeCount, afterCount); err != nil {
|
||||
log.log("Post-import validation failed: %v", err)
|
||||
return
|
||||
}
|
||||
if err := validatePostImport(beforeCount, afterCount); err != nil {
|
||||
log.log("Post-import validation failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if autoPush && autoCommit {
|
||||
if err := gitPush(syncCtx); err != nil {
|
||||
|
||||
241
cmd/bd/daemon_sync_branch.go
Normal file
241
cmd/bd/daemon_sync_branch.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/git"
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
)
|
||||
|
||||
// syncBranchCommitAndPush commits JSONL to the sync branch using a worktree
|
||||
// Returns true if changes were committed, false if no changes or sync.branch not configured
|
||||
func syncBranchCommitAndPush(ctx context.Context, store storage.Storage, jsonlPath string, autoPush bool, log daemonLogger) (bool, error) {
|
||||
// Get sync.branch config
|
||||
syncBranch, err := store.GetConfig(ctx, "sync.branch")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get sync.branch config: %w", err)
|
||||
}
|
||||
|
||||
// If no sync.branch configured, caller should use regular commit logic
|
||||
if syncBranch == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
log.log("Using sync branch: %s", syncBranch)
|
||||
|
||||
// Get repo root
|
||||
repoRoot, err := getGitRoot(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get git root: %w", err)
|
||||
}
|
||||
|
||||
// Worktree path is under .git/beads-worktrees/<branch>
|
||||
worktreePath := filepath.Join(repoRoot, ".git", "beads-worktrees", syncBranch)
|
||||
|
||||
// Initialize worktree manager
|
||||
wtMgr := git.NewWorktreeManager(repoRoot)
|
||||
|
||||
// Ensure worktree exists
|
||||
if err := wtMgr.CreateBeadsWorktree(syncBranch, worktreePath); err != nil {
|
||||
return false, fmt.Errorf("failed to create worktree: %w", err)
|
||||
}
|
||||
|
||||
// Check worktree health and repair if needed
|
||||
if err := wtMgr.CheckWorktreeHealth(worktreePath); err != nil {
|
||||
log.log("Worktree health check failed, attempting repair: %v", err)
|
||||
// Try to recreate worktree
|
||||
if err := wtMgr.RemoveBeadsWorktree(worktreePath); err != nil {
|
||||
log.log("Failed to remove unhealthy worktree: %v", err)
|
||||
}
|
||||
if err := wtMgr.CreateBeadsWorktree(syncBranch, worktreePath); err != nil {
|
||||
return false, fmt.Errorf("failed to recreate worktree after health check: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync JSONL file to worktree
|
||||
// Use hardcoded relative path since JSONL is always at .beads/issues.jsonl
|
||||
jsonlRelPath := filepath.Join(".beads", "issues.jsonl")
|
||||
if err := wtMgr.SyncJSONLToWorktree(worktreePath, jsonlRelPath); err != nil {
|
||||
return false, fmt.Errorf("failed to sync JSONL to worktree: %w", err)
|
||||
}
|
||||
|
||||
// Check for changes in worktree
|
||||
worktreeJSONLPath := filepath.Join(worktreePath, ".beads", "issues.jsonl")
|
||||
hasChanges, err := gitHasChangesInWorktree(ctx, worktreePath, worktreeJSONLPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check for changes in worktree: %w", err)
|
||||
}
|
||||
|
||||
if !hasChanges {
|
||||
log.log("No changes to commit in sync branch")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Commit in worktree
|
||||
message := fmt.Sprintf("bd daemon sync: %s", time.Now().Format("2006-01-02 15:04:05"))
|
||||
if err := gitCommitInWorktree(ctx, worktreePath, worktreeJSONLPath, message); err != nil {
|
||||
return false, fmt.Errorf("failed to commit in worktree: %w", err)
|
||||
}
|
||||
log.log("Committed changes to sync branch %s", syncBranch)
|
||||
|
||||
// Push if enabled
|
||||
if autoPush {
|
||||
if err := gitPushFromWorktree(ctx, worktreePath, syncBranch); err != nil {
|
||||
return false, fmt.Errorf("failed to push from worktree: %w", err)
|
||||
}
|
||||
log.log("Pushed sync branch %s to remote", syncBranch)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// getGitRoot returns the git repository root directory
|
||||
func getGitRoot(ctx context.Context) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get git root: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
|
||||
// gitHasChangesInWorktree checks if there are changes in the worktree
|
||||
func gitHasChangesInWorktree(ctx context.Context, worktreePath, filePath string) (bool, error) {
|
||||
// Make filePath relative to worktree
|
||||
relPath, err := filepath.Rel(worktreePath, filePath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to make path relative: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "status", "--porcelain", relPath)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("git status failed in worktree: %w", err)
|
||||
}
|
||||
return len(strings.TrimSpace(string(output))) > 0, nil
|
||||
}
|
||||
|
||||
// gitCommitInWorktree commits changes in the worktree
|
||||
func gitCommitInWorktree(ctx context.Context, worktreePath, filePath, message string) error {
|
||||
// Make filePath relative to worktree
|
||||
relPath, err := filepath.Rel(worktreePath, filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to make path relative: %w", err)
|
||||
}
|
||||
|
||||
// Stage the file
|
||||
addCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "add", relPath)
|
||||
if err := addCmd.Run(); err != nil {
|
||||
return fmt.Errorf("git add failed in worktree: %w", err)
|
||||
}
|
||||
|
||||
// Commit
|
||||
commitCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "commit", "-m", message)
|
||||
output, err := commitCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git commit failed in worktree: %w\n%s", err, output)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// gitPushFromWorktree pushes the sync branch from the worktree
|
||||
func gitPushFromWorktree(ctx context.Context, worktreePath, branch string) error {
|
||||
// Get remote name (usually "origin")
|
||||
remoteCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "config", "--get", fmt.Sprintf("branch.%s.remote", branch))
|
||||
remoteOutput, err := remoteCmd.Output()
|
||||
if err != nil {
|
||||
// If no remote configured, default to "origin" and set up tracking
|
||||
remoteOutput = []byte("origin\n")
|
||||
}
|
||||
remote := strings.TrimSpace(string(remoteOutput))
|
||||
|
||||
// Push with explicit remote and branch, set upstream if not set
|
||||
cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "push", "--set-upstream", remote, branch)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git push failed from worktree: %w\n%s", err, output)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncBranchPull pulls changes from the sync branch into the worktree
|
||||
// Returns true if pull was performed, false if sync.branch not configured
|
||||
func syncBranchPull(ctx context.Context, store storage.Storage, log daemonLogger) (bool, error) {
|
||||
// Get sync.branch config
|
||||
syncBranch, err := store.GetConfig(ctx, "sync.branch")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get sync.branch config: %w", err)
|
||||
}
|
||||
|
||||
// If no sync.branch configured, caller should use regular pull logic
|
||||
if syncBranch == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Get repo root
|
||||
repoRoot, err := getGitRoot(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get git root: %w", err)
|
||||
}
|
||||
|
||||
// Worktree path is under .git/beads-worktrees/<branch>
|
||||
worktreePath := filepath.Join(repoRoot, ".git", "beads-worktrees", syncBranch)
|
||||
|
||||
// Initialize worktree manager
|
||||
wtMgr := git.NewWorktreeManager(repoRoot)
|
||||
|
||||
// Ensure worktree exists
|
||||
if err := wtMgr.CreateBeadsWorktree(syncBranch, worktreePath); err != nil {
|
||||
return false, fmt.Errorf("failed to create worktree: %w", err)
|
||||
}
|
||||
|
||||
// Get remote name
|
||||
remoteCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "config", "--get", fmt.Sprintf("branch.%s.remote", syncBranch))
|
||||
remoteOutput, err := remoteCmd.Output()
|
||||
if err != nil {
|
||||
// If no remote configured, default to "origin"
|
||||
remoteOutput = []byte("origin\n")
|
||||
}
|
||||
remote := strings.TrimSpace(string(remoteOutput))
|
||||
|
||||
// Pull in worktree
|
||||
cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "pull", remote, syncBranch)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("git pull failed in worktree: %w\n%s", err, output)
|
||||
}
|
||||
|
||||
log.log("Pulled sync branch %s", syncBranch)
|
||||
|
||||
// Copy JSONL back to main repo
|
||||
worktreeJSONLPath := filepath.Join(worktreePath, ".beads", "issues.jsonl")
|
||||
mainJSONLPath := filepath.Join(repoRoot, ".beads", "issues.jsonl")
|
||||
|
||||
// Check if worktree JSONL exists
|
||||
if _, err := os.Stat(worktreeJSONLPath); os.IsNotExist(err) {
|
||||
// No JSONL in worktree yet, nothing to sync
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Copy JSONL from worktree to main repo
|
||||
data, err := os.ReadFile(worktreeJSONLPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to read worktree JSONL: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(mainJSONLPath, data, 0644); err != nil {
|
||||
return false, fmt.Errorf("failed to write main JSONL: %w", err)
|
||||
}
|
||||
|
||||
log.log("Synced JSONL from sync branch to main repo")
|
||||
|
||||
return true, nil
|
||||
}
|
||||
751
cmd/bd/daemon_sync_branch_test.go
Normal file
751
cmd/bd/daemon_sync_branch_test.go
Normal file
@@ -0,0 +1,751 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// TestSyncBranchCommitAndPush_NotConfigured tests backward compatibility
|
||||
// when sync.branch is not configured (should return false, no error)
|
||||
func TestSyncBranchCommitAndPush_NotConfigured(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(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)
|
||||
}
|
||||
|
||||
// Create test issue
|
||||
issue := &types.Issue{
|
||||
Title: "Test 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)
|
||||
}
|
||||
|
||||
// Export to JSONL
|
||||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
|
||||
t.Fatalf("Failed to export: %v", err)
|
||||
}
|
||||
|
||||
// Change to temp directory for git operations
|
||||
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)
|
||||
}
|
||||
|
||||
// Test with no sync.branch configured
|
||||
log, logMsgs := newTestSyncBranchLogger()
|
||||
_ = logMsgs // unused in this test
|
||||
committed, err := syncBranchCommitAndPush(ctx, store, jsonlPath, false, log)
|
||||
|
||||
// Should return false (not committed), no error
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error when sync.branch not configured, got: %v", err)
|
||||
}
|
||||
if committed {
|
||||
t.Error("Expected committed=false when sync.branch not configured")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncBranchCommitAndPush_Success tests successful sync branch commit
|
||||
func TestSyncBranchCommitAndPush_Success(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(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 sync.branch
|
||||
syncBranch := "beads-sync"
|
||||
if err := store.SetConfig(ctx, "sync.branch", syncBranch); err != nil {
|
||||
t.Fatalf("Failed to set sync.branch: %v", err)
|
||||
}
|
||||
|
||||
// Initial commit on main branch (before creating JSONL)
|
||||
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
|
||||
issue := &types.Issue{
|
||||
Title: "Test sync branch 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)
|
||||
}
|
||||
|
||||
// Export to JSONL
|
||||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
|
||||
t.Fatalf("Failed to export: %v", err)
|
||||
}
|
||||
|
||||
// Test sync branch commit (without push)
|
||||
log, logMsgs := newTestSyncBranchLogger()
|
||||
_ = logMsgs // unused in this test
|
||||
committed, err := syncBranchCommitAndPush(ctx, store, jsonlPath, false, log)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("syncBranchCommitAndPush failed: %v", err)
|
||||
}
|
||||
if !committed {
|
||||
t.Error("Expected committed=true")
|
||||
}
|
||||
|
||||
// Verify worktree was created
|
||||
worktreePath := filepath.Join(tmpDir, ".git", "beads-worktrees", syncBranch)
|
||||
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
|
||||
t.Errorf("Worktree not created at %s", worktreePath)
|
||||
}
|
||||
|
||||
// Verify sync branch exists
|
||||
cmd := exec.Command("git", "branch", "--list", syncBranch)
|
||||
cmd.Dir = tmpDir
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list branches: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(output), syncBranch) {
|
||||
t.Errorf("Sync branch %s not created", syncBranch)
|
||||
}
|
||||
|
||||
// Verify JSONL was synced to worktree
|
||||
worktreeJSONL := filepath.Join(worktreePath, ".beads", "issues.jsonl")
|
||||
if _, err := os.Stat(worktreeJSONL); os.IsNotExist(err) {
|
||||
t.Error("JSONL not synced to worktree")
|
||||
}
|
||||
|
||||
// Verify commit was made in worktree
|
||||
cmd = exec.Command("git", "-C", worktreePath, "log", "--oneline", "-1")
|
||||
output, err = cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get log: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(output), "bd daemon sync") {
|
||||
t.Errorf("Expected commit message with 'bd daemon sync', got: %s", string(output))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncBranchCommitAndPush_NoChanges tests behavior when no changes to commit
|
||||
func TestSyncBranchCommitAndPush_NoChanges(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
initTestGitRepo(t, tmpDir)
|
||||
initMainBranch(t, tmpDir)
|
||||
|
||||
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(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)
|
||||
}
|
||||
|
||||
syncBranch := "beads-sync"
|
||||
if err := store.SetConfig(ctx, "sync.branch", syncBranch); err != nil {
|
||||
t.Fatalf("Failed to set sync.branch: %v", err)
|
||||
}
|
||||
|
||||
issue := &types.Issue{
|
||||
Title: "Test 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
log, logMsgs := newTestSyncBranchLogger()
|
||||
|
||||
// First commit should succeed
|
||||
committed, err := syncBranchCommitAndPush(ctx, store, jsonlPath, false, log)
|
||||
if err != nil {
|
||||
t.Fatalf("First commit failed: %v", err)
|
||||
}
|
||||
if !committed {
|
||||
t.Error("Expected first commit to succeed")
|
||||
}
|
||||
|
||||
// Second commit with no changes should return false
|
||||
committed, err = syncBranchCommitAndPush(ctx, store, jsonlPath, false, log)
|
||||
if err != nil {
|
||||
t.Fatalf("Second commit failed: %v", err)
|
||||
}
|
||||
if committed {
|
||||
t.Error("Expected committed=false when no changes")
|
||||
}
|
||||
|
||||
// Verify log message
|
||||
if !strings.Contains(*logMsgs, "No changes to commit") {
|
||||
t.Error("Expected 'No changes to commit' log message")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncBranchCommitAndPush_WorktreeHealthCheck tests worktree repair logic
|
||||
func TestSyncBranchCommitAndPush_WorktreeHealthCheck(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
initTestGitRepo(t, tmpDir)
|
||||
initMainBranch(t, tmpDir)
|
||||
|
||||
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(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)
|
||||
}
|
||||
|
||||
syncBranch := "beads-sync"
|
||||
if err := store.SetConfig(ctx, "sync.branch", syncBranch); err != nil {
|
||||
t.Fatalf("Failed to set sync.branch: %v", err)
|
||||
}
|
||||
|
||||
issue := &types.Issue{
|
||||
Title: "Test 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
log, logMsgs := newTestSyncBranchLogger()
|
||||
|
||||
// First commit to create worktree
|
||||
committed, err := syncBranchCommitAndPush(ctx, store, jsonlPath, false, log)
|
||||
if err != nil {
|
||||
t.Fatalf("First commit failed: %v", err)
|
||||
}
|
||||
if !committed {
|
||||
t.Error("Expected first commit to succeed")
|
||||
}
|
||||
|
||||
// Corrupt the worktree by deleting .git file
|
||||
worktreePath := filepath.Join(tmpDir, ".git", "beads-worktrees", syncBranch)
|
||||
worktreeGitFile := filepath.Join(worktreePath, ".git")
|
||||
if err := os.Remove(worktreeGitFile); err != nil {
|
||||
t.Fatalf("Failed to corrupt worktree: %v", err)
|
||||
}
|
||||
|
||||
// Update issue to create new changes
|
||||
if err := store.UpdateIssue(ctx, issue.ID, map[string]interface{}{
|
||||
"priority": 2,
|
||||
}, "test"); err != nil {
|
||||
t.Fatalf("Failed to update issue: %v", err)
|
||||
}
|
||||
|
||||
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
|
||||
t.Fatalf("Failed to export: %v", err)
|
||||
}
|
||||
|
||||
*logMsgs = "" // Reset log
|
||||
|
||||
// Should detect corruption and repair (CreateBeadsWorktree handles this silently)
|
||||
committed, err = syncBranchCommitAndPush(ctx, store, jsonlPath, false, log)
|
||||
if err != nil {
|
||||
t.Fatalf("Commit after corruption failed: %v", err)
|
||||
}
|
||||
if !committed {
|
||||
t.Error("Expected commit to succeed after repair")
|
||||
}
|
||||
|
||||
// Verify worktree is functional again - .git file should be restored
|
||||
if _, err := os.Stat(worktreeGitFile); os.IsNotExist(err) {
|
||||
t.Error("Worktree .git file not restored")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncBranchPull_NotConfigured tests pull with no sync.branch configured
|
||||
func TestSyncBranchPull_NotConfigured(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
initTestGitRepo(t, tmpDir)
|
||||
|
||||
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(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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
log, logMsgs := newTestSyncBranchLogger()
|
||||
_ = logMsgs // unused in this test
|
||||
pulled, err := syncBranchPull(ctx, store, log)
|
||||
|
||||
// Should return false (not pulled), no error
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error when sync.branch not configured, got: %v", err)
|
||||
}
|
||||
if pulled {
|
||||
t.Error("Expected pulled=false when sync.branch not configured")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncBranchPull_Success tests successful pull from sync branch
|
||||
func TestSyncBranchPull_Success(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
// Create remote repository
|
||||
tmpDir := t.TempDir()
|
||||
remoteDir := filepath.Join(tmpDir, "remote")
|
||||
if err := os.MkdirAll(remoteDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create remote dir: %v", err)
|
||||
}
|
||||
runGitCmd(t, remoteDir, "init", "--bare")
|
||||
|
||||
// Create clone1 (will push changes)
|
||||
clone1Dir := filepath.Join(tmpDir, "clone1")
|
||||
runGitCmd(t, tmpDir, "clone", remoteDir, clone1Dir)
|
||||
configureGit(t, clone1Dir)
|
||||
|
||||
clone1BeadsDir := filepath.Join(clone1Dir, ".beads")
|
||||
if err := os.MkdirAll(clone1BeadsDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create .beads dir: %v", err)
|
||||
}
|
||||
|
||||
clone1DBPath := filepath.Join(clone1BeadsDir, "test.db")
|
||||
store1, err := sqlite.New(clone1DBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create store1: %v", err)
|
||||
}
|
||||
defer store1.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := store1.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
||||
t.Fatalf("Failed to set prefix: %v", err)
|
||||
}
|
||||
|
||||
syncBranch := "beads-sync"
|
||||
if err := store1.SetConfig(ctx, "sync.branch", syncBranch); err != nil {
|
||||
t.Fatalf("Failed to set sync.branch: %v", err)
|
||||
}
|
||||
|
||||
// Create issue in clone1
|
||||
issue := &types.Issue{
|
||||
Title: "Test sync pull issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := store1.CreateIssue(ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("Failed to create issue: %v", err)
|
||||
}
|
||||
|
||||
clone1JSONLPath := filepath.Join(clone1BeadsDir, "issues.jsonl")
|
||||
if err := exportToJSONLWithStore(ctx, store1, clone1JSONLPath); err != nil {
|
||||
t.Fatalf("Failed to export: %v", err)
|
||||
}
|
||||
|
||||
// Commit to main branch first
|
||||
initMainBranch(t, clone1Dir)
|
||||
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
||||
|
||||
// Change to clone1 directory for sync branch operations
|
||||
oldWd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get working directory: %v", err)
|
||||
}
|
||||
defer os.Chdir(oldWd)
|
||||
|
||||
if err := os.Chdir(clone1Dir); err != nil {
|
||||
t.Fatalf("Failed to change directory: %v", err)
|
||||
}
|
||||
|
||||
// Push to sync branch using syncBranchCommitAndPush
|
||||
log, logMsgs := newTestSyncBranchLogger()
|
||||
_ = logMsgs // unused in this test
|
||||
committed, err := syncBranchCommitAndPush(ctx, store1, clone1JSONLPath, true, log)
|
||||
if err != nil {
|
||||
t.Fatalf("syncBranchCommitAndPush failed: %v", err)
|
||||
}
|
||||
if !committed {
|
||||
t.Error("Expected commit to succeed")
|
||||
}
|
||||
|
||||
// Create clone2 (will pull changes)
|
||||
clone2Dir := filepath.Join(tmpDir, "clone2")
|
||||
runGitCmd(t, tmpDir, "clone", remoteDir, clone2Dir)
|
||||
configureGit(t, clone2Dir)
|
||||
|
||||
clone2BeadsDir := filepath.Join(clone2Dir, ".beads")
|
||||
clone2DBPath := filepath.Join(clone2BeadsDir, "test.db")
|
||||
store2, err := sqlite.New(clone2DBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create store2: %v", err)
|
||||
}
|
||||
defer store2.Close()
|
||||
|
||||
if err := store2.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
||||
t.Fatalf("Failed to set prefix: %v", err)
|
||||
}
|
||||
|
||||
if err := store2.SetConfig(ctx, "sync.branch", syncBranch); err != nil {
|
||||
t.Fatalf("Failed to set sync.branch: %v", err)
|
||||
}
|
||||
|
||||
// Change to clone2 directory
|
||||
if err := os.Chdir(clone2Dir); err != nil {
|
||||
t.Fatalf("Failed to change directory: %v", err)
|
||||
}
|
||||
|
||||
// Pull from sync branch
|
||||
log2, logMsgs2 := newTestSyncBranchLogger()
|
||||
pulled, err := syncBranchPull(ctx, store2, log2)
|
||||
if err != nil {
|
||||
t.Fatalf("syncBranchPull failed: %v", err)
|
||||
}
|
||||
if !pulled {
|
||||
t.Error("Expected pulled=true")
|
||||
}
|
||||
|
||||
// Verify JSONL was copied to main repo
|
||||
clone2JSONLPath := filepath.Join(clone2BeadsDir, "issues.jsonl")
|
||||
if _, err := os.Stat(clone2JSONLPath); os.IsNotExist(err) {
|
||||
t.Error("JSONL not copied to main repo after pull")
|
||||
}
|
||||
|
||||
// Verify JSONL content matches
|
||||
clone1Data, err := os.ReadFile(clone1JSONLPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read clone1 JSONL: %v", err)
|
||||
}
|
||||
|
||||
clone2Data, err := os.ReadFile(clone2JSONLPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read clone2 JSONL: %v", err)
|
||||
}
|
||||
|
||||
if string(clone1Data) != string(clone2Data) {
|
||||
t.Error("JSONL content mismatch after pull")
|
||||
}
|
||||
|
||||
// Verify pull message in log
|
||||
if !strings.Contains(*logMsgs2, "Pulled sync branch") {
|
||||
t.Error("Expected 'Pulled sync branch' log message")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncBranchIntegration_EndToEnd tests full sync workflow
|
||||
func TestSyncBranchIntegration_EndToEnd(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
// Setup remote and two clones
|
||||
tmpDir := t.TempDir()
|
||||
remoteDir := filepath.Join(tmpDir, "remote")
|
||||
os.MkdirAll(remoteDir, 0755)
|
||||
runGitCmd(t, remoteDir, "init", "--bare")
|
||||
|
||||
// Clone1: Agent A
|
||||
clone1Dir := filepath.Join(tmpDir, "clone1")
|
||||
runGitCmd(t, tmpDir, "clone", remoteDir, clone1Dir)
|
||||
configureGit(t, clone1Dir)
|
||||
|
||||
clone1BeadsDir := filepath.Join(clone1Dir, ".beads")
|
||||
os.MkdirAll(clone1BeadsDir, 0755)
|
||||
clone1DBPath := filepath.Join(clone1BeadsDir, "test.db")
|
||||
store1, _ := sqlite.New(clone1DBPath)
|
||||
defer store1.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
store1.SetConfig(ctx, "issue_prefix", "test")
|
||||
|
||||
syncBranch := "beads-sync"
|
||||
store1.SetConfig(ctx, "sync.branch", syncBranch)
|
||||
|
||||
// Agent A creates issue
|
||||
issue := &types.Issue{
|
||||
Title: "E2E test issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
store1.CreateIssue(ctx, issue, "agent-a")
|
||||
issueID := issue.ID
|
||||
|
||||
clone1JSONLPath := filepath.Join(clone1BeadsDir, "issues.jsonl")
|
||||
exportToJSONLWithStore(ctx, store1, clone1JSONLPath)
|
||||
|
||||
// Initial commit to main
|
||||
initMainBranch(t, clone1Dir)
|
||||
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
||||
|
||||
// Change to clone1 directory
|
||||
oldWd, _ := os.Getwd()
|
||||
defer os.Chdir(oldWd)
|
||||
os.Chdir(clone1Dir)
|
||||
|
||||
// Agent A commits to sync branch
|
||||
log, logMsgs := newTestSyncBranchLogger()
|
||||
_ = logMsgs // unused in this test
|
||||
committed, err := syncBranchCommitAndPush(ctx, store1, clone1JSONLPath, true, log)
|
||||
if err != nil {
|
||||
t.Fatalf("syncBranchCommitAndPush failed: %v", err)
|
||||
}
|
||||
if !committed {
|
||||
t.Error("Expected commit to succeed")
|
||||
}
|
||||
|
||||
// Clone2: Agent B
|
||||
clone2Dir := filepath.Join(tmpDir, "clone2")
|
||||
runGitCmd(t, tmpDir, "clone", remoteDir, clone2Dir)
|
||||
configureGit(t, clone2Dir)
|
||||
|
||||
clone2BeadsDir := filepath.Join(clone2Dir, ".beads")
|
||||
clone2DBPath := filepath.Join(clone2BeadsDir, "test.db")
|
||||
store2, _ := sqlite.New(clone2DBPath)
|
||||
defer store2.Close()
|
||||
|
||||
store2.SetConfig(ctx, "issue_prefix", "test")
|
||||
store2.SetConfig(ctx, "sync.branch", syncBranch)
|
||||
|
||||
// Change to clone2 directory
|
||||
os.Chdir(clone2Dir)
|
||||
|
||||
// Agent B pulls from sync branch
|
||||
log2, logMsgs2 := newTestSyncBranchLogger()
|
||||
_ = logMsgs2 // unused in this test
|
||||
pulled, err := syncBranchPull(ctx, store2, log2)
|
||||
if err != nil {
|
||||
t.Fatalf("syncBranchPull failed: %v", err)
|
||||
}
|
||||
if !pulled {
|
||||
t.Error("Expected pull to succeed")
|
||||
}
|
||||
|
||||
// Import JSONL to database
|
||||
clone2JSONLPath := filepath.Join(clone2BeadsDir, "issues.jsonl")
|
||||
if err := importToJSONLWithStore(ctx, store2, clone2JSONLPath); err != nil {
|
||||
t.Fatalf("Failed to import: %v", err)
|
||||
}
|
||||
|
||||
// Verify issue exists in clone2
|
||||
clone2Issue, err := store2.GetIssue(ctx, issueID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get issue in clone2: %v", err)
|
||||
}
|
||||
if clone2Issue.Title != issue.Title {
|
||||
t.Errorf("Issue title mismatch: expected %s, got %s", issue.Title, clone2Issue.Title)
|
||||
}
|
||||
|
||||
// Agent B closes the issue
|
||||
store2.CloseIssue(ctx, issueID, "Done by Agent B", "agent-b")
|
||||
exportToJSONLWithStore(ctx, store2, clone2JSONLPath)
|
||||
|
||||
// Agent B commits to sync branch
|
||||
committed, err = syncBranchCommitAndPush(ctx, store2, clone2JSONLPath, true, log2)
|
||||
if err != nil {
|
||||
t.Fatalf("syncBranchCommitAndPush failed for clone2: %v", err)
|
||||
}
|
||||
if !committed {
|
||||
t.Error("Expected commit to succeed for clone2")
|
||||
}
|
||||
|
||||
// Agent A pulls the update
|
||||
os.Chdir(clone1Dir)
|
||||
pulled, err = syncBranchPull(ctx, store1, log)
|
||||
if err != nil {
|
||||
t.Fatalf("syncBranchPull failed for clone1: %v", err)
|
||||
}
|
||||
if !pulled {
|
||||
t.Error("Expected pull to succeed for clone1")
|
||||
}
|
||||
|
||||
// Import to see the closed status
|
||||
importToJSONLWithStore(ctx, store1, clone1JSONLPath)
|
||||
|
||||
// Verify Agent A sees the closed issue
|
||||
updatedIssue, err := store1.GetIssue(ctx, issueID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get issue in clone1: %v", err)
|
||||
}
|
||||
if updatedIssue.Status != types.StatusClosed {
|
||||
t.Errorf("Issue not closed in clone1: status=%s", updatedIssue.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper types for testing
|
||||
|
||||
func newTestSyncBranchLogger() (daemonLogger, *string) {
|
||||
messages := ""
|
||||
logger := daemonLogger{
|
||||
logFunc: func(format string, args ...interface{}) {
|
||||
messages += "\n" + format
|
||||
},
|
||||
}
|
||||
return logger, &messages
|
||||
}
|
||||
|
||||
// initMainBranch creates an initial commit on main branch
|
||||
// The JSONL file should not exist yet when this is called
|
||||
func initMainBranch(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
// Create a simple README to have something to commit
|
||||
readme := filepath.Join(dir, "README.md")
|
||||
if err := os.WriteFile(readme, []byte("# Test Repository\n"), 0644); err != nil {
|
||||
t.Fatalf("Failed to write README: %v", err)
|
||||
}
|
||||
runGitCmd(t, dir, "add", "README.md")
|
||||
runGitCmd(t, dir, "commit", "-m", "Initial commit")
|
||||
}
|
||||
Reference in New Issue
Block a user