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.
This commit is contained in:
Peter Chanthamynavong
2026-01-15 07:55:08 -08:00
committed by GitHub
parent 159114563b
commit 0a48519561
33 changed files with 3211 additions and 327 deletions

View File

@@ -116,10 +116,25 @@ func (s *SQLiteStorage) exportToRepo(ctx context.Context, repoPath string, issue
return 0, fmt.Errorf("failed to expand path: %w", err)
}
// Get absolute path
absRepoPath, err := filepath.Abs(expandedPath)
if err != nil {
return 0, fmt.Errorf("failed to get absolute path: %w", err)
// Resolve path to absolute form
// For relative paths, resolve from repo root (parent of .beads/), NOT CWD.
// This ensures paths like "oss/" in config become "{repo}/oss/", not ".beads/oss/"
// when running from different directories or in daemon context.
var absRepoPath string
if filepath.IsAbs(expandedPath) {
absRepoPath = expandedPath
} else {
// Resolve relative to repo root (parent of .beads/)
// Config is at .beads/config.yaml, so go up twice
configFile := config.ConfigFileUsed()
if configFile != "" {
repoRoot := filepath.Dir(filepath.Dir(configFile)) // .beads/config.yaml -> repo/
absRepoPath = filepath.Join(repoRoot, expandedPath)
} else {
// Fallback: dbPath is .beads/beads.db, go up one level to repo root
repoRoot := filepath.Dir(filepath.Dir(s.dbPath))
absRepoPath = filepath.Join(repoRoot, expandedPath)
}
}
// Construct JSONL path

View File

@@ -893,6 +893,226 @@ func TestExportToMultiRepo(t *testing.T) {
})
}
// 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.