Files
beads/beads_twoclone_test.go
Steve Yegge 79b952fccf Optimize TestTwoCloneCollision: 3x faster with polling instead of sleeps
- Replace fixed 2s sleeps with smart polling (50-100ms intervals)
- Reduce daemon wait timeout from 5s to 1s with early exit
- Add timeout to daemon cleanup to prevent hanging
- Test now runs in ~18s instead of timing out at 60s

Amp-Thread-ID: https://ampcode.com/threads/T-51945255-d5fd-4b42-9816-c44ff4d3bdd1
Co-authored-by: Amp <amp@ampcode.com>
2025-10-28 11:52:27 -07:00

341 lines
10 KiB
Go

package beads_test
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// TestTwoCloneCollision demonstrates that beads does NOT work with the basic workflow
// of two independent clones filing issues simultaneously.
func TestTwoCloneCollision(t *testing.T) {
tmpDir := t.TempDir()
// Get path to bd binary
bdPath, err := filepath.Abs("./bd")
if err != nil {
t.Fatalf("Failed to get bd path: %v", err)
}
if _, err := os.Stat(bdPath); err != nil {
t.Fatalf("bd binary not found at %s - run 'go build -o bd ./cmd/bd' first", bdPath)
}
// Create a bare git repo to act as the remote
remoteDir := filepath.Join(tmpDir, "remote.git")
runCmd(t, tmpDir, "git", "init", "--bare", remoteDir)
// Create clone A
cloneA := filepath.Join(tmpDir, "clone-a")
runCmd(t, tmpDir, "git", "clone", remoteDir, cloneA)
// Create clone B
cloneB := filepath.Join(tmpDir, "clone-b")
runCmd(t, tmpDir, "git", "clone", remoteDir, cloneB)
// Copy bd binary to both clones
copyFile(t, bdPath, filepath.Join(cloneA, "bd"))
copyFile(t, bdPath, filepath.Join(cloneB, "bd"))
// Initialize beads in clone A
t.Log("Initializing beads in clone A")
runCmd(t, cloneA, "./bd", "init", "--quiet", "--prefix", "test")
// Commit the initial .beads directory from clone A
runCmd(t, cloneA, "git", "add", ".beads")
runCmd(t, cloneA, "git", "commit", "-m", "Initialize beads")
runCmd(t, cloneA, "git", "push", "origin", "master")
// Pull in clone B to get the beads initialization
t.Log("Pulling beads init to clone B")
runCmd(t, cloneB, "git", "pull", "origin", "master")
// Initialize database in clone B from JSONL
t.Log("Initializing database in clone B")
runCmd(t, cloneB, "./bd", "init", "--quiet", "--prefix", "test")
// Install git hooks in both clones
t.Log("Installing git hooks")
installGitHooks(t, cloneA)
installGitHooks(t, cloneB)
// Start daemons in both clones with auto-commit and auto-push
t.Log("Starting daemons")
startDaemon(t, cloneA)
startDaemon(t, cloneB)
// Ensure cleanup happens even if test fails
t.Cleanup(func() {
t.Log("Cleaning up daemons")
stopAllDaemons(t, cloneA)
stopAllDaemons(t, cloneB)
})
// Wait for daemons to be ready (short timeout)
waitForDaemon(t, cloneA, 1*time.Second)
waitForDaemon(t, cloneB, 1*time.Second)
// Clone A creates an issue
t.Log("Clone A creating issue")
runCmd(t, cloneA, "./bd", "create", "Issue from clone A", "-t", "task", "-p", "1", "--json")
// Clone B creates an issue (should get same ID since databases are independent)
t.Log("Clone B creating issue")
runCmd(t, cloneB, "./bd", "create", "Issue from clone B", "-t", "task", "-p", "1", "--json")
// Force sync clone A first
t.Log("Clone A syncing")
runCmd(t, cloneA, "./bd", "sync")
// Wait for push to complete by polling git log
waitForPush(t, cloneA, 2*time.Second)
// Clone B will conflict when syncing
t.Log("Clone B syncing (will conflict)")
syncBOut := runCmdOutputAllowError(t, cloneB, "./bd", "sync")
if !strings.Contains(syncBOut, "CONFLICT") && !strings.Contains(syncBOut, "Error") {
t.Log("Expected conflict during clone B sync, but got success. Output:")
t.Log(syncBOut)
}
// Clone B needs to abort the rebase and resolve manually
t.Log("Clone B aborting rebase")
runCmdAllowError(t, cloneB, "git", "rebase", "--abort")
// Pull with merge instead
t.Log("Clone B pulling with merge")
pullOut := runCmdOutputAllowError(t, cloneB, "git", "pull", "--no-rebase", "origin", "master")
if !strings.Contains(pullOut, "CONFLICT") {
t.Logf("Pull output: %s", pullOut)
}
// Check if we have conflict markers in the JSONL
jsonlPath := filepath.Join(cloneB, ".beads", "issues.jsonl")
jsonlContent, _ := os.ReadFile(jsonlPath)
if strings.Contains(string(jsonlContent), "<<<<<<<") {
t.Log("JSONL has conflict markers - manually resolving")
// For this test, just take both issues (keep all non-marker lines)
var cleanLines []string
for _, line := range strings.Split(string(jsonlContent), "\n") {
if !strings.HasPrefix(line, "<<<<<<<") &&
!strings.HasPrefix(line, "=======") &&
!strings.HasPrefix(line, ">>>>>>>") {
if strings.TrimSpace(line) != "" {
cleanLines = append(cleanLines, line)
}
}
}
cleaned := strings.Join(cleanLines, "\n") + "\n"
if err := os.WriteFile(jsonlPath, []byte(cleaned), 0644); err != nil {
t.Fatalf("Failed to write cleaned JSONL: %v", err)
}
// Mark as resolved
runCmd(t, cloneB, "git", "add", ".beads/issues.jsonl")
runCmd(t, cloneB, "git", "commit", "-m", "Resolve merge conflict")
}
// Force import with collision resolution in both
t.Log("Resolving collisions via import")
runCmd(t, cloneB, "./bd", "import", "-i", ".beads/issues.jsonl", "--resolve-collisions")
// Push the resolved state from clone B
t.Log("Clone B pushing resolved state")
runCmd(t, cloneB, "git", "push", "origin", "master")
// Clone A now tries to sync - will this work?
t.Log("Clone A syncing after clone B resolved collision")
syncAOut := runCmdOutputAllowError(t, cloneA, "./bd", "sync")
// Check if clone A also hit a conflict
hasConflict := strings.Contains(syncAOut, "CONFLICT") || strings.Contains(syncAOut, "Error pulling")
if hasConflict {
t.Log("✓ TEST PROVES THE PROBLEM: Clone A also hit a conflict when syncing!")
t.Log("This demonstrates that the basic two-clone workflow does NOT converge cleanly.")
t.Errorf("EXPECTED FAILURE: beads cannot handle two clones filing issues simultaneously")
return
}
// If we somehow got here, check if things converged
t.Log("Checking if git status is clean")
statusA := runCmdOutputAllowError(t, cloneA, "git", "status", "--porcelain")
statusB := runCmdOutputAllowError(t, cloneB, "git", "status", "--porcelain")
if strings.TrimSpace(statusA) != "" {
t.Errorf("Clone A git status not clean:\n%s", statusA)
}
if strings.TrimSpace(statusB) != "" {
t.Errorf("Clone B git status not clean:\n%s", statusB)
}
// Check if bd ready matches
readyA := runCmdOutputAllowError(t, cloneA, "./bd", "ready", "--json")
readyB := runCmdOutputAllowError(t, cloneB, "./bd", "ready", "--json")
if readyA != readyB {
t.Log("✓ TEST PROVES THE PROBLEM: Databases did not converge!")
t.Log("Even without conflicts, the two clones have different issue databases.")
t.Errorf("bd ready output differs:\nClone A:\n%s\n\nClone B:\n%s", readyA, readyB)
} else {
t.Log("Unexpected success: beads handled two-clone collision properly!")
}
}
func installGitHooks(t *testing.T, repoDir string) {
hooksDir := filepath.Join(repoDir, ".git", "hooks")
preCommit := `#!/bin/sh
./bd --no-daemon export -o .beads/issues.jsonl >/dev/null 2>&1 || true
git add .beads/issues.jsonl >/dev/null 2>&1 || true
exit 0
`
postMerge := `#!/bin/sh
./bd --no-daemon import -i .beads/issues.jsonl >/dev/null 2>&1 || true
exit 0
`
if err := os.WriteFile(filepath.Join(hooksDir, "pre-commit"), []byte(preCommit), 0755); err != nil {
t.Fatalf("Failed to write pre-commit hook: %v", err)
}
if err := os.WriteFile(filepath.Join(hooksDir, "post-merge"), []byte(postMerge), 0755); err != nil {
t.Fatalf("Failed to write post-merge hook: %v", err)
}
}
func startDaemon(t *testing.T, repoDir string) {
cmd := exec.Command("./bd", "daemon", "start", "--auto-commit", "--auto-push")
cmd.Dir = repoDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
t.Logf("Warning: daemon start failed (may already be running): %v", err)
}
}
func stopAllDaemons(t *testing.T, repoDir string) {
t.Helper()
cmd := exec.Command("./bd", "daemons", "killall", "--force")
cmd.Dir = repoDir
// Run with timeout to avoid hanging
done := make(chan struct{})
go func() {
defer close(done)
out, err := cmd.CombinedOutput()
if err != nil {
t.Logf("Warning: daemon killall failed (may not be running): %v\nOutput: %s", err, string(out))
}
}()
select {
case <-done:
// Success
case <-time.After(2 * time.Second):
t.Logf("Warning: daemon killall timed out, continuing")
if cmd.Process != nil {
cmd.Process.Kill()
}
}
}
func runCmd(t *testing.T, dir string, name string, args ...string) {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
t.Fatalf("Command failed: %s %v\nError: %v", name, args, err)
}
}
func runCmdOutput(t *testing.T, dir string, name string, args ...string) string {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Logf("Command output: %s", string(out))
t.Fatalf("Command failed: %s %v\nError: %v", name, args, err)
}
return string(out)
}
func copyFile(t *testing.T, src, dst string) {
t.Helper()
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("Failed to read %s: %v", src, err)
}
if err := os.WriteFile(dst, data, 0755); err != nil {
t.Fatalf("Failed to write %s: %v", dst, err)
}
}
func runCmdAllowError(t *testing.T, dir string, name string, args ...string) {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
}
func runCmdOutputAllowError(t *testing.T, dir string, name string, args ...string) string {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
out, _ := cmd.CombinedOutput()
return string(out)
}
func waitForDaemon(t *testing.T, repoDir string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
// Just check if we can list issues - daemon doesn't have to be running
cmd := exec.Command("./bd", "list", "--json")
cmd.Dir = repoDir
_, err := cmd.CombinedOutput()
if err == nil {
return
}
time.Sleep(50 * time.Millisecond)
}
// Don't fail - test can continue without daemon
t.Logf("Warning: daemon not ready within %v, continuing anyway", timeout)
}
func waitForPush(t *testing.T, repoDir string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
var lastCommit string
// Get initial commit
cmd := exec.Command("git", "rev-parse", "HEAD")
cmd.Dir = repoDir
if out, err := cmd.Output(); err == nil {
lastCommit = strings.TrimSpace(string(out))
}
for time.Now().Before(deadline) {
// First fetch to update remote tracking
exec.Command("git", "fetch", "origin").Run()
// Check if remote has our commit
cmd := exec.Command("git", "log", "origin/master", "--oneline", "-1")
cmd.Dir = repoDir
out, err := cmd.Output()
if err == nil && strings.Contains(string(out), lastCommit) {
return
}
time.Sleep(50 * time.Millisecond)
}
// Don't fail, just warn - push might complete async
t.Logf("Warning: push not detected within %v", timeout)
}