Files
beads/internal/storage/sqlite/multirepo_test.go
collins 7cf67153de refactor(types): remove Gas Town type constants from beads core (bd-w2zz4)
Remove Gas Town-specific type constants (TypeMolecule, TypeGate, TypeConvoy,
TypeMergeRequest, TypeSlot, TypeAgent, TypeRole, TypeRig, TypeEvent, TypeMessage)
from internal/types/types.go.

Beads now only has core work types built-in:
- bug, feature, task, epic, chore

All Gas Town types are now purely custom types with no special handling in beads.
Use string literals like "gate" or "molecule" when needed, and configure
types.custom in config.yaml for validation.

Changes:
- Remove Gas Town type constants from types.go
- Remove mr/mol aliases from Normalize()
- Update bd types command to only show core types
- Replace all constant usages with string literals throughout codebase
- Update tests to use string literals

This decouples beads from Gas Town, making it a generic issue tracker.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 10:36:59 -08:00

1220 lines
34 KiB
Go

package sqlite
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/types"
)
func TestExpandTilde(t *testing.T) {
tests := []struct {
name string
path string
wantErr bool
}{
{"no tilde", "/absolute/path", false},
{"tilde alone", "~", false},
{"tilde with path", "~/Documents", false},
{"relative path", "relative/path", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := expandTilde(tt.path)
if (err != nil) != tt.wantErr {
t.Errorf("expandTilde() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && result == "" {
t.Error("expandTilde() returned empty string")
}
})
}
}
func TestHydrateFromMultiRepo(t *testing.T) {
t.Run("single-repo mode returns nil", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
// No multi-repo config - should return nil
ctx := context.Background()
results, err := store.HydrateFromMultiRepo(ctx)
if err != nil {
t.Fatalf("HydrateFromMultiRepo() error = %v", err)
}
if results != nil {
t.Errorf("expected nil results in single-repo mode, got %v", results)
}
})
t.Run("hydrates from primary repo", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
// Initialize config
if err := config.Initialize(); err != nil {
t.Fatalf("failed to initialize config: %v", err)
}
// Create temporary repo with JSONL file
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads dir: %v", err)
}
// Create test issue
issue := types.Issue{
ID: "test-1",
Title: "Test Issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SourceRepo: ".",
}
issue.ContentHash = issue.ComputeContentHash()
// Write JSONL file
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("failed to create JSONL file: %v", err)
}
enc := json.NewEncoder(f)
if err := enc.Encode(issue); err != nil {
f.Close()
t.Fatalf("failed to write issue: %v", err)
}
f.Close()
// Set multi-repo config
config.Set("repos.primary", tmpDir)
ctx := context.Background()
results, err := store.HydrateFromMultiRepo(ctx)
if err != nil {
t.Fatalf("HydrateFromMultiRepo() error = %v", err)
}
if results == nil || results["."] != 1 {
t.Errorf("expected 1 issue from primary repo, got %v", results)
}
// Verify issue was imported
imported, err := store.GetIssue(ctx, "test-1")
if err != nil {
t.Fatalf("failed to get imported issue: %v", err)
}
if imported.Title != "Test Issue" {
t.Errorf("expected title 'Test Issue', got %q", imported.Title)
}
if imported.SourceRepo != "." {
t.Errorf("expected source_repo '.', got %q", imported.SourceRepo)
}
// Clean up config
config.Set("repos.primary", "")
})
t.Run("uses mtime caching to skip unchanged files", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
// Initialize config
if err := config.Initialize(); err != nil {
t.Fatalf("failed to initialize config: %v", err)
}
// Create temporary repo with JSONL file
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads dir: %v", err)
}
// Create test issue
issue := types.Issue{
ID: "test-2",
Title: "Test Issue 2",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SourceRepo: ".",
}
issue.ContentHash = issue.ComputeContentHash()
// Write JSONL file
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("failed to create JSONL file: %v", err)
}
enc := json.NewEncoder(f)
if err := enc.Encode(issue); err != nil {
f.Close()
t.Fatalf("failed to write issue: %v", err)
}
f.Close()
// Set multi-repo config
config.Set("repos.primary", tmpDir)
ctx := context.Background()
// First hydration - should import
results1, err := store.HydrateFromMultiRepo(ctx)
if err != nil {
t.Fatalf("first HydrateFromMultiRepo() error = %v", err)
}
if results1["."] != 1 {
t.Errorf("first hydration: expected 1 issue, got %d", results1["."])
}
// Second hydration - should skip (mtime unchanged)
results2, err := store.HydrateFromMultiRepo(ctx)
if err != nil {
t.Fatalf("second HydrateFromMultiRepo() error = %v", err)
}
if results2["."] != 0 {
t.Errorf("second hydration: expected 0 issues (cached), got %d", results2["."])
}
})
t.Run("imports additional repos", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
// Initialize config
if err := config.Initialize(); err != nil {
t.Fatalf("failed to initialize config: %v", err)
}
// Create primary repo
primaryDir := t.TempDir()
primaryBeadsDir := filepath.Join(primaryDir, ".beads")
if err := os.MkdirAll(primaryBeadsDir, 0755); err != nil {
t.Fatalf("failed to create primary .beads dir: %v", err)
}
issue1 := types.Issue{
ID: "primary-1",
Title: "Primary Issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SourceRepo: ".",
}
issue1.ContentHash = issue1.ComputeContentHash()
f1, err := os.Create(filepath.Join(primaryBeadsDir, "issues.jsonl"))
if err != nil {
t.Fatalf("failed to create primary JSONL: %v", err)
}
json.NewEncoder(f1).Encode(issue1)
f1.Close()
// Create additional repo
additionalDir := t.TempDir()
additionalBeadsDir := filepath.Join(additionalDir, ".beads")
if err := os.MkdirAll(additionalBeadsDir, 0755); err != nil {
t.Fatalf("failed to create additional .beads dir: %v", err)
}
issue2 := types.Issue{
ID: "additional-1",
Title: "Additional Issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SourceRepo: additionalDir,
}
issue2.ContentHash = issue2.ComputeContentHash()
f2, err := os.Create(filepath.Join(additionalBeadsDir, "issues.jsonl"))
if err != nil {
t.Fatalf("failed to create additional JSONL: %v", err)
}
json.NewEncoder(f2).Encode(issue2)
f2.Close()
// Set multi-repo config
config.Set("repos.primary", primaryDir)
config.Set("repos.additional", []string{additionalDir})
ctx := context.Background()
results, err := store.HydrateFromMultiRepo(ctx)
if err != nil {
t.Fatalf("HydrateFromMultiRepo() error = %v", err)
}
if results["."] != 1 {
t.Errorf("expected 1 issue from primary, got %d", results["."])
}
if results[additionalDir] != 1 {
t.Errorf("expected 1 issue from additional, got %d", results[additionalDir])
}
// Verify both issues were imported
primary, err := store.GetIssue(ctx, "primary-1")
if err != nil {
t.Fatalf("failed to get primary issue: %v", err)
}
if primary.SourceRepo != "." {
t.Errorf("primary issue: expected source_repo '.', got %q", primary.SourceRepo)
}
additional, err := store.GetIssue(ctx, "additional-1")
if err != nil {
t.Fatalf("failed to get additional issue: %v", err)
}
if additional.SourceRepo != additionalDir {
t.Errorf("additional issue: expected source_repo %q, got %q", additionalDir, additional.SourceRepo)
}
})
}
func TestImportJSONLFile(t *testing.T) {
t.Run("imports issues with dependencies and labels", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
// Create test JSONL file
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "test.jsonl")
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("failed to create JSONL file: %v", err)
}
// Create issues with dependencies and labels
issue1 := types.Issue{
ID: "test-1",
Title: "Issue 1",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Labels: []string{"bug", "critical"},
SourceRepo: "test",
}
issue1.ContentHash = issue1.ComputeContentHash()
issue2 := types.Issue{
ID: "test-2",
Title: "Issue 2",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Dependencies: []*types.Dependency{
{
IssueID: "test-2",
DependsOnID: "test-1",
Type: types.DepBlocks,
CreatedAt: time.Now(),
CreatedBy: "test",
},
},
SourceRepo: "test",
}
issue2.ContentHash = issue2.ComputeContentHash()
enc := json.NewEncoder(f)
enc.Encode(issue1)
enc.Encode(issue2)
f.Close()
// Import
ctx := context.Background()
count, err := store.importJSONLFile(ctx, jsonlPath, "test")
if err != nil {
t.Fatalf("importJSONLFile() error = %v", err)
}
if count != 2 {
t.Errorf("expected 2 issues imported, got %d", count)
}
// Verify issues
imported1, err := store.GetIssue(ctx, "test-1")
if err != nil {
t.Fatalf("failed to get issue 1: %v", err)
}
if len(imported1.Labels) != 2 {
t.Errorf("expected 2 labels, got %d", len(imported1.Labels))
}
// Verify dependency
deps, err := store.GetDependencies(ctx, "test-2")
if err != nil {
t.Fatalf("failed to get dependencies: %v", err)
}
if len(deps) != 1 {
t.Errorf("expected 1 dependency, got %d", len(deps))
}
if len(deps) > 0 && deps[0].ID != "test-1" {
t.Errorf("expected dependency on test-1, got %s", deps[0].ID)
}
})
}
func TestImportJSONLFileOutOfOrderDeps(t *testing.T) {
t.Run("handles out-of-order dependencies", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
// Create test JSONL file with dependency BEFORE its target
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "test.jsonl")
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("failed to create JSONL file: %v", err)
}
// Issue 1 depends on Issue 2, but Issue 1 comes FIRST in the file
// This would fail with FK constraint if not handled properly
issue1 := types.Issue{
ID: "test-1",
Title: "Issue 1 (depends on Issue 2)",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Dependencies: []*types.Dependency{
{
IssueID: "test-1",
DependsOnID: "test-2", // test-2 doesn't exist yet!
Type: types.DepBlocks,
CreatedAt: time.Now(),
CreatedBy: "test",
},
},
SourceRepo: "test",
}
issue1.ContentHash = issue1.ComputeContentHash()
issue2 := types.Issue{
ID: "test-2",
Title: "Issue 2 (dependency target)",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SourceRepo: "test",
}
issue2.ContentHash = issue2.ComputeContentHash()
enc := json.NewEncoder(f)
enc.Encode(issue1) // Dependent first
enc.Encode(issue2) // Dependency target second
f.Close()
// Import should succeed despite out-of-order dependencies
ctx := context.Background()
count, err := store.importJSONLFile(ctx, jsonlPath, "test")
if err != nil {
t.Fatalf("importJSONLFile() error = %v", err)
}
if count != 2 {
t.Errorf("expected 2 issues imported, got %d", count)
}
// Verify dependency was created
deps, err := store.GetDependencies(ctx, "test-1")
if err != nil {
t.Fatalf("failed to get dependencies: %v", err)
}
if len(deps) != 1 {
t.Errorf("expected 1 dependency, got %d", len(deps))
}
if len(deps) > 0 && deps[0].ID != "test-2" {
t.Errorf("expected dependency on test-2, got %s", deps[0].ID)
}
})
t.Run("detects orphaned dependencies in corrupted data", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
// Create test JSONL with orphaned dependency (target doesn't exist)
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "test.jsonl")
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("failed to create JSONL file: %v", err)
}
issue := types.Issue{
ID: "test-orphan",
Title: "Issue with orphaned dependency",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Dependencies: []*types.Dependency{
{
IssueID: "test-orphan",
DependsOnID: "nonexistent-issue", // This issue doesn't exist
Type: types.DepBlocks,
CreatedAt: time.Now(),
CreatedBy: "test",
},
},
SourceRepo: "test",
}
issue.ContentHash = issue.ComputeContentHash()
enc := json.NewEncoder(f)
enc.Encode(issue)
f.Close()
// Import should fail due to FK violation
ctx := context.Background()
_, err = store.importJSONLFile(ctx, jsonlPath, "test")
if err == nil {
t.Error("expected error for orphaned dependency, got nil")
}
if err != nil && !strings.Contains(err.Error(), "foreign key violation") {
t.Errorf("expected foreign key violation error, got: %v", err)
}
})
}
func TestDeleteIssuesBySourceRepo(t *testing.T) {
t.Run("deletes all issues from specified repo", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create issues with different source_repos
issue1 := &types.Issue{
ID: "bd-repo1-1",
Title: "Repo1 Issue 1",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SourceRepo: "~/test-repo",
}
issue1.ContentHash = issue1.ComputeContentHash()
issue2 := &types.Issue{
ID: "bd-repo1-2",
Title: "Repo1 Issue 2",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SourceRepo: "~/test-repo",
}
issue2.ContentHash = issue2.ComputeContentHash()
issue3 := &types.Issue{
ID: "bd-primary-1",
Title: "Primary Issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SourceRepo: ".",
}
issue3.ContentHash = issue3.ComputeContentHash()
// Insert all issues
if err := store.CreateIssue(ctx, issue1, "test"); err != nil {
t.Fatalf("failed to create issue1: %v", err)
}
if err := store.CreateIssue(ctx, issue2, "test"); err != nil {
t.Fatalf("failed to create issue2: %v", err)
}
if err := store.CreateIssue(ctx, issue3, "test"); err != nil {
t.Fatalf("failed to create issue3: %v", err)
}
// Delete issues from ~/test-repo
deletedCount, err := store.DeleteIssuesBySourceRepo(ctx, "~/test-repo")
if err != nil {
t.Fatalf("DeleteIssuesBySourceRepo() error = %v", err)
}
if deletedCount != 2 {
t.Errorf("expected 2 issues deleted, got %d", deletedCount)
}
// Verify ~/test-repo issues are gone
// GetIssue returns (nil, nil) when issue doesn't exist
issue1After, err := store.GetIssue(ctx, "bd-repo1-1")
if issue1After != nil || err != nil {
t.Errorf("expected bd-repo1-1 to be deleted, got issue=%v, err=%v", issue1After, err)
}
issue2After, err := store.GetIssue(ctx, "bd-repo1-2")
if issue2After != nil || err != nil {
t.Errorf("expected bd-repo1-2 to be deleted, got issue=%v, err=%v", issue2After, err)
}
// Verify primary issue still exists
primary, err := store.GetIssue(ctx, "bd-primary-1")
if err != nil {
t.Fatalf("primary issue should still exist: %v", err)
}
if primary.Title != "Primary Issue" {
t.Errorf("expected 'Primary Issue', got %q", primary.Title)
}
})
t.Run("returns 0 when no issues match", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create an issue with a different source_repo
issue := &types.Issue{
ID: "bd-other-1",
Title: "Other Issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SourceRepo: ".",
}
issue.ContentHash = issue.ComputeContentHash()
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("failed to create issue: %v", err)
}
// Delete from non-existent repo
deletedCount, err := store.DeleteIssuesBySourceRepo(ctx, "~/nonexistent")
if err != nil {
t.Fatalf("DeleteIssuesBySourceRepo() error = %v", err)
}
if deletedCount != 0 {
t.Errorf("expected 0 issues deleted, got %d", deletedCount)
}
// Verify original issue still exists
_, err = store.GetIssue(ctx, "bd-other-1")
if err != nil {
t.Errorf("issue should still exist: %v", err)
}
})
t.Run("cleans up related data", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create an issue with labels and comments
issue := &types.Issue{
ID: "bd-cleanup-1",
Title: "Cleanup Test Issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SourceRepo: "~/cleanup-repo",
Labels: []string{"test", "cleanup"},
}
issue.ContentHash = issue.ComputeContentHash()
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("failed to create issue: %v", err)
}
// Add a comment
_, err := store.AddIssueComment(ctx, "bd-cleanup-1", "test", "Test comment")
if err != nil {
t.Fatalf("failed to add comment: %v", err)
}
// Delete the repo
deletedCount, err := store.DeleteIssuesBySourceRepo(ctx, "~/cleanup-repo")
if err != nil {
t.Fatalf("DeleteIssuesBySourceRepo() error = %v", err)
}
if deletedCount != 1 {
t.Errorf("expected 1 issue deleted, got %d", deletedCount)
}
// Verify issue is gone
// GetIssue returns (nil, nil) when issue doesn't exist
issueAfter, err := store.GetIssue(ctx, "bd-cleanup-1")
if issueAfter != nil || err != nil {
t.Errorf("expected issue to be deleted, got issue=%v, err=%v", issueAfter, err)
}
// Verify labels are gone (query directly to check)
var labelCount int
err = store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM labels WHERE issue_id = ?`, "bd-cleanup-1").Scan(&labelCount)
if err != nil {
t.Fatalf("failed to query labels: %v", err)
}
if labelCount != 0 {
t.Errorf("expected 0 labels, got %d", labelCount)
}
// Verify comments are gone
var commentCount int
err = store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM comments WHERE issue_id = ?`, "bd-cleanup-1").Scan(&commentCount)
if err != nil {
t.Fatalf("failed to query comments: %v", err)
}
if commentCount != 0 {
t.Errorf("expected 0 comments, got %d", commentCount)
}
})
}
func TestClearRepoMtime(t *testing.T) {
t.Run("clears mtime cache for repo", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Insert a mtime cache entry directly
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
// Create a dummy JSONL file for the mtime
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
f.Close()
_, err = store.db.ExecContext(ctx, `
INSERT INTO repo_mtimes (repo_path, jsonl_path, mtime_ns, last_checked)
VALUES (?, ?, ?, ?)
`, tmpDir, jsonlPath, 12345, time.Now())
if err != nil {
t.Fatalf("failed to insert mtime cache: %v", err)
}
// Verify it exists
var count int
err = store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM repo_mtimes WHERE repo_path = ?`, tmpDir).Scan(&count)
if err != nil {
t.Fatalf("failed to query mtime cache: %v", err)
}
if count != 1 {
t.Fatalf("expected 1 mtime cache entry, got %d", count)
}
// Clear it
if err := store.ClearRepoMtime(ctx, tmpDir); err != nil {
t.Fatalf("ClearRepoMtime() error = %v", err)
}
// Verify it's gone
err = store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM repo_mtimes WHERE repo_path = ?`, tmpDir).Scan(&count)
if err != nil {
t.Fatalf("failed to query mtime cache: %v", err)
}
if count != 0 {
t.Errorf("expected 0 mtime cache entries, got %d", count)
}
})
t.Run("handles non-existent repo gracefully", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Clear a repo that doesn't exist in cache - should not error
err := store.ClearRepoMtime(ctx, "/nonexistent/path")
if err != nil {
t.Errorf("ClearRepoMtime() should not error for non-existent path: %v", err)
}
})
}
func TestExportToMultiRepo(t *testing.T) {
t.Run("returns nil in single-repo mode", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
// Initialize config fresh
if err := config.Initialize(); err != nil {
t.Fatalf("failed to initialize config: %v", err)
}
// Clear any multi-repo config from previous tests
config.Set("repos.primary", "")
config.Set("repos.additional", nil)
ctx := context.Background()
results, err := store.ExportToMultiRepo(ctx)
if err != nil {
t.Errorf("unexpected error in single-repo mode: %v", err)
}
if results != nil {
t.Errorf("expected nil results in single-repo mode, got %v", results)
}
})
t.Run("exports issues to correct repos", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
// Initialize config
if err := config.Initialize(); err != nil {
t.Fatalf("failed to initialize config: %v", err)
}
// Create temporary repos
primaryDir := t.TempDir()
additionalDir := t.TempDir()
// Create .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)
}
// Set multi-repo config
config.Set("repos.primary", primaryDir)
config.Set("repos.additional", []string{additionalDir})
ctx := context.Background()
// Create issues with different source_repos
issue1 := &types.Issue{
ID: "bd-primary-1",
Title: "Primary Issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SourceRepo: ".",
}
issue1.ContentHash = issue1.ComputeContentHash()
issue2 := &types.Issue{
ID: "bd-additional-1",
Title: "Additional Issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SourceRepo: additionalDir,
}
issue2.ContentHash = issue2.ComputeContentHash()
// Insert issues
if err := store.CreateIssue(ctx, issue1, "test"); err != nil {
t.Fatalf("failed to create primary issue: %v", err)
}
if err := store.CreateIssue(ctx, issue2, "test"); err != nil {
t.Fatalf("failed to create additional issue: %v", err)
}
// Export to multi-repo
results, err := store.ExportToMultiRepo(ctx)
if err != nil {
t.Fatalf("ExportToMultiRepo() error = %v", err)
}
// Verify export counts
if results["."] != 1 {
t.Errorf("expected 1 issue exported to primary, got %d", results["."])
}
if results[additionalDir] != 1 {
t.Errorf("expected 1 issue exported to additional, got %d", results[additionalDir])
}
// Verify JSONL files exist and contain correct issues
primaryJSONL := filepath.Join(primaryBeadsDir, "issues.jsonl")
additionalJSONL := filepath.Join(additionalBeadsDir, "issues.jsonl")
// Check primary JSONL
f1, err := os.Open(primaryJSONL)
if err != nil {
t.Fatalf("failed to open primary JSONL: %v", err)
}
defer f1.Close()
var primaryIssue types.Issue
if err := json.NewDecoder(f1).Decode(&primaryIssue); err != nil {
t.Fatalf("failed to decode primary issue: %v", err)
}
if primaryIssue.ID != "bd-primary-1" {
t.Errorf("expected bd-primary-1 in primary JSONL, got %s", primaryIssue.ID)
}
// Check additional JSONL
f2, err := os.Open(additionalJSONL)
if err != nil {
t.Fatalf("failed to open additional JSONL: %v", err)
}
defer f2.Close()
var additionalIssue types.Issue
if err := json.NewDecoder(f2).Decode(&additionalIssue); err != nil {
t.Fatalf("failed to decode additional issue: %v", err)
}
if additionalIssue.ID != "bd-additional-1" {
t.Errorf("expected bd-additional-1 in additional JSONL, got %s", additionalIssue.ID)
}
})
}
// TestExportToMultiRepoPathResolution tests that relative paths in repos.additional
// are resolved from repo root (parent of .beads/), NOT from CWD.
// This is the fix for oss-lbp.
func TestExportToMultiRepoPathResolution(t *testing.T) {
t.Run("relative path resolved from repo root not CWD", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
// Initialize config
if err := config.Initialize(); err != nil {
t.Fatalf("failed to initialize config: %v", err)
}
// Create a repo structure:
// tmpDir/
// .beads/
// config.yaml
// beads.db
// oss/ <- relative path "oss/" should resolve here
// .beads/
// issues.jsonl <- export destination
tmpDir := t.TempDir()
// Create .beads directory with config file
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads dir: %v", err)
}
// Create config file so ConfigFileUsed() returns a valid path
configPath := filepath.Join(beadsDir, "config.yaml")
configContent := `repos:
primary: .
additional:
- oss/
`
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
t.Fatalf("failed to write config: %v", err)
}
// Create oss/ subdirectory (the additional repo)
ossDir := filepath.Join(tmpDir, "oss")
ossBeadsDir := filepath.Join(ossDir, ".beads")
if err := os.MkdirAll(ossBeadsDir, 0755); err != nil {
t.Fatalf("failed to create oss/.beads dir: %v", err)
}
// Change to a DIFFERENT directory (to test that CWD doesn't affect resolution)
// This simulates daemon context where CWD is .beads/
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
if err := os.Chdir(beadsDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
defer os.Chdir(origDir)
// Reload config from the new location
if err := config.Initialize(); err != nil {
t.Fatalf("failed to reinitialize config: %v", err)
}
// Verify config was loaded correctly
multiRepo := config.GetMultiRepoConfig()
if multiRepo == nil {
t.Skip("config not loaded - skipping test")
}
ctx := context.Background()
// Create an issue destined for the "oss/" repo
issue := &types.Issue{
ID: "bd-oss-1",
Title: "OSS Issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SourceRepo: "oss/", // Will be matched against repos.additional
}
issue.ContentHash = issue.ComputeContentHash()
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("failed to create issue: %v", err)
}
// Export - this should resolve "oss/" relative to tmpDir (repo root), not .beads/ (CWD)
results, err := store.ExportToMultiRepo(ctx)
if err != nil {
t.Fatalf("ExportToMultiRepo() error = %v", err)
}
// Check the export count
if results["oss/"] != 1 {
t.Errorf("expected 1 issue exported to oss/, got %d", results["oss/"])
}
// Verify the JSONL was written to the correct location (tmpDir/oss/.beads/issues.jsonl)
// NOT to .beads/oss/.beads/issues.jsonl (which would happen with CWD-based resolution)
expectedJSONL := filepath.Join(ossBeadsDir, "issues.jsonl")
wrongJSONL := filepath.Join(beadsDir, "oss", ".beads", "issues.jsonl")
if _, err := os.Stat(expectedJSONL); os.IsNotExist(err) {
t.Errorf("JSONL not written to expected location: %s", expectedJSONL)
}
if _, err := os.Stat(wrongJSONL); err == nil {
t.Errorf("JSONL was incorrectly written to CWD-relative path: %s", wrongJSONL)
}
})
t.Run("absolute path returned unchanged", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
// Initialize config
if err := config.Initialize(); err != nil {
t.Fatalf("failed to initialize config: %v", err)
}
// Create repos with absolute paths
primaryDir := t.TempDir()
additionalDir := t.TempDir()
// Create .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)
}
// Set config with ABSOLUTE paths
config.Set("repos.primary", primaryDir)
config.Set("repos.additional", []string{additionalDir})
ctx := context.Background()
// Create issue for additional repo (using absolute path as source_repo)
issue := &types.Issue{
ID: "bd-abs-1",
Title: "Absolute Path Issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SourceRepo: additionalDir,
}
issue.ContentHash = issue.ComputeContentHash()
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("failed to create issue: %v", err)
}
// Export
results, err := store.ExportToMultiRepo(ctx)
if err != nil {
t.Fatalf("ExportToMultiRepo() error = %v", err)
}
// Verify export to absolute path
if results[additionalDir] != 1 {
t.Errorf("expected 1 issue exported to %s, got %d", additionalDir, results[additionalDir])
}
// Verify JSONL was written to the correct location
expectedJSONL := filepath.Join(additionalBeadsDir, "issues.jsonl")
if _, err := os.Stat(expectedJSONL); os.IsNotExist(err) {
t.Errorf("JSONL not written to expected location: %s", expectedJSONL)
}
})
t.Run("empty config handled gracefully", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
// Initialize config fresh
if err := config.Initialize(); err != nil {
t.Fatalf("failed to initialize config: %v", err)
}
// Explicitly clear repos config
config.Set("repos.primary", "")
config.Set("repos.additional", nil)
ctx := context.Background()
// Create an issue
issue := &types.Issue{
ID: "bd-empty-1",
Title: "Empty Config Issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SourceRepo: ".",
}
issue.ContentHash = issue.ComputeContentHash()
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("failed to create issue: %v", err)
}
// Export should return nil gracefully (single-repo mode)
results, err := store.ExportToMultiRepo(ctx)
if err != nil {
t.Errorf("ExportToMultiRepo() should not error with empty config: %v", err)
}
if results != nil {
t.Errorf("expected nil results with empty config, got %v", results)
}
})
}
// TestUpsertPreservesGateFields tests that gate await fields are preserved during upsert (bd-gr4q).
// Gates are wisps and aren't exported to JSONL. When an issue with the same ID is imported,
// the await fields should NOT be cleared.
func TestUpsertPreservesGateFields(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create a gate with await fields directly in the database
gate := &types.Issue{
ID: "bd-gate1",
Title: "Test Gate",
Status: types.StatusOpen,
Priority: 1,
IssueType: "gate",
Ephemeral: true,
AwaitType: "gh:run",
AwaitID: "123456789",
Timeout: 30 * 60 * 1000000000, // 30 minutes in nanoseconds
Waiters: []string{"beads/dave"},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
gate.ContentHash = gate.ComputeContentHash()
if err := store.CreateIssue(ctx, gate, "test"); err != nil {
t.Fatalf("failed to create gate: %v", err)
}
// Verify gate was created with await fields
retrieved, err := store.GetIssue(ctx, gate.ID)
if err != nil || retrieved == nil {
t.Fatalf("failed to get gate: %v", err)
}
if retrieved.AwaitType != "gh:run" {
t.Errorf("expected AwaitType=gh:run, got %q", retrieved.AwaitType)
}
if retrieved.AwaitID != "123456789" {
t.Errorf("expected AwaitID=123456789, got %q", retrieved.AwaitID)
}
// Create a JSONL file with an issue that has the same ID but no await fields
// (simulating what happens when a non-gate issue is imported)
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("failed to create JSONL file: %v", err)
}
// Same ID, different content (to trigger update), no await fields
incomingIssue := types.Issue{
ID: "bd-gate1",
Title: "Test Gate Updated", // Different title to trigger update
Status: types.StatusOpen,
Priority: 1,
IssueType: "gate",
AwaitType: "", // Empty - simulating JSONL without await fields
AwaitID: "", // Empty
Timeout: 0,
Waiters: nil,
CreatedAt: time.Now(),
UpdatedAt: time.Now().Add(time.Second), // Newer timestamp
}
incomingIssue.ContentHash = incomingIssue.ComputeContentHash()
enc := json.NewEncoder(f)
if err := enc.Encode(incomingIssue); err != nil {
t.Fatalf("failed to encode issue: %v", err)
}
f.Close()
// Import the JSONL file (this should NOT clear the await fields)
_, err = store.importJSONLFile(ctx, jsonlPath, "test")
if err != nil {
t.Fatalf("importJSONLFile failed: %v", err)
}
// Verify await fields are preserved
updated, err := store.GetIssue(ctx, gate.ID)
if err != nil || updated == nil {
t.Fatalf("failed to get updated gate: %v", err)
}
// Title should be updated
if updated.Title != "Test Gate Updated" {
t.Errorf("expected title to be updated, got %q", updated.Title)
}
// Await fields should be PRESERVED (not cleared)
if updated.AwaitType != "gh:run" {
t.Errorf("AwaitType was cleared! expected 'gh:run', got %q", updated.AwaitType)
}
if updated.AwaitID != "123456789" {
t.Errorf("AwaitID was cleared! expected '123456789', got %q", updated.AwaitID)
}
if updated.Timeout != 30*60*1000000000 {
t.Errorf("Timeout was cleared! expected %d, got %d", 30*60*1000000000, updated.Timeout)
}
if len(updated.Waiters) != 1 || updated.Waiters[0] != "beads/dave" {
t.Errorf("Waiters was cleared! expected [beads/dave], got %v", updated.Waiters)
}
}