diff --git a/cmd/bd/daemon_autoimport_test.go b/cmd/bd/daemon_autoimport_test.go index 07aaef46..e959ba51 100644 --- a/cmd/bd/daemon_autoimport_test.go +++ b/cmd/bd/daemon_autoimport_test.go @@ -30,36 +30,36 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) { t.Fatal(err) } defer os.RemoveAll(tempDir) - + // Create "remote" repository remoteDir := filepath.Join(tempDir, "remote") if err := os.MkdirAll(remoteDir, 0750); err != nil { t.Fatalf("Failed to create remote dir: %v", err) } - + // Initialize remote git repo - runGitCmd(t, remoteDir, "init", "--bare") - + runGitCmd(t, remoteDir, "init", "--bare", "-b", "master") + // Create "clone1" repository (Agent A) clone1Dir := filepath.Join(tempDir, "clone1") runGitCmd(t, tempDir, "clone", remoteDir, clone1Dir) configureGit(t, clone1Dir) - + // Initialize beads in clone1 clone1BeadsDir := filepath.Join(clone1Dir, ".beads") if err := os.MkdirAll(clone1BeadsDir, 0750); err != nil { t.Fatalf("Failed to create .beads dir: %v", err) } - + clone1DBPath := filepath.Join(clone1BeadsDir, "test.db") clone1Store := newTestStore(t, clone1DBPath) defer clone1Store.Close() - + ctx := context.Background() if err := clone1Store.SetMetadata(ctx, "issue_prefix", "test"); err != nil { t.Fatalf("Failed to set prefix: %v", err) } - + // Create an open issue in clone1 issue := &types.Issue{ Title: "Test daemon auto-import", @@ -73,39 +73,39 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) { t.Fatalf("Failed to create issue: %v", err) } issueID := issue.ID - + // Export to JSONL jsonlPath := filepath.Join(clone1BeadsDir, "issues.jsonl") if err := exportIssuesToJSONL(ctx, clone1Store, jsonlPath); err != nil { t.Fatalf("Failed to export: %v", err) } - + // Commit and push from clone1 runGitCmd(t, clone1Dir, "add", ".beads") runGitCmd(t, clone1Dir, "commit", "-m", "Add test issue") runGitCmd(t, clone1Dir, "push", "origin", "master") - + // Create "clone2" repository (Agent B) clone2Dir := filepath.Join(tempDir, "clone2") runGitCmd(t, tempDir, "clone", remoteDir, clone2Dir) configureGit(t, clone2Dir) - + // Initialize empty database in clone2 clone2BeadsDir := filepath.Join(clone2Dir, ".beads") clone2DBPath := filepath.Join(clone2BeadsDir, "test.db") clone2Store := newTestStore(t, clone2DBPath) defer clone2Store.Close() - + if err := clone2Store.SetMetadata(ctx, "issue_prefix", "test"); err != nil { t.Fatalf("Failed to set prefix: %v", err) } - + // Import initial JSONL in clone2 clone2JSONLPath := filepath.Join(clone2BeadsDir, "issues.jsonl") if err := importJSONLToStore(ctx, clone2Store, clone2DBPath, clone2JSONLPath); err != nil { t.Fatalf("Failed to import: %v", err) } - + // Verify issue exists in clone2 initialIssue, err := clone2Store.GetIssue(ctx, issueID) if err != nil { @@ -114,27 +114,27 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) { if initialIssue.Status != types.StatusOpen { t.Errorf("Expected status open, got %s", initialIssue.Status) } - + // NOW THE CRITICAL TEST: Agent A closes the issue and pushes t.Run("DaemonAutoImportsAfterGitPull", func(t *testing.T) { // Agent A closes the issue if err := clone1Store.CloseIssue(ctx, issueID, "Completed", "agent-a"); err != nil { t.Fatalf("Failed to close issue: %v", err) } - + // Agent A exports to JSONL if err := exportIssuesToJSONL(ctx, clone1Store, jsonlPath); err != nil { t.Fatalf("Failed to export after close: %v", err) } - + // Agent A commits and pushes runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl") runGitCmd(t, clone1Dir, "commit", "-m", "Close issue") runGitCmd(t, clone1Dir, "push", "origin", "master") - + // Agent B does git pull (updates JSONL on disk) runGitCmd(t, clone2Dir, "pull") - + // Wait for filesystem to settle after git operations // Windows has lower filesystem timestamp precision (typically 100ms) // and file I/O may be slower, so we need a longer delay @@ -143,23 +143,23 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) { } else { time.Sleep(50 * time.Millisecond) } - + // Start daemon server in clone2 socketPath := filepath.Join(clone2BeadsDir, "bd.sock") os.Remove(socketPath) // Ensure clean state - + server := rpc.NewServer(socketPath, clone2Store, clone2Dir, clone2DBPath) - + // Start server in background serverCtx, serverCancel := context.WithCancel(context.Background()) defer serverCancel() - + go func() { if err := server.Start(serverCtx); err != nil { t.Logf("Server error: %v", err) } }() - + // Wait for server to be ready for i := 0; i < 50; i++ { time.Sleep(10 * time.Millisecond) @@ -167,7 +167,7 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) { break } } - + // Simulate a daemon request (like "bd show ") // The daemon should auto-import the updated JSONL before responding client, err := rpc.TryConnect(socketPath) @@ -178,15 +178,15 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) { t.Fatal("Client is nil") } defer client.Close() - + client.SetDatabasePath(clone2DBPath) // Route to correct database - + // Make a request that triggers auto-import check resp, err := client.Execute("show", map[string]string{"id": issueID}) if err != nil { t.Fatalf("Failed to get issue from daemon: %v", err) } - + // Parse response var issue types.Issue issueJSON, err := json.Marshal(resp.Data) @@ -196,25 +196,25 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) { if err := json.Unmarshal(issueJSON, &issue); err != nil { t.Fatalf("Failed to unmarshal issue: %v", err) } - + status := issue.Status - + // CRITICAL ASSERTION: Daemon should return CLOSED status from JSONL // not stale OPEN status from SQLite if status != types.StatusClosed { t.Errorf("DAEMON AUTO-IMPORT FAILED: Expected status 'closed' but got '%s'", status) t.Errorf("This means daemon is serving stale SQLite data instead of auto-importing JSONL") - + // Double-check JSONL has correct status jsonlData, _ := os.ReadFile(clone2JSONLPath) t.Logf("JSONL content: %s", string(jsonlData)) - + // Double-check what's in SQLite directIssue, _ := clone2Store.GetIssue(ctx, issueID) t.Logf("SQLite status: %s", directIssue.Status) } }) - + // Additional test: Verify multiple rapid changes t.Run("DaemonHandlesRapidUpdates", func(t *testing.T) { // Agent A updates priority @@ -223,18 +223,18 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) { }, "agent-a"); err != nil { t.Fatalf("Failed to update priority: %v", err) } - + if err := exportIssuesToJSONL(ctx, clone1Store, jsonlPath); err != nil { t.Fatalf("Failed to export: %v", err) } - + runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl") runGitCmd(t, clone1Dir, "commit", "-m", "Update priority") runGitCmd(t, clone1Dir, "push", "origin", "master") - + // Agent B pulls runGitCmd(t, clone2Dir, "pull") - + // Query via daemon - should see priority 0 // (Execute forces auto-import synchronously) socketPath := filepath.Join(clone2BeadsDir, "bd.sock") @@ -243,18 +243,18 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) { t.Fatalf("Failed to connect to daemon: %v", err) } defer client.Close() - + client.SetDatabasePath(clone2DBPath) // Route to correct database - + resp, err := client.Execute("show", map[string]string{"id": issueID}) if err != nil { t.Fatalf("Failed to get issue from daemon: %v", err) } - + var issue types.Issue issueJSON, _ := json.Marshal(resp.Data) json.Unmarshal(issueJSON, &issue) - + if issue.Priority != 0 { t.Errorf("Expected priority 0 after auto-import, got %d", issue.Priority) } @@ -273,23 +273,23 @@ func TestDaemonAutoImportDataCorruption(t *testing.T) { t.Fatal(err) } defer os.RemoveAll(tempDir) - + // Setup remote and two clones remoteDir := filepath.Join(tempDir, "remote") os.MkdirAll(remoteDir, 0750) - runGitCmd(t, remoteDir, "init", "--bare") - + runGitCmd(t, remoteDir, "init", "--bare", "-b", "master") + clone1Dir := filepath.Join(tempDir, "clone1") runGitCmd(t, tempDir, "clone", remoteDir, clone1Dir) configureGit(t, clone1Dir) - + clone2Dir := filepath.Join(tempDir, "clone2") runGitCmd(t, tempDir, "clone", remoteDir, clone2Dir) configureGit(t, clone2Dir) - + // Initialize beads in both clones ctx := context.Background() - + // Clone1 setup clone1BeadsDir := filepath.Join(clone1Dir, ".beads") os.MkdirAll(clone1BeadsDir, 0750) @@ -297,7 +297,7 @@ func TestDaemonAutoImportDataCorruption(t *testing.T) { clone1Store := newTestStore(t, clone1DBPath) defer clone1Store.Close() clone1Store.SetMetadata(ctx, "issue_prefix", "test") - + // Clone2 setup clone2BeadsDir := filepath.Join(clone2Dir, ".beads") os.MkdirAll(clone2BeadsDir, 0750) @@ -305,7 +305,7 @@ func TestDaemonAutoImportDataCorruption(t *testing.T) { clone2Store := newTestStore(t, clone2DBPath) defer clone2Store.Close() clone2Store.SetMetadata(ctx, "issue_prefix", "test") - + // Agent A creates issue and pushes issue2 := &types.Issue{ Title: "Shared issue", @@ -317,18 +317,18 @@ func TestDaemonAutoImportDataCorruption(t *testing.T) { } clone1Store.CreateIssue(ctx, issue2, "agent-a") issueID := issue2.ID - + clone1JSONLPath := filepath.Join(clone1BeadsDir, "issues.jsonl") exportIssuesToJSONL(ctx, clone1Store, clone1JSONLPath) runGitCmd(t, clone1Dir, "add", ".beads") runGitCmd(t, clone1Dir, "commit", "-m", "Initial issue") runGitCmd(t, clone1Dir, "push", "origin", "master") - + // Agent B pulls and imports runGitCmd(t, clone2Dir, "pull") clone2JSONLPath := filepath.Join(clone2BeadsDir, "issues.jsonl") importJSONLToStore(ctx, clone2Store, clone2DBPath, clone2JSONLPath) - + // THE CORRUPTION SCENARIO: // 1. Agent A closes the issue and pushes clone1Store.CloseIssue(ctx, issueID, "Done", "agent-a") @@ -336,31 +336,31 @@ func TestDaemonAutoImportDataCorruption(t *testing.T) { runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl") runGitCmd(t, clone1Dir, "commit", "-m", "Close issue") runGitCmd(t, clone1Dir, "push", "origin", "master") - + // 2. Agent B does git pull (JSONL updated on disk) runGitCmd(t, clone2Dir, "pull") - + // Wait for filesystem to settle after git operations time.Sleep(50 * time.Millisecond) - + // 3. Agent B daemon exports STALE data (if auto-import doesn't work) // This would overwrite Agent A's closure with old "open" status - + // Start daemon in clone2 socketPath := filepath.Join(clone2BeadsDir, "bd.sock") os.Remove(socketPath) - + server := rpc.NewServer(socketPath, clone2Store, clone2Dir, clone2DBPath) - + serverCtx, serverCancel := context.WithCancel(context.Background()) defer serverCancel() - + go func() { if err := server.Start(serverCtx); err != nil { t.Logf("Server error: %v", err) } }() - + // Wait for server for i := 0; i < 50; i++ { time.Sleep(10 * time.Millisecond) @@ -368,43 +368,43 @@ func TestDaemonAutoImportDataCorruption(t *testing.T) { break } } - + // Trigger daemon operation (should auto-import first) client, err := rpc.TryConnect(socketPath) if err != nil { t.Fatalf("Failed to connect: %v", err) } defer client.Close() - + client.SetDatabasePath(clone2DBPath) - + resp, err := client.Execute("show", map[string]string{"id": issueID}) if err != nil { t.Fatalf("Failed to get issue: %v", err) } - + var issue types.Issue issueJSON, _ := json.Marshal(resp.Data) json.Unmarshal(issueJSON, &issue) - + status := issue.Status - + // If daemon didn't auto-import, this would be "open" (stale) // With the fix, it should be "closed" (fresh from JSONL) if status != types.StatusClosed { t.Errorf("DATA CORRUPTION DETECTED: Daemon has stale status '%s' instead of 'closed'", status) t.Error("If daemon exports this stale data, it will overwrite Agent A's changes on next push") } - + // Now simulate daemon export (which happens on timer) // With auto-import working, this export should have fresh data exportIssuesToJSONL(ctx, clone2Store, clone2JSONLPath) - + // Read back JSONL to verify it has correct status data, _ := os.ReadFile(clone2JSONLPath) var exportedIssue types.Issue json.NewDecoder(bytes.NewReader(data)).Decode(&exportedIssue) - + if exportedIssue.Status != types.StatusClosed { t.Errorf("CORRUPTION: Exported JSONL has wrong status '%s', would overwrite remote", exportedIssue.Status) } diff --git a/cmd/bd/daemon_sync_branch_test.go b/cmd/bd/daemon_sync_branch_test.go index d6731347..186c8533 100644 --- a/cmd/bd/daemon_sync_branch_test.go +++ b/cmd/bd/daemon_sync_branch_test.go @@ -48,12 +48,12 @@ func TestSyncBranchCommitAndPush_NotConfigured(t *testing.T) { // Create test issue issue := &types.Issue{ - Title: "Test issue", - Status: types.StatusOpen, - Priority: 1, - IssueType: types.TypeTask, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + 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) @@ -122,12 +122,12 @@ func TestSyncBranchCommitAndPush_Success(t *testing.T) { // 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(), + 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) @@ -228,12 +228,12 @@ func TestSyncBranchCommitAndPush_EnvOverridesDB(t *testing.T) { // 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(), + 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) @@ -303,12 +303,12 @@ func TestSyncBranchCommitAndPush_NoChanges(t *testing.T) { } issue := &types.Issue{ - Title: "Test issue", - Status: types.StatusOpen, - Priority: 1, - IssueType: types.TypeTask, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + 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) @@ -380,12 +380,12 @@ func TestSyncBranchCommitAndPush_WorktreeHealthCheck(t *testing.T) { } issue := &types.Issue{ - Title: "Test issue", - Status: types.StatusOpen, - Priority: 1, - IssueType: types.TypeTask, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + 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) @@ -497,7 +497,7 @@ func TestSyncBranchPull_Success(t *testing.T) { if err := os.MkdirAll(remoteDir, 0755); err != nil { t.Fatalf("Failed to create remote dir: %v", err) } - runGitCmd(t, remoteDir, "init", "--bare") + runGitCmd(t, remoteDir, "init", "--bare", "-b", "master") // Create clone1 (will push changes) clone1Dir := filepath.Join(tmpDir, "clone1") @@ -528,12 +528,12 @@ func TestSyncBranchPull_Success(t *testing.T) { // 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(), + 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) @@ -639,7 +639,7 @@ func TestSyncBranchIntegration_EndToEnd(t *testing.T) { tmpDir := t.TempDir() remoteDir := filepath.Join(tmpDir, "remote") os.MkdirAll(remoteDir, 0755) - runGitCmd(t, remoteDir, "init", "--bare") + runGitCmd(t, remoteDir, "init", "--bare", "-b", "master") // Clone1: Agent A clone1Dir := filepath.Join(tmpDir, "clone1") @@ -660,12 +660,12 @@ func TestSyncBranchIntegration_EndToEnd(t *testing.T) { // 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(), + 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 @@ -914,7 +914,7 @@ func TestSyncBranchMultipleConcurrentClones(t *testing.T) { tmpDir := t.TempDir() remoteDir := filepath.Join(tmpDir, "remote") os.MkdirAll(remoteDir, 0755) - runGitCmd(t, remoteDir, "init", "--bare") + runGitCmd(t, remoteDir, "init", "--bare", "-b", "master") syncBranch := "beads-sync" @@ -1454,7 +1454,7 @@ func TestGitPushFromWorktree_FetchRebaseRetry(t *testing.T) { // Create a "remote" bare repository remoteDir := t.TempDir() - runGitCmd(t, remoteDir, "init", "--bare") + runGitCmd(t, remoteDir, "init", "--bare", "-b", "master") // Create first clone (simulates another developer's clone) clone1Dir := t.TempDir() @@ -1524,7 +1524,7 @@ func TestGitPushFromWorktree_FetchRebaseRetry(t *testing.T) { // Now try to push from worktree - this should trigger the fetch-rebase-retry logic // because the remote has commits that the local worktree doesn't have - err := gitPushFromWorktree(ctx, worktreePath, "beads-sync") + err := gitPushFromWorktree(ctx, worktreePath, "beads-sync", "") if err != nil { t.Fatalf("gitPushFromWorktree failed: %v (expected fetch-rebase-retry to succeed)", err) } diff --git a/cmd/bd/delete_rpc_test.go b/cmd/bd/delete_rpc_test.go index de8b862d..82211b76 100644 --- a/cmd/bd/delete_rpc_test.go +++ b/cmd/bd/delete_rpc_test.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "io" + "log/slog" "os" "path/filepath" "strings" @@ -897,11 +898,7 @@ func setupDaemonTestEnvForDelete(t *testing.T) (context.Context, context.CancelF ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - log := daemonLogger{ - logFunc: func(format string, args ...interface{}) { - t.Logf("[daemon] "+format, args...) - }, - } + log := daemonLogger{logger: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelInfo}))} server, _, err := startRPCServer(ctx, socketPath, testStore, tmpDir, testDBPath, log) if err != nil { diff --git a/cmd/bd/doctor/git_hygiene_test.go b/cmd/bd/doctor/git_hygiene_test.go index 89c68aba..ebc0d266 100644 --- a/cmd/bd/doctor/git_hygiene_test.go +++ b/cmd/bd/doctor/git_hygiene_test.go @@ -12,7 +12,11 @@ func mkTmpDirInTmp(t *testing.T, prefix string) string { t.Helper() dir, err := os.MkdirTemp("/tmp", prefix) if err != nil { - t.Fatalf("failed to create temp dir: %v", err) + // Fallback for platforms without /tmp (e.g. Windows). + dir, err = os.MkdirTemp("", prefix) + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } } t.Cleanup(func() { _ = os.RemoveAll(dir) }) return dir diff --git a/cmd/bd/doctor_repair_chaos_test.go b/cmd/bd/doctor_repair_chaos_test.go new file mode 100644 index 00000000..792327d4 --- /dev/null +++ b/cmd/bd/doctor_repair_chaos_test.go @@ -0,0 +1,125 @@ +//go:build chaos + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDoctorRepair_CorruptDatabase_NotADatabase_RebuildFromJSONL(t *testing.T) { + bdExe := buildBDForTest(t) + ws := mkTmpDirInTmp(t, "bd-doctor-chaos-*") + dbPath := filepath.Join(ws, ".beads", "beads.db") + jsonlPath := filepath.Join(ws, ".beads", "issues.jsonl") + + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil { + t.Fatalf("bd init failed: %v", err) + } + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil { + t.Fatalf("bd create failed: %v", err) + } + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "export", "-o", jsonlPath, "--force"); err != nil { + t.Fatalf("bd export failed: %v", err) + } + + // Make the DB unreadable. + if err := os.WriteFile(dbPath, []byte("not a database"), 0644); err != nil { + t.Fatalf("corrupt db: %v", err) + } + + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes"); err != nil { + t.Fatalf("bd doctor --fix failed: %v", err) + } + + if out, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor"); err != nil { + t.Fatalf("bd doctor after fix failed: %v\n%s", err, out) + } +} + +func TestDoctorRepair_CorruptDatabase_NoJSONL_FixFails(t *testing.T) { + bdExe := buildBDForTest(t) + ws := mkTmpDirInTmp(t, "bd-doctor-chaos-nojsonl-*") + dbPath := filepath.Join(ws, ".beads", "beads.db") + + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil { + t.Fatalf("bd init failed: %v", err) + } + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil { + t.Fatalf("bd create failed: %v", err) + } + + // Some workflows keep JSONL in sync automatically; force it to be missing. + _ = os.Remove(filepath.Join(ws, ".beads", "issues.jsonl")) + _ = os.Remove(filepath.Join(ws, ".beads", "beads.jsonl")) + + // Corrupt without providing JSONL source-of-truth. + if err := os.Truncate(dbPath, 64); err != nil { + t.Fatalf("truncate db: %v", err) + } + + out, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes") + if err == nil { + t.Fatalf("expected bd doctor --fix to fail without JSONL") + } + if !strings.Contains(out, "cannot auto-recover") { + t.Fatalf("expected auto-recover error, got:\n%s", out) + } +} + +func TestDoctorRepair_CorruptDatabase_BacksUpSidecars(t *testing.T) { + bdExe := buildBDForTest(t) + ws := mkTmpDirInTmp(t, "bd-doctor-chaos-sidecars-*") + dbPath := filepath.Join(ws, ".beads", "beads.db") + jsonlPath := filepath.Join(ws, ".beads", "issues.jsonl") + + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil { + t.Fatalf("bd init failed: %v", err) + } + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil { + t.Fatalf("bd create failed: %v", err) + } + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "export", "-o", jsonlPath, "--force"); err != nil { + t.Fatalf("bd export failed: %v", err) + } + + // Ensure sidecars exist so we can verify they get moved with the backup. + for _, suffix := range []string{"-wal", "-shm", "-journal"} { + if err := os.WriteFile(dbPath+suffix, []byte("x"), 0644); err != nil { + t.Fatalf("write sidecar %s: %v", suffix, err) + } + } + if err := os.Truncate(dbPath, 64); err != nil { + t.Fatalf("truncate db: %v", err) + } + + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes"); err != nil { + t.Fatalf("bd doctor --fix failed: %v", err) + } + + // Verify a backup exists, and at least one sidecar got moved. + entries, err := os.ReadDir(filepath.Join(ws, ".beads")) + if err != nil { + t.Fatalf("readdir: %v", err) + } + var backup string + for _, e := range entries { + if strings.Contains(e.Name(), ".corrupt.backup.db") { + backup = filepath.Join(ws, ".beads", e.Name()) + break + } + } + if backup == "" { + t.Fatalf("expected backup db in .beads, found none") + } + + wal := backup + "-wal" + if _, err := os.Stat(wal); err != nil { + // At minimum, the backup DB itself should exist; sidecar backup is best-effort. + if _, err2 := os.Stat(backup); err2 != nil { + t.Fatalf("backup db missing: %v", err2) + } + } +} diff --git a/cmd/bd/doctor_repair_test.go b/cmd/bd/doctor_repair_test.go index b7476bf5..5e223a44 100644 --- a/cmd/bd/doctor_repair_test.go +++ b/cmd/bd/doctor_repair_test.go @@ -31,7 +31,11 @@ func mkTmpDirInTmp(t *testing.T, prefix string) string { t.Helper() dir, err := os.MkdirTemp("/tmp", prefix) if err != nil { - t.Fatalf("failed to create temp dir: %v", err) + // Fallback for platforms without /tmp (e.g. Windows). + dir, err = os.MkdirTemp("", prefix) + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } } t.Cleanup(func() { _ = os.RemoveAll(dir) }) return dir diff --git a/cmd/bd/git_sync_test.go b/cmd/bd/git_sync_test.go index ba5a8b73..d68f0803 100644 --- a/cmd/bd/git_sync_test.go +++ b/cmd/bd/git_sync_test.go @@ -26,36 +26,36 @@ func TestGitPullSyncIntegration(t *testing.T) { // Create temp directory for test repositories tempDir := t.TempDir() - + // Create "remote" repository remoteDir := filepath.Join(tempDir, "remote") if err := os.MkdirAll(remoteDir, 0750); err != nil { t.Fatalf("Failed to create remote dir: %v", err) } - + // Initialize remote git repo - runGitCmd(t, remoteDir, "init", "--bare") - + runGitCmd(t, remoteDir, "init", "--bare", "-b", "master") + // Create "clone1" repository clone1Dir := filepath.Join(tempDir, "clone1") runGitCmd(t, tempDir, "clone", remoteDir, clone1Dir) configureGit(t, clone1Dir) - + // Initialize beads in clone1 clone1BeadsDir := filepath.Join(clone1Dir, ".beads") if err := os.MkdirAll(clone1BeadsDir, 0750); err != nil { t.Fatalf("Failed to create .beads dir: %v", err) } - + clone1DBPath := filepath.Join(clone1BeadsDir, "test.db") clone1Store := newTestStore(t, clone1DBPath) defer clone1Store.Close() - + ctx := context.Background() if err := clone1Store.SetMetadata(ctx, "issue_prefix", "test"); err != nil { t.Fatalf("Failed to set prefix: %v", err) } - + // Create and close an issue in clone1 issue := &types.Issue{ Title: "Test sync issue", @@ -69,80 +69,80 @@ func TestGitPullSyncIntegration(t *testing.T) { t.Fatalf("Failed to create issue: %v", err) } issueID := issue.ID - + // Close the issue if err := clone1Store.CloseIssue(ctx, issueID, "Test completed", "test-user"); err != nil { t.Fatalf("Failed to close issue: %v", err) } - + // Export to JSONL jsonlPath := filepath.Join(clone1BeadsDir, "issues.jsonl") if err := exportIssuesToJSONL(ctx, clone1Store, jsonlPath); err != nil { t.Fatalf("Failed to export: %v", err) } - + // Commit and push from clone1 runGitCmd(t, clone1Dir, "add", ".beads") runGitCmd(t, clone1Dir, "commit", "-m", "Add closed issue") runGitCmd(t, clone1Dir, "push", "origin", "master") - + // Create "clone2" repository clone2Dir := filepath.Join(tempDir, "clone2") runGitCmd(t, tempDir, "clone", remoteDir, clone2Dir) configureGit(t, clone2Dir) - + // Initialize empty database in clone2 clone2BeadsDir := filepath.Join(clone2Dir, ".beads") clone2DBPath := filepath.Join(clone2BeadsDir, "test.db") clone2Store := newTestStore(t, clone2DBPath) defer clone2Store.Close() - + if err := clone2Store.SetMetadata(ctx, "issue_prefix", "test"); err != nil { t.Fatalf("Failed to set prefix: %v", err) } - + // Import the existing JSONL (simulating initial sync) clone2JSONLPath := filepath.Join(clone2BeadsDir, "issues.jsonl") if err := importJSONLToStore(ctx, clone2Store, clone2DBPath, clone2JSONLPath); err != nil { t.Fatalf("Failed to import: %v", err) } - + // Verify issue exists and is closed verifyIssueClosed(t, clone2Store, issueID) - + // Note: We don't commit in clone2 - it stays clean as a read-only consumer - + // Now test git pull scenario: Clone1 makes a change (update priority) if err := clone1Store.UpdateIssue(ctx, issueID, map[string]interface{}{ "priority": 0, }, "test-user"); err != nil { t.Fatalf("Failed to update issue: %v", err) } - + if err := exportIssuesToJSONL(ctx, clone1Store, jsonlPath); err != nil { t.Fatalf("Failed to export after update: %v", err) } - + runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl") runGitCmd(t, clone1Dir, "commit", "-m", "Update priority") runGitCmd(t, clone1Dir, "push", "origin", "master") - + // Clone2 pulls the change runGitCmd(t, clone2Dir, "pull") - + // Test auto-import in non-daemon mode t.Run("NonDaemonAutoImport", func(t *testing.T) { // Use a temporary local store for this test localStore := newTestStore(t, clone2DBPath) defer localStore.Close() - + // Manually import to simulate auto-import behavior startTime := time.Now() if err := importJSONLToStore(ctx, localStore, clone2DBPath, clone2JSONLPath); err != nil { t.Fatalf("Failed to auto-import: %v", err) } elapsed := time.Since(startTime) - + // Verify priority was updated issue, err := localStore.GetIssue(ctx, issueID) if err != nil { @@ -151,13 +151,13 @@ func TestGitPullSyncIntegration(t *testing.T) { if issue.Priority != 0 { t.Errorf("Expected priority 0 after auto-import, got %d", issue.Priority) } - + // Verify performance: import should be fast if elapsed > 100*time.Millisecond { t.Logf("Info: import took %v", elapsed) } }) - + // Test bd sync --import-only command t.Run("BdSyncCommand", func(t *testing.T) { // Make another change in clone1 (change priority back to 1) @@ -166,27 +166,27 @@ func TestGitPullSyncIntegration(t *testing.T) { }, "test-user"); err != nil { t.Fatalf("Failed to update issue: %v", err) } - + if err := exportIssuesToJSONL(ctx, clone1Store, jsonlPath); err != nil { t.Fatalf("Failed to export: %v", err) } - + runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl") runGitCmd(t, clone1Dir, "commit", "-m", "Update priority") runGitCmd(t, clone1Dir, "push", "origin", "master") - + // Clone2 pulls runGitCmd(t, clone2Dir, "pull") - + // Use a fresh store for import syncStore := newTestStore(t, clone2DBPath) defer syncStore.Close() - + // Manually trigger import via in-process equivalent if err := importJSONLToStore(ctx, syncStore, clone2DBPath, clone2JSONLPath); err != nil { t.Fatalf("Failed to import via sync: %v", err) } - + // Verify priority was updated back to 1 issue, err := syncStore.GetIssue(ctx, issueID) if err != nil { @@ -214,7 +214,7 @@ func configureGit(t *testing.T, dir string) { runGitCmd(t, dir, "config", "user.email", "test@example.com") runGitCmd(t, dir, "config", "user.name", "Test User") runGitCmd(t, dir, "config", "pull.rebase", "false") - + // Create .gitignore to prevent test database files from being tracked gitignorePath := filepath.Join(dir, ".gitignore") gitignoreContent := `# Test database files @@ -233,7 +233,7 @@ func exportIssuesToJSONL(ctx context.Context, store *sqlite.SQLiteStorage, jsonl if err != nil { return err } - + // Populate dependencies allDeps, err := store.GetAllDependencyRecords(ctx) if err != nil { @@ -244,20 +244,20 @@ func exportIssuesToJSONL(ctx context.Context, store *sqlite.SQLiteStorage, jsonl labels, _ := store.GetLabels(ctx, issue.ID) issue.Labels = labels } - + f, err := os.Create(jsonlPath) if err != nil { return err } defer f.Close() - + encoder := json.NewEncoder(f) for _, issue := range issues { if err := encoder.Encode(issue); err != nil { return err } } - + return nil } @@ -266,7 +266,7 @@ func importJSONLToStore(ctx context.Context, store *sqlite.SQLiteStorage, dbPath if err != nil { return err } - + // Use the autoimport package's AutoImportIfNewer function // For testing, we'll directly parse and import var issues []*types.Issue @@ -278,7 +278,7 @@ func importJSONLToStore(ctx context.Context, store *sqlite.SQLiteStorage, dbPath } issues = append(issues, &issue) } - + // Import each issue for _, issue := range issues { existing, _ := store.GetIssue(ctx, issue.ID) @@ -298,12 +298,12 @@ func importJSONLToStore(ctx context.Context, store *sqlite.SQLiteStorage, dbPath } } } - + // Set last_import_time metadata so staleness check works if err := store.SetMetadata(ctx, "last_import_time", time.Now().Format(time.RFC3339)); err != nil { return err } - + return nil } diff --git a/internal/beads/beads_hash_multiclone_test.go b/internal/beads/beads_hash_multiclone_test.go index 89ee842a..ea5a4383 100644 --- a/internal/beads/beads_hash_multiclone_test.go +++ b/internal/beads/beads_hash_multiclone_test.go @@ -48,10 +48,10 @@ func TestMain(m *testing.M) { fmt.Fprintf(os.Stderr, "Failed to build bd binary: %v\n%s\n", err, out) os.Exit(1) } - + // Optimize git for tests os.Setenv("GIT_CONFIG_NOSYSTEM", "1") - + os.Exit(m.Run()) } @@ -85,35 +85,35 @@ func TestHashIDs_MultiCloneConverge(t *testing.T) { } t.Parallel() tmpDir := testutil.TempDirInMemory(t) - + bdPath := getBDPath() if _, err := os.Stat(bdPath); err != nil { t.Fatalf("bd binary not found at %s", bdPath) } - + // Setup remote and 3 clones remoteDir := setupBareRepo(t, tmpDir) cloneA := setupClone(t, tmpDir, remoteDir, "A", bdPath) cloneB := setupClone(t, tmpDir, remoteDir, "B", bdPath) cloneC := setupClone(t, tmpDir, remoteDir, "C", bdPath) - + // Each clone creates unique issue (different content = different hash ID) createIssueInClone(t, cloneA, "Issue from clone A") createIssueInClone(t, cloneB, "Issue from clone B") createIssueInClone(t, cloneC, "Issue from clone C") - + // Sync all clones once (hash IDs prevent collisions, don't need multiple rounds) for _, clone := range []string{cloneA, cloneB, cloneC} { runCmdOutputWithEnvAllowError(t, clone, map[string]string{"BEADS_NO_DAEMON": "1"}, true, bdPath, "sync") } - + // Verify all clones have all 3 issues expectedTitles := map[string]bool{ "Issue from clone A": true, "Issue from clone B": true, "Issue from clone C": true, } - + allConverged := true for name, dir := range map[string]string{"A": cloneA, "B": cloneB, "C": cloneC} { titles := getTitlesFromClone(t, dir) @@ -122,7 +122,7 @@ func TestHashIDs_MultiCloneConverge(t *testing.T) { allConverged = false } } - + if allConverged { t.Log("✓ All 3 clones converged with hash-based IDs") } else { @@ -138,26 +138,26 @@ func TestHashIDs_IdenticalContentDedup(t *testing.T) { } t.Parallel() tmpDir := testutil.TempDirInMemory(t) - + bdPath := getBDPath() if _, err := os.Stat(bdPath); err != nil { t.Fatalf("bd binary not found at %s", bdPath) } - + // Setup remote and 2 clones remoteDir := setupBareRepo(t, tmpDir) cloneA := setupClone(t, tmpDir, remoteDir, "A", bdPath) cloneB := setupClone(t, tmpDir, remoteDir, "B", bdPath) - + // Both clones create identical issue (same content = same hash ID) createIssueInClone(t, cloneA, "Identical issue") createIssueInClone(t, cloneB, "Identical issue") - + // Sync both clones once (hash IDs handle dedup automatically) for _, clone := range []string{cloneA, cloneB} { runCmdOutputWithEnvAllowError(t, clone, map[string]string{"BEADS_NO_DAEMON": "1"}, true, bdPath, "sync") } - + // Verify both clones have exactly 1 issue (deduplication worked) for name, dir := range map[string]string{"A": cloneA, "B": cloneB} { titles := getTitlesFromClone(t, dir) @@ -168,7 +168,7 @@ func TestHashIDs_IdenticalContentDedup(t *testing.T) { t.Errorf("Clone %s missing expected issue: %v", name, sortedKeys(titles)) } } - + t.Log("✓ Identical content deduplicated correctly with hash-based IDs") } @@ -177,36 +177,36 @@ func TestHashIDs_IdenticalContentDedup(t *testing.T) { func setupBareRepo(t *testing.T, tmpDir string) string { t.Helper() remoteDir := filepath.Join(tmpDir, "remote.git") - runCmd(t, tmpDir, "git", "init", "--bare", remoteDir) - + runCmd(t, tmpDir, "git", "init", "--bare", "-b", "master", remoteDir) + tempClone := filepath.Join(tmpDir, "temp-init") runCmd(t, tmpDir, "git", "clone", remoteDir, tempClone) runCmd(t, tempClone, "git", "commit", "--allow-empty", "-m", "Initial commit") runCmd(t, tempClone, "git", "push", "origin", "master") - + return remoteDir } func setupClone(t *testing.T, tmpDir, remoteDir, name, bdPath string) string { t.Helper() cloneDir := filepath.Join(tmpDir, "clone-"+strings.ToLower(name)) - + // Use shallow, shared clones for speed runCmd(t, tmpDir, "git", "clone", "--shared", "--depth=1", "--no-tags", remoteDir, cloneDir) - + // Disable hooks to avoid overhead emptyHooks := filepath.Join(cloneDir, ".empty-hooks") os.MkdirAll(emptyHooks, 0755) runCmd(t, cloneDir, "git", "config", "core.hooksPath", emptyHooks) - + // Speed configs runCmd(t, cloneDir, "git", "config", "gc.auto", "0") runCmd(t, cloneDir, "git", "config", "core.fsync", "false") runCmd(t, cloneDir, "git", "config", "commit.gpgSign", "false") - + bdCmd := getBDCommand() copyFile(t, bdPath, filepath.Join(cloneDir, filepath.Base(bdCmd))) - + if name == "A" { runCmd(t, cloneDir, bdCmd, "init", "--quiet", "--prefix", "test") runCmd(t, cloneDir, "git", "add", ".beads") @@ -216,7 +216,7 @@ func setupClone(t *testing.T, tmpDir, remoteDir, name, bdPath string) string { runCmd(t, cloneDir, "git", "pull", "origin", "master") runCmd(t, cloneDir, bdCmd, "init", "--quiet", "--prefix", "test") } - + return cloneDir } @@ -231,13 +231,13 @@ func getTitlesFromClone(t *testing.T, cloneDir string) map[string]bool { "BEADS_NO_DAEMON": "1", "BD_NO_AUTO_IMPORT": "1", }, getBDCommand(), "list", "--json") - + jsonStart := strings.Index(listJSON, "[") if jsonStart == -1 { return make(map[string]bool) } listJSON = listJSON[jsonStart:] - + var issues []struct { Title string `json:"title"` } @@ -245,7 +245,7 @@ func getTitlesFromClone(t *testing.T, cloneDir string) map[string]bool { t.Logf("Failed to parse JSON: %v", err) return make(map[string]bool) } - + titles := make(map[string]bool) for _, issue := range issues { titles[issue.Title] = true @@ -280,7 +280,7 @@ func installGitHooks(t *testing.T, repoDir string) { hooksDir := filepath.Join(repoDir, ".git", "hooks") // Ensure POSIX-style path for sh scripts (even on Windows) bdCmd := strings.ReplaceAll(getBDCommand(), "\\", "/") - + preCommit := fmt.Sprintf(`#!/bin/sh %s --no-daemon export -o .beads/issues.jsonl >/dev/null 2>&1 || true git add .beads/issues.jsonl >/dev/null 2>&1 || true