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:
committed by
GitHub
parent
159114563b
commit
0a48519561
@@ -361,6 +361,16 @@ func AllSettings() map[string]interface{} {
|
||||
return v.AllSettings()
|
||||
}
|
||||
|
||||
// ConfigFileUsed returns the path to the config file that was loaded.
|
||||
// Returns empty string if no config file was found or viper is not initialized.
|
||||
// This is useful for resolving relative paths from the config file's directory.
|
||||
func ConfigFileUsed() string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return v.ConfigFileUsed()
|
||||
}
|
||||
|
||||
// GetStringSlice retrieves a string slice configuration value
|
||||
func GetStringSlice(key string) []string {
|
||||
if v == nil {
|
||||
@@ -452,13 +462,23 @@ func ResolveExternalProjectPath(projectName string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Expand relative paths from config file location or cwd
|
||||
// Resolve relative paths from repo root (parent of .beads/), NOT CWD.
|
||||
// This ensures paths like "../beads" in config resolve correctly
|
||||
// when running from different directories or in daemon context.
|
||||
if !filepath.IsAbs(path) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
// Config is at .beads/config.yaml, so go up twice to get repo root
|
||||
configFile := ConfigFileUsed()
|
||||
if configFile != "" {
|
||||
repoRoot := filepath.Dir(filepath.Dir(configFile)) // .beads/config.yaml -> repo/
|
||||
path = filepath.Join(repoRoot, path)
|
||||
} else {
|
||||
// Fallback: resolve from CWD (legacy behavior)
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
path = filepath.Join(cwd, path)
|
||||
}
|
||||
path = filepath.Join(cwd, path)
|
||||
}
|
||||
|
||||
// Verify path exists
|
||||
|
||||
@@ -787,6 +787,160 @@ func TestConfigSourceConstants(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveExternalProjectPathFromRepoRoot tests that external_projects paths
|
||||
// are resolved from repo root (parent of .beads/), NOT from CWD.
|
||||
// This is the fix for oss-lbp (related to Bug 3 in the spec).
|
||||
func TestResolveExternalProjectPathFromRepoRoot(t *testing.T) {
|
||||
// Helper to canonicalize paths for comparison (handles macOS /var -> /private/var symlink)
|
||||
canonicalize := func(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
t.Run("relative path resolved from repo root not CWD", func(t *testing.T) {
|
||||
// Create a repo structure:
|
||||
// tmpDir/
|
||||
// .beads/
|
||||
// config.yaml
|
||||
// beads-project/ <- relative path should resolve here
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create .beads directory with config file
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||
t.Fatalf("failed to create .beads dir: %v", err)
|
||||
}
|
||||
|
||||
// Create the target project directory
|
||||
projectDir := filepath.Join(tmpDir, "beads-project")
|
||||
if err := os.MkdirAll(projectDir, 0750); err != nil {
|
||||
t.Fatalf("failed to create project dir: %v", err)
|
||||
}
|
||||
|
||||
// Create config file with relative path
|
||||
configContent := `
|
||||
external_projects:
|
||||
beads: beads-project
|
||||
`
|
||||
configPath := filepath.Join(beadsDir, "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
|
||||
t.Fatalf("failed to write config: %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 := Initialize(); err != nil {
|
||||
t.Fatalf("failed to initialize config: %v", err)
|
||||
}
|
||||
|
||||
// Verify ConfigFileUsed() returns the config path
|
||||
usedConfig := ConfigFileUsed()
|
||||
if usedConfig == "" {
|
||||
t.Skip("config file not loaded - skipping test")
|
||||
}
|
||||
|
||||
// Resolve the external project path
|
||||
got := ResolveExternalProjectPath("beads")
|
||||
|
||||
// The path should resolve to tmpDir/beads-project (repo root + relative path)
|
||||
// NOT to .beads/beads-project (CWD + relative path)
|
||||
// Use canonicalize to handle macOS /var -> /private/var symlink
|
||||
if canonicalize(got) != canonicalize(projectDir) {
|
||||
t.Errorf("ResolveExternalProjectPath(beads) = %q, want %q", got, projectDir)
|
||||
}
|
||||
|
||||
// Verify the wrong path doesn't exist (CWD-based resolution)
|
||||
wrongPath := filepath.Join(beadsDir, "beads-project")
|
||||
if canonicalize(got) == canonicalize(wrongPath) {
|
||||
t.Errorf("path was incorrectly resolved from CWD: %s", wrongPath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CWD should not affect resolution", func(t *testing.T) {
|
||||
// Create two different directory structures
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create main repo with .beads and target project
|
||||
mainRepoDir := filepath.Join(tmpDir, "main-repo")
|
||||
beadsDir := filepath.Join(mainRepoDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||
t.Fatalf("failed to create .beads dir: %v", err)
|
||||
}
|
||||
|
||||
// Create the target project as a sibling directory
|
||||
siblingProject := filepath.Join(tmpDir, "sibling-project")
|
||||
if err := os.MkdirAll(siblingProject, 0750); err != nil {
|
||||
t.Fatalf("failed to create sibling project: %v", err)
|
||||
}
|
||||
|
||||
// Create config file with parent-relative path
|
||||
configContent := `
|
||||
external_projects:
|
||||
sibling: ../sibling-project
|
||||
`
|
||||
configPath := filepath.Join(beadsDir, "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
// Test from multiple different CWDs
|
||||
// Note: We only test from mainRepoDir and beadsDir, not from tmpDir
|
||||
// because when CWD is tmpDir, the config file at mainRepoDir/.beads/config.yaml
|
||||
// won't be discovered (viper searches from CWD upward)
|
||||
testDirs := []string{
|
||||
mainRepoDir, // From repo root
|
||||
beadsDir, // From .beads/ (daemon context)
|
||||
}
|
||||
|
||||
for _, testDir := range testDirs {
|
||||
// Change to test directory
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get cwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(testDir); err != nil {
|
||||
t.Fatalf("failed to chdir to %s: %v", testDir, err)
|
||||
}
|
||||
|
||||
// Reload config
|
||||
if err := Initialize(); err != nil {
|
||||
os.Chdir(origDir)
|
||||
t.Fatalf("failed to initialize config: %v", err)
|
||||
}
|
||||
|
||||
// Resolve the external project path
|
||||
got := ResolveExternalProjectPath("sibling")
|
||||
|
||||
// Restore CWD before checking result
|
||||
os.Chdir(origDir)
|
||||
|
||||
// Path should always resolve to the sibling project,
|
||||
// regardless of which directory we were in
|
||||
// Use canonicalize to handle macOS /var -> /private/var symlink
|
||||
if canonicalize(got) != canonicalize(siblingProject) {
|
||||
t.Errorf("from CWD=%s: ResolveExternalProjectPath(sibling) = %q, want %q",
|
||||
testDir, got, siblingProject)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidationConfigDefaults(t *testing.T) {
|
||||
// Isolate from environment variables
|
||||
restore := envSnapshot(t)
|
||||
|
||||
Reference in New Issue
Block a user