Files
beads/cmd/bd/sync_cwd_test.go
Peter Chanthamynavong 0a48519561 feat(context): centralize RepoContext API for git operations (#1102)
Centralizes repository context resolution via RepoContext API, fixing bugs where git commands run in the wrong repo when BEADS_DIR points elsewhere or in worktree scenarios.
2026-01-15 07:55:08 -08:00

684 lines
20 KiB
Go

package main
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
// TestMultiRepoPathResolutionCWDInvariant verifies that path resolution for
// repos.additional produces the same absolute paths regardless of CWD.
//
// The bug (oss-lbp): Running from .beads/ caused paths like "oss/" to become
// ".beads/oss/" instead of "{repo}/oss/". This test ensures the fix works
// by verifying resolution from multiple CWDs produces identical results.
//
// Covers: T040-T042
func TestMultiRepoPathResolutionCWDInvariant(t *testing.T) {
ctx := context.Background()
// Store original working directory
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get original working directory: %v", err)
}
defer func() { _ = os.Chdir(originalWd) }()
// Create temp repo structure
// Resolve symlinks to avoid macOS /var -> /private/var issues
tmpDir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("eval symlinks failed: %v", err)
}
// Setup git repo
if err := setupGitRepoInDir(t, tmpDir); err != nil {
t.Fatalf("failed to setup git repo: %v", err)
}
// Create .beads directory structure
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads dir: %v", err)
}
// Create subdirectory for testing CWD from subdir
subDir := filepath.Join(tmpDir, "src", "pkg")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatalf("failed to create subdir: %v", err)
}
// Create oss/ directory (the multi-repo target)
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)
}
// Create config.yaml with relative path
configContent := `repos:
primary: "."
additional:
- oss/
`
configPath := filepath.Join(beadsDir, "config.yaml")
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
// Create database
dbPath := filepath.Join(beadsDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
defer store.Close()
// Set issue prefix
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("failed to set issue_prefix: %v", err)
}
// Create a test issue
issue := &types.Issue{
ID: "test-1",
Title: "Test Issue",
Status: types.StatusOpen,
IssueType: types.TypeTask,
Priority: 2,
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("failed to create issue: %v", err)
}
store.Close()
// T040: Test from repo root
t.Run("T040_from_repo_root", func(t *testing.T) {
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("chdir to repo root failed: %v", err)
}
git.ResetCaches()
// Initialize config
if err := config.Initialize(); err != nil {
t.Fatalf("config.Initialize() failed: %v", err)
}
multiRepo := config.GetMultiRepoConfig()
if multiRepo == nil {
t.Fatal("GetMultiRepoConfig() returned nil")
}
// The key assertion: "oss/" should resolve to {repo}/oss/
if len(multiRepo.Additional) != 1 {
t.Fatalf("expected 1 additional repo, got %d", len(multiRepo.Additional))
}
// Verify config file used is in the right place
configUsed := config.ConfigFileUsed()
expectedConfig := filepath.Join(beadsDir, "config.yaml")
if configUsed != expectedConfig {
t.Errorf("ConfigFileUsed() = %q, want %q", configUsed, expectedConfig)
}
t.Logf("From repo root: additional[0] = %q", multiRepo.Additional[0])
t.Logf("ConfigFileUsed() = %q", configUsed)
})
// T041: Test from .beads/ directory (the bug trigger location)
t.Run("T041_from_beads_directory", func(t *testing.T) {
if err := os.Chdir(beadsDir); err != nil {
t.Fatalf("chdir to .beads failed: %v", err)
}
git.ResetCaches()
// Re-initialize config from new CWD
if err := config.Initialize(); err != nil {
t.Fatalf("config.Initialize() failed: %v", err)
}
multiRepo := config.GetMultiRepoConfig()
if multiRepo == nil {
t.Fatal("GetMultiRepoConfig() returned nil")
}
if len(multiRepo.Additional) != 1 {
t.Fatalf("expected 1 additional repo, got %d", len(multiRepo.Additional))
}
// Verify config is still found correctly
configUsed := config.ConfigFileUsed()
expectedConfig := filepath.Join(beadsDir, "config.yaml")
if configUsed != expectedConfig {
t.Errorf("ConfigFileUsed() = %q, want %q", configUsed, expectedConfig)
}
t.Logf("From .beads/: additional[0] = %q", multiRepo.Additional[0])
t.Logf("ConfigFileUsed() = %q", configUsed)
})
// T042: Test from subdirectory
t.Run("T042_from_subdirectory", func(t *testing.T) {
if err := os.Chdir(subDir); err != nil {
t.Fatalf("chdir to subdir failed: %v", err)
}
git.ResetCaches()
// Re-initialize config from new CWD
if err := config.Initialize(); err != nil {
t.Fatalf("config.Initialize() failed: %v", err)
}
multiRepo := config.GetMultiRepoConfig()
if multiRepo == nil {
t.Fatal("GetMultiRepoConfig() returned nil")
}
if len(multiRepo.Additional) != 1 {
t.Fatalf("expected 1 additional repo, got %d", len(multiRepo.Additional))
}
// Verify config is still found correctly
configUsed := config.ConfigFileUsed()
expectedConfig := filepath.Join(beadsDir, "config.yaml")
if configUsed != expectedConfig {
t.Errorf("ConfigFileUsed() = %q, want %q", configUsed, expectedConfig)
}
t.Logf("From subdir: additional[0] = %q", multiRepo.Additional[0])
t.Logf("ConfigFileUsed() = %q", configUsed)
})
}
// TestExportToMultiRepoCWDInvariant tests that ExportToMultiRepo produces
// consistent export paths regardless of CWD.
//
// This is an integration test that exercises the actual export code path
// which was affected by the bug.
func TestExportToMultiRepoCWDInvariant(t *testing.T) {
ctx := context.Background()
// Store original working directory
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get original working directory: %v", err)
}
defer func() { _ = os.Chdir(originalWd) }()
// Create temp repo structure
tmpDir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("eval symlinks failed: %v", err)
}
// Setup git repo
if err := setupGitRepoInDir(t, tmpDir); err != nil {
t.Fatalf("failed to setup git repo: %v", err)
}
// Create .beads directory
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads dir: %v", err)
}
// Create oss/.beads directory
ossBeadsDir := filepath.Join(tmpDir, "oss", ".beads")
if err := os.MkdirAll(ossBeadsDir, 0755); err != nil {
t.Fatalf("failed to create oss/.beads dir: %v", err)
}
// Create config.yaml with relative path
configContent := `repos:
primary: "."
additional:
- oss/
`
configPath := filepath.Join(beadsDir, "config.yaml")
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
// Create database and issue once before CWD tests
dbPath := filepath.Join(beadsDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
// Set issue prefix
if err := store.SetConfig(ctx, "issue_prefix", "oss"); err != nil {
store.Close()
t.Fatalf("failed to set issue_prefix: %v", err)
}
// Create a test issue for oss/ repo
issue := &types.Issue{
ID: "oss-1",
Title: "OSS Issue",
Status: types.StatusOpen,
IssueType: types.TypeTask,
Priority: 2,
SourceRepo: "oss/", // This routes to additional repo
}
if err := store.CreateIssue(ctx, issue, "oss"); err != nil {
store.Close()
t.Fatalf("failed to create issue: %v", err)
}
store.Close()
// Helper function to run export and check results
runExportTest := func(t *testing.T, testCwd string) string {
t.Helper()
// Change to test CWD
if err := os.Chdir(testCwd); err != nil {
t.Fatalf("chdir to %s failed: %v", testCwd, err)
}
git.ResetCaches()
// Initialize config
if err := config.Initialize(); err != nil {
t.Fatalf("config.Initialize() failed: %v", err)
}
// Open existing store
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to open store: %v", err)
}
defer store.Close()
// Run multi-repo export
results, err := store.ExportToMultiRepo(ctx)
if err != nil {
t.Fatalf("ExportToMultiRepo failed: %v", err)
}
// Check that oss/ was exported
if results == nil {
t.Fatal("ExportToMultiRepo returned nil results")
}
// The export should create issues.jsonl in oss/.beads/
expectedPath := filepath.Join(ossBeadsDir, "issues.jsonl")
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
t.Errorf("expected %s to exist after export from %s", expectedPath, testCwd)
}
return expectedPath
}
// Test from repo root
t.Run("export_from_repo_root", func(t *testing.T) {
path := runExportTest(t, tmpDir)
t.Logf("Export from repo root created: %s", path)
})
// Test from .beads/ directory
t.Run("export_from_beads_dir", func(t *testing.T) {
path := runExportTest(t, beadsDir)
t.Logf("Export from .beads/ created: %s", path)
// Key assertion: should NOT create .beads/oss/.beads/issues.jsonl
badPath := filepath.Join(beadsDir, "oss", ".beads", "issues.jsonl")
if _, err := os.Stat(badPath); err == nil {
t.Errorf("BUG: export created %s (CWD-relative path)", badPath)
}
})
// Test from subdirectory
subDir := filepath.Join(tmpDir, "src")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatalf("failed to create subdir: %v", err)
}
t.Run("export_from_subdirectory", func(t *testing.T) {
path := runExportTest(t, subDir)
t.Logf("Export from subdir created: %s", path)
})
}
// TestSyncModePathResolution tests path resolution across different sync modes.
//
// Covers: T050-T052
func TestSyncModePathResolution(t *testing.T) {
ctx := context.Background()
// Store original working directory
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get original working directory: %v", err)
}
defer func() { _ = os.Chdir(originalWd) }()
// T050: Normal sync mode path resolution
t.Run("T050_normal_sync_mode", func(t *testing.T) {
// Restore CWD at end of subtest to prevent interference with subsequent tests.
// t.TempDir() cleanup happens after subtest returns, so CWD must be restored first.
subtestWd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get subtest working directory: %v", err)
}
defer func() { _ = os.Chdir(subtestWd) }()
tmpDir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("eval symlinks failed: %v", err)
}
// Setup git repo
if err := setupGitRepoInDir(t, tmpDir); err != nil {
t.Fatalf("failed to setup git repo: %v", err)
}
// Create .beads directory
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads dir: %v", err)
}
// Create oss/.beads directory
ossBeadsDir := filepath.Join(tmpDir, "oss", ".beads")
if err := os.MkdirAll(ossBeadsDir, 0755); err != nil {
t.Fatalf("failed to create oss/.beads dir: %v", err)
}
// Create config.yaml with relative path
configContent := `repos:
primary: "."
additional:
- oss/
`
configPath := filepath.Join(beadsDir, "config.yaml")
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
// Create database
dbPath := filepath.Join(beadsDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
// Set issue prefix
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
store.Close()
t.Fatalf("failed to set issue_prefix: %v", err)
}
// Create issue for oss/
issue := &types.Issue{
ID: "test-100",
Title: "Normal mode issue",
Status: types.StatusOpen,
IssueType: types.TypeTask,
Priority: 2,
SourceRepo: "oss/",
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
store.Close()
t.Fatalf("failed to create issue: %v", err)
}
// Change to repo root and initialize config
if err := os.Chdir(tmpDir); err != nil {
store.Close()
t.Fatalf("chdir failed: %v", err)
}
git.ResetCaches()
if err := config.Initialize(); err != nil {
store.Close()
t.Fatalf("config.Initialize() failed: %v", err)
}
// Export
results, err := store.ExportToMultiRepo(ctx)
store.Close()
if err != nil {
t.Fatalf("ExportToMultiRepo failed: %v", err)
}
// Verify export created file in correct location
expectedPath := filepath.Join(ossBeadsDir, "issues.jsonl")
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
t.Errorf("expected %s to exist", expectedPath)
}
t.Logf("Normal sync mode export results: %v", results)
})
// T051: Sync-branch mode with daemon context
t.Run("T051_sync_branch_mode", func(t *testing.T) {
// Restore CWD at end of subtest to prevent interference with subsequent tests.
subtestWd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get subtest working directory: %v", err)
}
defer func() { _ = os.Chdir(subtestWd) }()
tmpDir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("eval symlinks failed: %v", err)
}
// Setup git repo
if err := setupGitRepoInDir(t, tmpDir); err != nil {
t.Fatalf("failed to setup git repo: %v", err)
}
// Create .beads directory
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads dir: %v", err)
}
// Create oss/.beads directory
ossBeadsDir := filepath.Join(tmpDir, "oss", ".beads")
if err := os.MkdirAll(ossBeadsDir, 0755); err != nil {
t.Fatalf("failed to create oss/.beads dir: %v", err)
}
// Create config.yaml with sync-branch AND multi-repo
configContent := `sync:
branch: beads-sync
repos:
primary: "."
additional:
- oss/
`
configPath := filepath.Join(beadsDir, "config.yaml")
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
// Create the sync branch
if err := exec.Command("git", "-C", tmpDir, "branch", "beads-sync").Run(); err != nil {
t.Fatalf("failed to create sync branch: %v", err)
}
// Create database
dbPath := filepath.Join(beadsDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
// Set issue prefix
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
store.Close()
t.Fatalf("failed to set issue_prefix: %v", err)
}
// Create issue for oss/
issue := &types.Issue{
ID: "test-200",
Title: "Sync-branch mode issue",
Status: types.StatusOpen,
IssueType: types.TypeTask,
Priority: 2,
SourceRepo: "oss/",
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
store.Close()
t.Fatalf("failed to create issue: %v", err)
}
// Simulate daemon context: CWD is .beads/
if err := os.Chdir(beadsDir); err != nil {
store.Close()
t.Fatalf("chdir to .beads/ failed: %v", err)
}
git.ResetCaches()
if err := config.Initialize(); err != nil {
store.Close()
t.Fatalf("config.Initialize() failed: %v", err)
}
// Export from daemon-like context
results, err := store.ExportToMultiRepo(ctx)
store.Close()
if err != nil {
t.Fatalf("ExportToMultiRepo failed: %v", err)
}
// Key assertion: should still export to correct location
expectedPath := filepath.Join(ossBeadsDir, "issues.jsonl")
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
t.Errorf("expected %s to exist (sync-branch mode from .beads/)", expectedPath)
}
// Verify no spurious directory created
badPath := filepath.Join(beadsDir, "oss", ".beads", "issues.jsonl")
if _, err := os.Stat(badPath); err == nil {
t.Errorf("BUG: created %s (CWD-relative in sync-branch mode)", badPath)
}
t.Logf("Sync-branch mode export results: %v", results)
})
// T052: External BEADS_DIR mode
t.Run("T052_external_beads_dir_mode", func(t *testing.T) {
// Restore CWD at end of subtest to prevent interference with subsequent tests.
subtestWd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get subtest working directory: %v", err)
}
defer func() { _ = os.Chdir(subtestWd) }()
// Create main project repo
projectDir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("eval symlinks failed: %v", err)
}
if err := setupGitRepoInDir(t, projectDir); err != nil {
t.Fatalf("failed to setup project repo: %v", err)
}
// Create external beads repo
externalDir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("eval symlinks failed: %v", err)
}
if err := setupGitRepoInDir(t, externalDir); err != nil {
t.Fatalf("failed to setup external repo: %v", err)
}
// Create .beads in external repo
externalBeadsDir := filepath.Join(externalDir, ".beads")
if err := os.MkdirAll(externalBeadsDir, 0755); err != nil {
t.Fatalf("failed to create external .beads: %v", err)
}
// Create oss/.beads in external repo (sibling to external .beads)
ossBeadsDir := filepath.Join(externalDir, "oss", ".beads")
if err := os.MkdirAll(ossBeadsDir, 0755); err != nil {
t.Fatalf("failed to create oss/.beads: %v", err)
}
// Create config.yaml in external repo with relative path
configContent := `repos:
primary: "."
additional:
- oss/
`
configPath := filepath.Join(externalBeadsDir, "config.yaml")
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
// Create database in external repo
dbPath := filepath.Join(externalBeadsDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
// Set issue prefix
if err := store.SetConfig(ctx, "issue_prefix", "ext"); err != nil {
store.Close()
t.Fatalf("failed to set issue_prefix: %v", err)
}
// Create issue for oss/ (ext prefix matches issue ID)
issue := &types.Issue{
ID: "ext-300",
Title: "External mode issue",
Status: types.StatusOpen,
IssueType: types.TypeTask,
Priority: 2,
SourceRepo: "oss/",
}
if err := store.CreateIssue(ctx, issue, "ext"); err != nil {
store.Close()
t.Fatalf("failed to create issue: %v", err)
}
// Simulate external mode: CWD is project repo, BEADS_DIR points elsewhere
if err := os.Chdir(projectDir); err != nil {
store.Close()
t.Fatalf("chdir to project failed: %v", err)
}
git.ResetCaches()
// Initialize config from external location
// In external mode, config is loaded from BEADS_DIR, not CWD
// We simulate this by changing to external dir for config init
if err := os.Chdir(externalDir); err != nil {
store.Close()
t.Fatalf("chdir to external failed: %v", err)
}
git.ResetCaches()
if err := config.Initialize(); err != nil {
store.Close()
t.Fatalf("config.Initialize() failed: %v", err)
}
// Export
results, err := store.ExportToMultiRepo(ctx)
store.Close()
if err != nil {
t.Fatalf("ExportToMultiRepo failed: %v", err)
}
// Verify export in correct location
expectedPath := filepath.Join(ossBeadsDir, "issues.jsonl")
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
t.Errorf("expected %s to exist (external mode)", expectedPath)
}
t.Logf("External BEADS_DIR mode export results: %v", results)
})
}