Complete implementation of signal-aware context propagation for graceful cancellation across all commands and storage operations. Key changes: 1. Signal-aware contexts (bd-rtp): - Added rootCtx/rootCancel in main.go using signal.NotifyContext() - Set up in PersistentPreRun, cancelled in PersistentPostRun - Daemon uses same pattern in runDaemonLoop() - Handles SIGINT/SIGTERM for graceful shutdown 2. Context propagation (bd-yb8): - All commands now use rootCtx instead of context.Background() - sqlite.New() receives context for cancellable operations - Database operations respect context cancellation - Storage layer propagates context through all queries 3. Cancellation tests (bd-2o2): - Added import_cancellation_test.go with comprehensive tests - Added export cancellation test in export_test.go - Tests verify database integrity after cancellation - All cancellation tests passing Fixes applied during review: - Fixed rootCtx lifecycle (removed premature defer from PersistentPreRun) - Fixed test context contamination (reset rootCtx in test cleanup) - Fixed export tests missing context setup Impact: - Pressing Ctrl+C during import/export now cancels gracefully - No database corruption or hanging transactions - Clean shutdown of all operations Tested: - go build ./cmd/bd ✓ - go test ./cmd/bd -run TestImportCancellation ✓ - go test ./cmd/bd -run TestExportCommand ✓ - Manual Ctrl+C testing verified 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
834 lines
26 KiB
Go
834 lines
26 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/steveyegge/beads/internal/config"
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// TestMultiWorkspaceDeletionSync simulates the bd-hv01 bug scenario:
|
|
// Clone A deletes an issue, Clone B still has it, and after sync it should stay deleted
|
|
func TestMultiWorkspaceDeletionSync(t *testing.T) {
|
|
// Setup two separate workspaces simulating two git clones
|
|
cloneADir := t.TempDir()
|
|
cloneBDir := t.TempDir()
|
|
|
|
cloneAJSONL := filepath.Join(cloneADir, "beads.jsonl")
|
|
cloneBJSONL := filepath.Join(cloneBDir, "beads.jsonl")
|
|
|
|
cloneADB := filepath.Join(cloneADir, "beads.db")
|
|
cloneBDB := filepath.Join(cloneBDir, "beads.db")
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create stores for both clones
|
|
storeA, err := sqlite.New(context.Background(), cloneADB)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store A: %v", err)
|
|
}
|
|
defer storeA.Close()
|
|
|
|
if err := storeA.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
|
t.Fatalf("Failed to set issue_prefix for store A: %v", err)
|
|
}
|
|
|
|
storeB, err := sqlite.New(context.Background(), cloneBDB)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store B: %v", err)
|
|
}
|
|
defer storeB.Close()
|
|
|
|
if err := storeB.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
|
t.Fatalf("Failed to set issue_prefix for store B: %v", err)
|
|
}
|
|
|
|
// Step 1: Both clones start with the same two issues
|
|
issueToDelete := &types.Issue{
|
|
ID: "bd-delete-me",
|
|
Title: "Issue to be deleted",
|
|
Description: "This will be deleted in clone A",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: "bug",
|
|
}
|
|
|
|
issueToKeep := &types.Issue{
|
|
ID: "bd-keep-me",
|
|
Title: "Issue to keep",
|
|
Description: "This should remain",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: "feature",
|
|
}
|
|
|
|
// Create in both stores (using "test" as actor)
|
|
if err := storeA.CreateIssue(ctx, issueToDelete, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue in store A: %v", err)
|
|
}
|
|
if err := storeA.CreateIssue(ctx, issueToKeep, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue in store A: %v", err)
|
|
}
|
|
|
|
if err := storeB.CreateIssue(ctx, issueToDelete, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue in store B: %v", err)
|
|
}
|
|
if err := storeB.CreateIssue(ctx, issueToKeep, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue in store B: %v", err)
|
|
}
|
|
|
|
// Export from both
|
|
if err := exportToJSONLWithStore(ctx, storeA, cloneAJSONL); err != nil {
|
|
t.Fatalf("Failed to export from store A: %v", err)
|
|
}
|
|
if err := exportToJSONLWithStore(ctx, storeB, cloneBJSONL); err != nil {
|
|
t.Fatalf("Failed to export from store B: %v", err)
|
|
}
|
|
|
|
// Initialize base snapshots for both (simulating first sync)
|
|
if err := initializeSnapshotsIfNeeded(cloneAJSONL); err != nil {
|
|
t.Fatalf("Failed to initialize snapshots for A: %v", err)
|
|
}
|
|
if err := initializeSnapshotsIfNeeded(cloneBJSONL); err != nil {
|
|
t.Fatalf("Failed to initialize snapshots for B: %v", err)
|
|
}
|
|
|
|
// Step 2: Clone A deletes the issue
|
|
if err := storeA.DeleteIssue(ctx, "bd-delete-me"); err != nil {
|
|
t.Fatalf("Failed to delete issue in store A: %v", err)
|
|
}
|
|
|
|
// Step 3: Clone A exports and captures left snapshot (simulating pre-pull)
|
|
if err := exportToJSONLWithStore(ctx, storeA, cloneAJSONL); err != nil {
|
|
t.Fatalf("Failed to export from store A after deletion: %v", err)
|
|
}
|
|
if err := captureLeftSnapshot(cloneAJSONL); err != nil {
|
|
t.Fatalf("Failed to capture left snapshot for A: %v", err)
|
|
}
|
|
|
|
// Simulate git push/pull: Copy Clone A's JSONL to Clone B's "remote" state
|
|
remoteJSONL := cloneAJSONL
|
|
|
|
// Step 4: Clone B exports (still has both issues) and captures left snapshot
|
|
if err := exportToJSONLWithStore(ctx, storeB, cloneBJSONL); err != nil {
|
|
t.Fatalf("Failed to export from store B: %v", err)
|
|
}
|
|
if err := captureLeftSnapshot(cloneBJSONL); err != nil {
|
|
t.Fatalf("Failed to capture left snapshot for B: %v", err)
|
|
}
|
|
|
|
// Step 5: Simulate Clone B pulling from remote (copy remote JSONL)
|
|
remoteData, err := os.ReadFile(remoteJSONL)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read remote JSONL: %v", err)
|
|
}
|
|
if err := os.WriteFile(cloneBJSONL, remoteData, 0644); err != nil {
|
|
t.Fatalf("Failed to write pulled JSONL to clone B: %v", err)
|
|
}
|
|
|
|
// Step 6: Clone B applies 3-way merge and prunes deletions
|
|
// This is the key fix - it should detect that bd-delete-me was deleted remotely
|
|
merged, err := merge3WayAndPruneDeletions(ctx, storeB, cloneBJSONL)
|
|
if err != nil {
|
|
t.Fatalf("Failed to apply deletions from merge: %v", err)
|
|
}
|
|
|
|
if !merged {
|
|
t.Error("Expected 3-way merge to run, but it was skipped")
|
|
}
|
|
|
|
// Step 7: Verify the deletion was applied to Clone B's database
|
|
deletedIssue, err := storeB.GetIssue(ctx, "bd-delete-me")
|
|
if err == nil && deletedIssue != nil {
|
|
t.Errorf("Issue bd-delete-me should have been deleted from Clone B, but still exists")
|
|
}
|
|
|
|
// Verify the kept issue still exists
|
|
keptIssue, err := storeB.GetIssue(ctx, "bd-keep-me")
|
|
if err != nil || keptIssue == nil {
|
|
t.Errorf("Issue bd-keep-me should still exist in Clone B")
|
|
}
|
|
|
|
// Verify Clone A still has only one issue
|
|
issuesA, err := storeA.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to search issues in store A: %v", err)
|
|
}
|
|
if len(issuesA) != 1 {
|
|
t.Errorf("Clone A should have 1 issue after deletion, got %d", len(issuesA))
|
|
}
|
|
|
|
// Verify Clone B now matches Clone A (both have 1 issue)
|
|
issuesB, err := storeB.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to search issues in store B: %v", err)
|
|
}
|
|
if len(issuesB) != 1 {
|
|
t.Errorf("Clone B should have 1 issue after merge, got %d", len(issuesB))
|
|
}
|
|
}
|
|
|
|
// TestDeletionWithLocalModification tests the conflict scenario:
|
|
// Remote deletes an issue, but local has modified it
|
|
func TestDeletionWithLocalModification(t *testing.T) {
|
|
dir := t.TempDir()
|
|
jsonlPath := filepath.Join(dir, "beads.jsonl")
|
|
dbPath := filepath.Join(dir, "beads.db")
|
|
|
|
ctx := context.Background()
|
|
|
|
store, err := sqlite.New(context.Background(), dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
|
t.Fatalf("Failed to set issue_prefix: %v", err)
|
|
}
|
|
|
|
// Create an issue
|
|
issue := &types.Issue{
|
|
ID: "bd-conflict",
|
|
Title: "Original title",
|
|
Description: "Original description",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: "bug",
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
|
|
// Export and create base snapshot
|
|
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
|
|
t.Fatalf("Failed to export: %v", err)
|
|
}
|
|
if err := initializeSnapshotsIfNeeded(jsonlPath); err != nil {
|
|
t.Fatalf("Failed to initialize snapshots: %v", err)
|
|
}
|
|
|
|
// Modify the issue locally
|
|
updates := map[string]interface{}{
|
|
"title": "Modified title locally",
|
|
}
|
|
if err := store.UpdateIssue(ctx, "bd-conflict", updates, "test"); err != nil {
|
|
t.Fatalf("Failed to update issue: %v", err)
|
|
}
|
|
|
|
// Export modified state and capture left snapshot
|
|
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
|
|
t.Fatalf("Failed to export after modification: %v", err)
|
|
}
|
|
if err := captureLeftSnapshot(jsonlPath); err != nil {
|
|
t.Fatalf("Failed to capture left snapshot: %v", err)
|
|
}
|
|
|
|
// Simulate remote deletion (write empty JSONL)
|
|
if err := os.WriteFile(jsonlPath, []byte(""), 0644); err != nil {
|
|
t.Fatalf("Failed to simulate remote deletion: %v", err)
|
|
}
|
|
|
|
// Try to merge - this should detect a conflict (modified locally, deleted remotely)
|
|
_, err = merge3WayAndPruneDeletions(ctx, store, jsonlPath)
|
|
if err == nil {
|
|
t.Error("Expected merge conflict error, but got nil")
|
|
}
|
|
|
|
// The issue should still exist in the database (conflict not auto-resolved)
|
|
conflictIssue, err := store.GetIssue(ctx, "bd-conflict")
|
|
if err != nil || conflictIssue == nil {
|
|
t.Error("Issue should still exist after conflict")
|
|
}
|
|
}
|
|
|
|
// TestComputeAcceptedDeletions tests the deletion detection logic
|
|
func TestComputeAcceptedDeletions(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
jsonlPath := filepath.Join(dir, "issues.jsonl")
|
|
sm := NewSnapshotManager(jsonlPath)
|
|
basePath, leftPath := sm.GetSnapshotPaths()
|
|
mergedPath := filepath.Join(dir, "merged.jsonl")
|
|
|
|
// Base has 3 issues
|
|
baseContent := `{"id":"bd-1","title":"Issue 1"}
|
|
{"id":"bd-2","title":"Issue 2"}
|
|
{"id":"bd-3","title":"Issue 3"}
|
|
`
|
|
|
|
// Left has 3 issues (unchanged from base)
|
|
leftContent := baseContent
|
|
|
|
// Merged has only 2 issues (bd-2 was deleted remotely)
|
|
mergedContent := `{"id":"bd-1","title":"Issue 1"}
|
|
{"id":"bd-3","title":"Issue 3"}
|
|
`
|
|
|
|
if err := os.WriteFile(basePath, []byte(baseContent), 0644); err != nil {
|
|
t.Fatalf("Failed to write base: %v", err)
|
|
}
|
|
if err := os.WriteFile(leftPath, []byte(leftContent), 0644); err != nil {
|
|
t.Fatalf("Failed to write left: %v", err)
|
|
}
|
|
if err := os.WriteFile(mergedPath, []byte(mergedContent), 0644); err != nil {
|
|
t.Fatalf("Failed to write merged: %v", err)
|
|
}
|
|
|
|
deletions, err := sm.ComputeAcceptedDeletions(mergedPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to compute deletions: %v", err)
|
|
}
|
|
|
|
if len(deletions) != 1 {
|
|
t.Errorf("Expected 1 deletion, got %d", len(deletions))
|
|
}
|
|
|
|
if len(deletions) > 0 && deletions[0] != "bd-2" {
|
|
t.Errorf("Expected deletion of bd-2, got %s", deletions[0])
|
|
}
|
|
}
|
|
|
|
// TestComputeAcceptedDeletions_LocallyModified tests that locally modified issues are not deleted
|
|
func TestComputeAcceptedDeletions_LocallyModified(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
basePath := filepath.Join(dir, "base.jsonl")
|
|
leftPath := filepath.Join(dir, "left.jsonl")
|
|
mergedPath := filepath.Join(dir, "merged.jsonl")
|
|
|
|
// Base has 2 issues
|
|
baseContent := `{"id":"bd-1","title":"Original 1"}
|
|
{"id":"bd-2","title":"Original 2"}
|
|
`
|
|
|
|
// Left has bd-2 modified locally
|
|
leftContent := `{"id":"bd-1","title":"Original 1"}
|
|
{"id":"bd-2","title":"Modified locally"}
|
|
`
|
|
|
|
// Merged has only bd-1 (bd-2 deleted remotely, but we modified it locally)
|
|
mergedContent := `{"id":"bd-1","title":"Original 1"}
|
|
`
|
|
|
|
if err := os.WriteFile(basePath, []byte(baseContent), 0644); err != nil {
|
|
t.Fatalf("Failed to write base: %v", err)
|
|
}
|
|
if err := os.WriteFile(leftPath, []byte(leftContent), 0644); err != nil {
|
|
t.Fatalf("Failed to write left: %v", err)
|
|
}
|
|
if err := os.WriteFile(mergedPath, []byte(mergedContent), 0644); err != nil {
|
|
t.Fatalf("Failed to write merged: %v", err)
|
|
}
|
|
|
|
jsonlPath := filepath.Join(dir, "issues.jsonl")
|
|
sm := NewSnapshotManager(jsonlPath)
|
|
deletions, err := sm.ComputeAcceptedDeletions(mergedPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to compute deletions: %v", err)
|
|
}
|
|
|
|
// bd-2 should NOT be in accepted deletions because it was modified locally
|
|
if len(deletions) != 0 {
|
|
t.Errorf("Expected 0 deletions (locally modified), got %d: %v", len(deletions), deletions)
|
|
}
|
|
}
|
|
|
|
// TestSnapshotManagement tests the snapshot file lifecycle
|
|
func TestSnapshotManagement(t *testing.T) {
|
|
dir := t.TempDir()
|
|
jsonlPath := filepath.Join(dir, "beads.jsonl")
|
|
|
|
// Write initial JSONL
|
|
content := `{"id":"bd-1","title":"Test"}
|
|
`
|
|
if err := os.WriteFile(jsonlPath, []byte(content), 0644); err != nil {
|
|
t.Fatalf("Failed to write JSONL: %v", err)
|
|
}
|
|
|
|
// Initialize snapshots
|
|
if err := initializeSnapshotsIfNeeded(jsonlPath); err != nil {
|
|
t.Fatalf("Failed to initialize snapshots: %v", err)
|
|
}
|
|
|
|
sm := NewSnapshotManager(jsonlPath)
|
|
basePath, leftPath := sm.GetSnapshotPaths()
|
|
|
|
// Base should exist, left should not
|
|
if !fileExists(basePath) {
|
|
t.Error("Base snapshot should exist after initialization")
|
|
}
|
|
if fileExists(leftPath) {
|
|
t.Error("Left snapshot should not exist yet")
|
|
}
|
|
|
|
// Capture left snapshot
|
|
if err := captureLeftSnapshot(jsonlPath); err != nil {
|
|
t.Fatalf("Failed to capture left snapshot: %v", err)
|
|
}
|
|
|
|
if !fileExists(leftPath) {
|
|
t.Error("Left snapshot should exist after capture")
|
|
}
|
|
|
|
// Update base snapshot
|
|
if err := updateBaseSnapshot(jsonlPath); err != nil {
|
|
t.Fatalf("Failed to update base snapshot: %v", err)
|
|
}
|
|
|
|
// Both should exist now
|
|
baseCount, leftCount, baseExists, leftExists := getSnapshotStats(jsonlPath)
|
|
if !baseExists || !leftExists {
|
|
t.Error("Both snapshots should exist")
|
|
}
|
|
if baseCount != 1 || leftCount != 1 {
|
|
t.Errorf("Expected 1 issue in each snapshot, got base=%d left=%d", baseCount, leftCount)
|
|
}
|
|
}
|
|
|
|
// TestMultiRepoDeletionTracking tests deletion tracking with multi-repo mode
|
|
// This is the test for bd-4oob: snapshot files need to be created per-JSONL file
|
|
func TestMultiRepoDeletionTracking(t *testing.T) {
|
|
// Setup workspace directories
|
|
primaryDir := t.TempDir()
|
|
additionalDir := t.TempDir()
|
|
|
|
// Setup .beads directories
|
|
primaryBeadsDir := filepath.Join(primaryDir, ".beads")
|
|
additionalBeadsDir := filepath.Join(additionalDir, ".beads")
|
|
if err := os.MkdirAll(primaryBeadsDir, 0755); err != nil {
|
|
t.Fatalf("Failed to create primary .beads dir: %v", err)
|
|
}
|
|
if err := os.MkdirAll(additionalBeadsDir, 0755); err != nil {
|
|
t.Fatalf("Failed to create additional .beads dir: %v", err)
|
|
}
|
|
|
|
// Create database in primary dir
|
|
dbPath := filepath.Join(primaryBeadsDir, "beads.db")
|
|
ctx := context.Background()
|
|
|
|
store, err := sqlite.New(context.Background(), dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
|
t.Fatalf("Failed to set issue_prefix: %v", err)
|
|
}
|
|
|
|
// Setup multi-repo config
|
|
config.Set("repos.primary", primaryDir)
|
|
config.Set("repos.additional", []string{additionalDir})
|
|
defer func() {
|
|
config.Set("repos.primary", "")
|
|
config.Set("repos.additional", nil)
|
|
}()
|
|
|
|
// Create issues in different repos
|
|
primaryIssue := &types.Issue{
|
|
ID: "bd-primary",
|
|
Title: "Primary repo issue",
|
|
Description: "This belongs to primary",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: "task",
|
|
SourceRepo: ".", // Primary repo
|
|
}
|
|
|
|
additionalIssue := &types.Issue{
|
|
ID: "bd-additional",
|
|
Title: "Additional repo issue",
|
|
Description: "This belongs to additional",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: "task",
|
|
SourceRepo: additionalDir,
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, primaryIssue, "test"); err != nil {
|
|
t.Fatalf("Failed to create primary issue: %v", err)
|
|
}
|
|
if err := store.CreateIssue(ctx, additionalIssue, "test"); err != nil {
|
|
t.Fatalf("Failed to create additional issue: %v", err)
|
|
}
|
|
|
|
// Export to multi-repo (this creates multiple JSONL files)
|
|
results, err := store.ExportToMultiRepo(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ExportToMultiRepo failed: %v", err)
|
|
}
|
|
if results == nil {
|
|
t.Fatal("Expected multi-repo results, got nil")
|
|
}
|
|
if results["."] != 1 {
|
|
t.Errorf("Expected 1 issue in primary repo, got %d", results["."])
|
|
}
|
|
if results[additionalDir] != 1 {
|
|
t.Errorf("Expected 1 issue in additional repo, got %d", results[additionalDir])
|
|
}
|
|
|
|
// Verify JSONL files exist
|
|
primaryJSONL := filepath.Join(primaryBeadsDir, "issues.jsonl")
|
|
additionalJSONL := filepath.Join(additionalBeadsDir, "issues.jsonl")
|
|
|
|
if !fileExists(primaryJSONL) {
|
|
t.Fatalf("Primary JSONL not created: %s", primaryJSONL)
|
|
}
|
|
if !fileExists(additionalJSONL) {
|
|
t.Fatalf("Additional JSONL not created: %s", additionalJSONL)
|
|
}
|
|
|
|
// THIS IS THE BUG: Initialize snapshots - currently only works for single JSONL
|
|
// Should create snapshots for BOTH JSONL files
|
|
if err := initializeSnapshotsIfNeeded(primaryJSONL); err != nil {
|
|
t.Fatalf("Failed to initialize primary snapshots: %v", err)
|
|
}
|
|
if err := initializeSnapshotsIfNeeded(additionalJSONL); err != nil {
|
|
t.Fatalf("Failed to initialize additional snapshots: %v", err)
|
|
}
|
|
|
|
// Verify snapshot files exist for both repos
|
|
primarySM := NewSnapshotManager(primaryJSONL)
|
|
primaryBasePath, primaryLeftPath := primarySM.GetSnapshotPaths()
|
|
additionalSM := NewSnapshotManager(additionalJSONL)
|
|
additionalBasePath, additionalLeftPath := additionalSM.GetSnapshotPaths()
|
|
|
|
if !fileExists(primaryBasePath) {
|
|
t.Errorf("Primary base snapshot not created: %s", primaryBasePath)
|
|
}
|
|
if !fileExists(additionalBasePath) {
|
|
t.Errorf("Additional base snapshot not created: %s", additionalBasePath)
|
|
}
|
|
|
|
// Capture left snapshot BEFORE simulating git pull
|
|
// This represents our local state before the pull
|
|
if err := captureLeftSnapshot(primaryJSONL); err != nil {
|
|
t.Fatalf("Failed to capture primary left snapshot: %v", err)
|
|
}
|
|
if err := captureLeftSnapshot(additionalJSONL); err != nil {
|
|
t.Fatalf("Failed to capture additional left snapshot: %v", err)
|
|
}
|
|
|
|
// Simulate remote deletion: replace additional repo JSONL with empty file
|
|
// This simulates what happens after a git pull where remote deleted the issue
|
|
emptyFile, err := os.Create(additionalJSONL)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create empty JSONL: %v", err)
|
|
}
|
|
emptyFile.Close()
|
|
|
|
// Verify left snapshots exist
|
|
if !fileExists(primaryLeftPath) {
|
|
t.Errorf("Primary left snapshot not created: %s", primaryLeftPath)
|
|
}
|
|
if !fileExists(additionalLeftPath) {
|
|
t.Errorf("Additional left snapshot not created: %s", additionalLeftPath)
|
|
}
|
|
|
|
// Now apply deletion tracking for additional repo
|
|
// This should detect that bd-additional was deleted remotely and remove it from DB
|
|
merged, err := merge3WayAndPruneDeletions(ctx, store, additionalJSONL)
|
|
if err != nil {
|
|
t.Fatalf("merge3WayAndPruneDeletions failed for additional repo: %v", err)
|
|
}
|
|
if !merged {
|
|
t.Error("Expected merge to be performed (base snapshot exists)")
|
|
}
|
|
|
|
// Verify the issue was deleted from the database
|
|
issue, err := store.GetIssue(ctx, "bd-additional")
|
|
if err != nil {
|
|
t.Errorf("Unexpected error getting issue: %v", err)
|
|
}
|
|
if issue != nil {
|
|
t.Errorf("Expected bd-additional to be deleted from database, but it still exists: %+v", issue)
|
|
}
|
|
|
|
// Verify primary issue still exists
|
|
primaryResult, err := store.GetIssue(ctx, "bd-primary")
|
|
if err != nil {
|
|
t.Errorf("Primary issue should still exist: %v", err)
|
|
}
|
|
if primaryResult == nil {
|
|
t.Error("Primary issue should not be nil")
|
|
}
|
|
}
|
|
|
|
// TestGetMultiRepoJSONLPaths_EmptyPaths tests handling of empty path configs
|
|
func TestGetMultiRepoJSONLPaths_EmptyPaths(t *testing.T) {
|
|
// Test with empty primary (should default to ".")
|
|
config.Set("repos.primary", "")
|
|
config.Set("repos.additional", []string{})
|
|
defer func() {
|
|
config.Set("repos.primary", "")
|
|
config.Set("repos.additional", nil)
|
|
}()
|
|
|
|
paths := getMultiRepoJSONLPaths()
|
|
if paths != nil {
|
|
t.Errorf("Expected nil (single-repo mode) for empty primary, got %v", paths)
|
|
}
|
|
}
|
|
|
|
// TestGetMultiRepoJSONLPaths_Duplicates tests deduplication of paths
|
|
func TestGetMultiRepoJSONLPaths_Duplicates(t *testing.T) {
|
|
// Setup temp dirs
|
|
primaryDir := t.TempDir()
|
|
|
|
// Create .beads directories
|
|
if err := os.MkdirAll(filepath.Join(primaryDir, ".beads"), 0755); err != nil {
|
|
t.Fatalf("Failed to create .beads dir: %v", err)
|
|
}
|
|
|
|
// Test with duplicate paths (., ./, and absolute path to same location)
|
|
config.Set("repos.primary", primaryDir)
|
|
config.Set("repos.additional", []string{primaryDir, primaryDir}) // Duplicates
|
|
defer func() {
|
|
config.Set("repos.primary", "")
|
|
config.Set("repos.additional", nil)
|
|
}()
|
|
|
|
paths := getMultiRepoJSONLPaths()
|
|
|
|
// Current implementation doesn't dedupe - just verify it returns all entries
|
|
// (This documents current behavior; future improvement could dedupe)
|
|
expectedCount := 3 // primary + 2 duplicates
|
|
if len(paths) != expectedCount {
|
|
t.Errorf("Expected %d paths, got %d: %v", expectedCount, len(paths), paths)
|
|
}
|
|
|
|
// All should point to same JSONL location
|
|
expectedJSONL := filepath.Join(primaryDir, ".beads", "issues.jsonl")
|
|
for i, p := range paths {
|
|
if p != expectedJSONL {
|
|
t.Errorf("Path[%d] = %s, want %s", i, p, expectedJSONL)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGetMultiRepoJSONLPaths_PathsWithSpaces tests handling of paths containing spaces
|
|
func TestGetMultiRepoJSONLPaths_PathsWithSpaces(t *testing.T) {
|
|
// Create temp dir with space in name
|
|
baseDir := t.TempDir()
|
|
primaryDir := filepath.Join(baseDir, "my project")
|
|
additionalDir := filepath.Join(baseDir, "other repo")
|
|
|
|
// Create .beads directories
|
|
if err := os.MkdirAll(filepath.Join(primaryDir, ".beads"), 0755); err != nil {
|
|
t.Fatalf("Failed to create primary .beads: %v", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(additionalDir, ".beads"), 0755); err != nil {
|
|
t.Fatalf("Failed to create additional .beads: %v", err)
|
|
}
|
|
|
|
config.Set("repos.primary", primaryDir)
|
|
config.Set("repos.additional", []string{additionalDir})
|
|
defer func() {
|
|
config.Set("repos.primary", "")
|
|
config.Set("repos.additional", nil)
|
|
}()
|
|
|
|
paths := getMultiRepoJSONLPaths()
|
|
|
|
if len(paths) != 2 {
|
|
t.Fatalf("Expected 2 paths, got %d", len(paths))
|
|
}
|
|
|
|
// Verify paths are constructed correctly
|
|
expectedPrimary := filepath.Join(primaryDir, ".beads", "issues.jsonl")
|
|
expectedAdditional := filepath.Join(additionalDir, ".beads", "issues.jsonl")
|
|
|
|
if paths[0] != expectedPrimary {
|
|
t.Errorf("Primary path = %s, want %s", paths[0], expectedPrimary)
|
|
}
|
|
if paths[1] != expectedAdditional {
|
|
t.Errorf("Additional path = %s, want %s", paths[1], expectedAdditional)
|
|
}
|
|
}
|
|
|
|
// TestGetMultiRepoJSONLPaths_RelativePaths tests handling of relative paths
|
|
func TestGetMultiRepoJSONLPaths_RelativePaths(t *testing.T) {
|
|
// Note: Current implementation takes paths as-is without normalization
|
|
// This test documents current behavior
|
|
config.Set("repos.primary", ".")
|
|
config.Set("repos.additional", []string{"../other", "./foo/../bar"})
|
|
defer func() {
|
|
config.Set("repos.primary", "")
|
|
config.Set("repos.additional", nil)
|
|
}()
|
|
|
|
paths := getMultiRepoJSONLPaths()
|
|
|
|
if len(paths) != 3 {
|
|
t.Fatalf("Expected 3 paths, got %d", len(paths))
|
|
}
|
|
|
|
// Current implementation: relative paths are NOT expanded to absolute
|
|
// They're used as-is with filepath.Join
|
|
expectedPrimary := filepath.Join(".", ".beads", "issues.jsonl")
|
|
expectedOther := filepath.Join("../other", ".beads", "issues.jsonl")
|
|
expectedBar := filepath.Join("./foo/../bar", ".beads", "issues.jsonl")
|
|
|
|
if paths[0] != expectedPrimary {
|
|
t.Errorf("Primary path = %s, want %s", paths[0], expectedPrimary)
|
|
}
|
|
if paths[1] != expectedOther {
|
|
t.Errorf("Additional[0] path = %s, want %s", paths[1], expectedOther)
|
|
}
|
|
if paths[2] != expectedBar {
|
|
t.Errorf("Additional[1] path = %s, want %s", paths[2], expectedBar)
|
|
}
|
|
}
|
|
|
|
// TestGetMultiRepoJSONLPaths_TildeExpansion tests that tilde is NOT expanded
|
|
func TestGetMultiRepoJSONLPaths_TildeExpansion(t *testing.T) {
|
|
// Current implementation does NOT expand tilde - it's used literally
|
|
config.Set("repos.primary", "~/repos/main")
|
|
config.Set("repos.additional", []string{"~/repos/other"})
|
|
defer func() {
|
|
config.Set("repos.primary", "")
|
|
config.Set("repos.additional", nil)
|
|
}()
|
|
|
|
paths := getMultiRepoJSONLPaths()
|
|
|
|
if len(paths) != 2 {
|
|
t.Fatalf("Expected 2 paths, got %d", len(paths))
|
|
}
|
|
|
|
// Tilde should be literal (NOT expanded) in current implementation
|
|
expectedPrimary := filepath.Join("~/repos/main", ".beads", "issues.jsonl")
|
|
expectedAdditional := filepath.Join("~/repos/other", ".beads", "issues.jsonl")
|
|
|
|
if paths[0] != expectedPrimary {
|
|
t.Errorf("Primary path = %s, want %s", paths[0], expectedPrimary)
|
|
}
|
|
if paths[1] != expectedAdditional {
|
|
t.Errorf("Additional path = %s, want %s", paths[1], expectedAdditional)
|
|
}
|
|
}
|
|
|
|
// TestMultiRepoSnapshotIsolation verifies that snapshot operations on one repo
|
|
// don't interfere with another repo's snapshots
|
|
func TestMultiRepoSnapshotIsolation(t *testing.T) {
|
|
// Setup two repo directories
|
|
repo1Dir := t.TempDir()
|
|
repo2Dir := t.TempDir()
|
|
|
|
// Create .beads/issues.jsonl in each
|
|
repo1JSONL := filepath.Join(repo1Dir, ".beads", "issues.jsonl")
|
|
repo2JSONL := filepath.Join(repo2Dir, ".beads", "issues.jsonl")
|
|
|
|
if err := os.MkdirAll(filepath.Dir(repo1JSONL), 0755); err != nil {
|
|
t.Fatalf("Failed to create repo1 .beads: %v", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(repo2JSONL), 0755); err != nil {
|
|
t.Fatalf("Failed to create repo2 .beads: %v", err)
|
|
}
|
|
|
|
// Write test issues
|
|
issue1 := map[string]interface{}{
|
|
"id": "bd-repo1-issue",
|
|
"title": "Repo 1 Issue",
|
|
}
|
|
issue2 := map[string]interface{}{
|
|
"id": "bd-repo2-issue",
|
|
"title": "Repo 2 Issue",
|
|
}
|
|
|
|
// Write to repo1
|
|
f1, err := os.Create(repo1JSONL)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create repo1 JSONL: %v", err)
|
|
}
|
|
json.NewEncoder(f1).Encode(issue1)
|
|
f1.Close()
|
|
|
|
// Write to repo2
|
|
f2, err := os.Create(repo2JSONL)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create repo2 JSONL: %v", err)
|
|
}
|
|
json.NewEncoder(f2).Encode(issue2)
|
|
f2.Close()
|
|
|
|
// Initialize snapshots for both
|
|
if err := initializeSnapshotsIfNeeded(repo1JSONL); err != nil {
|
|
t.Fatalf("Failed to init repo1 snapshots: %v", err)
|
|
}
|
|
if err := initializeSnapshotsIfNeeded(repo2JSONL); err != nil {
|
|
t.Fatalf("Failed to init repo2 snapshots: %v", err)
|
|
}
|
|
|
|
// Get snapshot paths for both
|
|
repo1SM := NewSnapshotManager(repo1JSONL)
|
|
repo1Base, repo1Left := repo1SM.GetSnapshotPaths()
|
|
repo2SM := NewSnapshotManager(repo2JSONL)
|
|
repo2Base, repo2Left := repo2SM.GetSnapshotPaths()
|
|
|
|
// Verify isolation: snapshots should be in different directories
|
|
if filepath.Dir(repo1Base) == filepath.Dir(repo2Base) {
|
|
t.Error("Snapshot directories should be different for different repos")
|
|
}
|
|
|
|
// Verify each snapshot contains only its own issue
|
|
repo1IDs, err := repo1SM.BuildIDSet(repo1Base)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read repo1 base snapshot: %v", err)
|
|
}
|
|
repo2IDs, err := repo2SM.BuildIDSet(repo2Base)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read repo2 base snapshot: %v", err)
|
|
}
|
|
|
|
if !repo1IDs["bd-repo1-issue"] {
|
|
t.Error("Repo1 snapshot should contain bd-repo1-issue")
|
|
}
|
|
if repo1IDs["bd-repo2-issue"] {
|
|
t.Error("Repo1 snapshot should NOT contain bd-repo2-issue")
|
|
}
|
|
|
|
if !repo2IDs["bd-repo2-issue"] {
|
|
t.Error("Repo2 snapshot should contain bd-repo2-issue")
|
|
}
|
|
if repo2IDs["bd-repo1-issue"] {
|
|
t.Error("Repo2 snapshot should NOT contain bd-repo1-issue")
|
|
}
|
|
|
|
// Capture left snapshots for both
|
|
if err := captureLeftSnapshot(repo1JSONL); err != nil {
|
|
t.Fatalf("Failed to capture repo1 left: %v", err)
|
|
}
|
|
if err := captureLeftSnapshot(repo2JSONL); err != nil {
|
|
t.Fatalf("Failed to capture repo2 left: %v", err)
|
|
}
|
|
|
|
// Verify left snapshots are isolated
|
|
if !fileExists(repo1Left) || !fileExists(repo2Left) {
|
|
t.Error("Both left snapshots should exist")
|
|
}
|
|
|
|
repo1LeftIDs, err := repo1SM.BuildIDSet(repo1Left)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read repo1 left snapshot: %v", err)
|
|
}
|
|
repo2LeftIDs, err := repo2SM.BuildIDSet(repo2Left)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read repo2 left snapshot: %v", err)
|
|
}
|
|
|
|
if !repo1LeftIDs["bd-repo1-issue"] || repo1LeftIDs["bd-repo2-issue"] {
|
|
t.Error("Repo1 left snapshot has wrong issues")
|
|
}
|
|
if !repo2LeftIDs["bd-repo2-issue"] || repo2LeftIDs["bd-repo1-issue"] {
|
|
t.Error("Repo2 left snapshot has wrong issues")
|
|
}
|
|
}
|