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
344
internal/beads/context.go
Normal file
344
internal/beads/context.go
Normal file
@@ -0,0 +1,344 @@
|
||||
// Package beads context.go provides centralized repository context resolution.
|
||||
//
|
||||
// Problem: 50+ git commands across the codebase assume CWD is the repository root.
|
||||
// When BEADS_DIR points to a different repo, or when running from a worktree,
|
||||
// these commands execute in the wrong directory.
|
||||
//
|
||||
// Solution: RepoContext provides a single source of truth for repository paths,
|
||||
// with methods that ensure git commands run in the correct repository.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// rc, err := beads.GetRepoContext()
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// cmd := rc.GitCmd(ctx, "status") // Runs in beads repo, not CWD
|
||||
//
|
||||
// See docs/REPO_CONTEXT.md for detailed documentation.
|
||||
package beads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/steveyegge/beads/internal/git"
|
||||
)
|
||||
|
||||
// RepoContext holds resolved repository paths for beads operations.
|
||||
//
|
||||
// The struct distinguishes between:
|
||||
// - RepoRoot: where .beads/ lives (for git operations on beads data)
|
||||
// - CWDRepoRoot: where user is working (for status display, etc.)
|
||||
//
|
||||
// These may differ when BEADS_DIR points to a different repository,
|
||||
// or when running from a git worktree.
|
||||
type RepoContext struct {
|
||||
// BeadsDir is the actual .beads directory path (after following redirects).
|
||||
BeadsDir string
|
||||
|
||||
// RepoRoot is the repository root containing BeadsDir.
|
||||
// Git commands for beads operations should run here.
|
||||
RepoRoot string
|
||||
|
||||
// CWDRepoRoot is the repository root containing the current working directory.
|
||||
// May differ from RepoRoot when BEADS_DIR points elsewhere.
|
||||
CWDRepoRoot string
|
||||
|
||||
// IsRedirected is true if BeadsDir was resolved via BEADS_DIR env var
|
||||
// pointing to a different repository than CWD.
|
||||
IsRedirected bool
|
||||
|
||||
// IsWorktree is true if CWD is in a git worktree.
|
||||
IsWorktree bool
|
||||
}
|
||||
|
||||
var (
|
||||
repoCtx *RepoContext
|
||||
repoCtxOnce sync.Once
|
||||
repoCtxErr error
|
||||
)
|
||||
|
||||
// GetRepoContext returns the cached repository context, initializing it on first call.
|
||||
//
|
||||
// The context is cached because:
|
||||
// 1. CWD doesn't change during command execution
|
||||
// 2. BEADS_DIR doesn't change during command execution
|
||||
// 3. Repeated filesystem access would be wasteful
|
||||
//
|
||||
// Returns an error if no .beads directory can be found.
|
||||
func GetRepoContext() (*RepoContext, error) {
|
||||
repoCtxOnce.Do(func() {
|
||||
repoCtx, repoCtxErr = buildRepoContext()
|
||||
})
|
||||
return repoCtx, repoCtxErr
|
||||
}
|
||||
|
||||
// buildRepoContext constructs the RepoContext by resolving all paths.
|
||||
// This is called once per process via sync.Once.
|
||||
func buildRepoContext() (*RepoContext, error) {
|
||||
// 1. Find .beads directory (respects BEADS_DIR env var)
|
||||
beadsDir := FindBeadsDir()
|
||||
if beadsDir == "" {
|
||||
return nil, fmt.Errorf("no .beads directory found")
|
||||
}
|
||||
|
||||
// 2. Security: Validate path boundary (SEC-003)
|
||||
if !isPathInSafeBoundary(beadsDir) {
|
||||
return nil, fmt.Errorf("BEADS_DIR points to unsafe location: %s", beadsDir)
|
||||
}
|
||||
|
||||
// 3. Check for redirect
|
||||
redirectInfo := GetRedirectInfo()
|
||||
|
||||
// 3. Determine RepoRoot based on redirect status
|
||||
var repoRoot string
|
||||
if redirectInfo.IsRedirected {
|
||||
// BEADS_DIR points to different repo - use that repo's root
|
||||
repoRoot = filepath.Dir(beadsDir)
|
||||
} else {
|
||||
// Normal case - find repo root via git
|
||||
var err error
|
||||
repoRoot, err = git.GetMainRepoRoot()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot determine repository root: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Get CWD's repo root (may differ from RepoRoot)
|
||||
cwdRepoRoot := git.GetRepoRoot() // Returns "" if not in git repo
|
||||
|
||||
// 5. Check worktree status
|
||||
isWorktree := git.IsWorktree()
|
||||
|
||||
return &RepoContext{
|
||||
BeadsDir: beadsDir,
|
||||
RepoRoot: repoRoot,
|
||||
CWDRepoRoot: cwdRepoRoot,
|
||||
IsRedirected: redirectInfo.IsRedirected,
|
||||
IsWorktree: isWorktree,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GitCmd creates an exec.Cmd configured to run git in the beads repository.
|
||||
//
|
||||
// This method sets cmd.Dir to RepoRoot, ensuring git commands operate on
|
||||
// the correct repository regardless of CWD.
|
||||
//
|
||||
// Security: Git hooks and templates are disabled to prevent code execution
|
||||
// in potentially malicious repositories (SEC-001, SEC-002).
|
||||
//
|
||||
// Pattern:
|
||||
//
|
||||
// cmd := rc.GitCmd(ctx, "add", ".beads/")
|
||||
// output, err := cmd.Output()
|
||||
//
|
||||
// Equivalent to running: cd $RepoRoot && git add .beads/
|
||||
func (rc *RepoContext) GitCmd(ctx context.Context, args ...string) *exec.Cmd {
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Dir = rc.RepoRoot
|
||||
// Security: Disable git hooks and templates to prevent code execution
|
||||
// in potentially malicious repositories (SEC-001, SEC-002)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_HOOKS_PATH=", // Disable hooks
|
||||
"GIT_TEMPLATE_DIR=", // Disable templates
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// GitCmdCWD creates an exec.Cmd configured to run git in the user's working repository.
|
||||
//
|
||||
// Use this for git commands that should reflect the user's current context,
|
||||
// such as showing status or checking for uncommitted changes in their working repo.
|
||||
//
|
||||
// If CWD is not in a git repository, cmd.Dir is left unset (uses process CWD).
|
||||
func (rc *RepoContext) GitCmdCWD(ctx context.Context, args ...string) *exec.Cmd {
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
if rc.CWDRepoRoot != "" {
|
||||
cmd.Dir = rc.CWDRepoRoot
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// RelPath returns the given absolute path relative to the beads repository root.
|
||||
//
|
||||
// Useful for displaying paths to users in a consistent, repo-relative format.
|
||||
// Returns an error if the path is not within the repository.
|
||||
func (rc *RepoContext) RelPath(absPath string) (string, error) {
|
||||
return filepath.Rel(rc.RepoRoot, absPath)
|
||||
}
|
||||
|
||||
// ResetCaches clears the cached RepoContext, forcing re-resolution on next call.
|
||||
//
|
||||
// This is intended for tests that need to change directory or BEADS_DIR
|
||||
// between test cases. In production, the cache is safe because these
|
||||
// values don't change during command execution.
|
||||
//
|
||||
// WARNING: Not thread-safe. Only call from single-threaded test contexts.
|
||||
//
|
||||
// Usage in tests:
|
||||
//
|
||||
// t.Cleanup(func() {
|
||||
// beads.ResetCaches()
|
||||
// git.ResetCaches()
|
||||
// })
|
||||
func ResetCaches() {
|
||||
repoCtxOnce = sync.Once{}
|
||||
repoCtx = nil
|
||||
repoCtxErr = nil
|
||||
}
|
||||
|
||||
// unsafePrefixes lists system directories that BEADS_DIR should never point to.
|
||||
// This prevents path traversal attacks (SEC-003).
|
||||
var unsafePrefixes = []string{
|
||||
"/etc", "/usr", "/var", "/root", "/System", "/Library",
|
||||
"/bin", "/sbin", "/opt", "/private",
|
||||
}
|
||||
|
||||
// isPathInSafeBoundary validates that a path is not in sensitive system directories.
|
||||
// Returns false if the path is in an unsafe location (SEC-003).
|
||||
func isPathInSafeBoundary(path string) bool {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Allow OS-designated temp directories (e.g., /var/folders on macOS)
|
||||
// On macOS, TempDir() returns paths under /var/folders which symlinks to /private/var/folders
|
||||
tempDir := os.TempDir()
|
||||
resolvedTemp, _ := filepath.EvalSymlinks(tempDir)
|
||||
resolvedPath, _ := filepath.EvalSymlinks(absPath)
|
||||
if resolvedTemp != "" && strings.HasPrefix(resolvedPath, resolvedTemp) {
|
||||
return true
|
||||
}
|
||||
// Also check unresolved paths (in case symlink resolution fails)
|
||||
if strings.HasPrefix(absPath, tempDir) {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, prefix := range unsafePrefixes {
|
||||
if strings.HasPrefix(absPath, prefix+"/") || absPath == prefix {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Also reject other users' home directories
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
if strings.HasPrefix(absPath, "/Users/") || strings.HasPrefix(absPath, "/home/") {
|
||||
if homeDir != "" && !strings.HasPrefix(absPath, homeDir) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetRepoContextForWorkspace returns a fresh RepoContext for a specific workspace.
|
||||
//
|
||||
// Unlike GetRepoContext(), this function:
|
||||
// - Does NOT cache results (daemon handles multiple workspaces)
|
||||
// - Does NOT respect BEADS_DIR (workspace path is explicit)
|
||||
// - Resolves worktree relationships correctly
|
||||
//
|
||||
// This is designed for long-running processes like the daemon that need to handle
|
||||
// multiple workspaces or detect context changes (DMN-001).
|
||||
//
|
||||
// The function temporarily changes to the workspace directory to resolve paths,
|
||||
// then restores the original directory.
|
||||
func GetRepoContextForWorkspace(workspacePath string) (*RepoContext, error) {
|
||||
// Normalize workspace path
|
||||
absWorkspace, err := filepath.Abs(workspacePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot resolve workspace path %s: %w", workspacePath, err)
|
||||
}
|
||||
|
||||
// Change to workspace directory temporarily
|
||||
originalDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = os.Chdir(originalDir) }()
|
||||
|
||||
if err := os.Chdir(absWorkspace); err != nil {
|
||||
return nil, fmt.Errorf("cannot access workspace %s: %w", absWorkspace, err)
|
||||
}
|
||||
|
||||
// Clear git caches for fresh resolution
|
||||
git.ResetCaches()
|
||||
|
||||
// Build context fresh, specifically for this workspace (ignores BEADS_DIR)
|
||||
return buildRepoContextForWorkspace(absWorkspace)
|
||||
}
|
||||
|
||||
// buildRepoContextForWorkspace constructs RepoContext for a specific workspace.
|
||||
// Unlike buildRepoContext(), this ignores BEADS_DIR env var since the workspace
|
||||
// path is explicitly provided (used by daemon).
|
||||
func buildRepoContextForWorkspace(workspacePath string) (*RepoContext, error) {
|
||||
// 1. Determine if we're in a worktree and find the main repo root
|
||||
var repoRoot string
|
||||
var isWorktree bool
|
||||
|
||||
if git.IsWorktree() {
|
||||
isWorktree = true
|
||||
var err error
|
||||
repoRoot, err = git.GetMainRepoRoot()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot determine main repository root: %w", err)
|
||||
}
|
||||
} else {
|
||||
isWorktree = false
|
||||
repoRoot = git.GetRepoRoot()
|
||||
if repoRoot == "" {
|
||||
return nil, fmt.Errorf("workspace %s is not in a git repository", workspacePath)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Find .beads directory in the appropriate location
|
||||
beadsDir := filepath.Join(repoRoot, ".beads")
|
||||
|
||||
// Check if .beads exists
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("no .beads directory found at %s", beadsDir)
|
||||
}
|
||||
|
||||
// 3. Follow redirect if present
|
||||
beadsDir = FollowRedirect(beadsDir)
|
||||
|
||||
// 4. Security: Validate path boundary (SEC-003)
|
||||
if !isPathInSafeBoundary(beadsDir) {
|
||||
return nil, fmt.Errorf("beads directory in unsafe location: %s", beadsDir)
|
||||
}
|
||||
|
||||
// 5. Validate directory contains actual project files
|
||||
if !hasBeadsProjectFiles(beadsDir) {
|
||||
return nil, fmt.Errorf("beads directory missing required files: %s", beadsDir)
|
||||
}
|
||||
|
||||
// 6. Get CWD's repo root (same as workspace in this case)
|
||||
cwdRepoRoot := git.GetRepoRoot()
|
||||
|
||||
return &RepoContext{
|
||||
BeadsDir: beadsDir,
|
||||
RepoRoot: repoRoot,
|
||||
CWDRepoRoot: cwdRepoRoot,
|
||||
IsRedirected: false, // Workspace-specific context is never "redirected"
|
||||
IsWorktree: isWorktree,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate checks if the cached context is still valid.
|
||||
//
|
||||
// Returns an error if BeadsDir or RepoRoot no longer exist. This is useful
|
||||
// for long-running processes that need to detect when context becomes stale (DMN-002).
|
||||
func (rc *RepoContext) Validate() error {
|
||||
if _, err := os.Stat(rc.BeadsDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("BeadsDir no longer exists: %s", rc.BeadsDir)
|
||||
}
|
||||
if _, err := os.Stat(rc.RepoRoot); os.IsNotExist(err) {
|
||||
return fmt.Errorf("RepoRoot no longer exists: %s", rc.RepoRoot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
519
internal/beads/context_test.go
Normal file
519
internal/beads/context_test.go
Normal file
@@ -0,0 +1,519 @@
|
||||
package beads
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/git"
|
||||
)
|
||||
|
||||
// TestGetRepoContextForWorkspace_NormalRepo tests context resolution for a normal git repository
|
||||
func TestGetRepoContextForWorkspace_NormalRepo(t *testing.T) {
|
||||
// Create a temporary git repo
|
||||
tmpDir := t.TempDir()
|
||||
if err := initGitRepo(tmpDir); err != nil {
|
||||
t.Fatalf("failed to init git repo: %v", err)
|
||||
}
|
||||
|
||||
// Create .beads directory with required files
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||
t.Fatalf("failed to create .beads dir: %v", err)
|
||||
}
|
||||
// Create a database file (required for hasBeadsProjectFiles)
|
||||
if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte{}, 0644); err != nil {
|
||||
t.Fatalf("failed to create beads.db: %v", err)
|
||||
}
|
||||
|
||||
// Reset caches before test
|
||||
t.Cleanup(func() {
|
||||
ResetCaches()
|
||||
git.ResetCaches()
|
||||
})
|
||||
|
||||
// Get context for the workspace
|
||||
rc, err := GetRepoContextForWorkspace(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRepoContextForWorkspace failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify context fields
|
||||
if rc.RepoRoot != resolveSymlinks(tmpDir) {
|
||||
t.Errorf("RepoRoot mismatch: expected %s, got %s", resolveSymlinks(tmpDir), rc.RepoRoot)
|
||||
}
|
||||
if rc.BeadsDir != resolveSymlinks(beadsDir) {
|
||||
t.Errorf("BeadsDir mismatch: expected %s, got %s", resolveSymlinks(beadsDir), rc.BeadsDir)
|
||||
}
|
||||
if rc.IsRedirected {
|
||||
t.Error("IsRedirected should be false for workspace-specific context")
|
||||
}
|
||||
if rc.IsWorktree {
|
||||
t.Error("IsWorktree should be false for main repo")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRepoContextForWorkspace_IgnoresBEADS_DIR verifies that workspace-specific
|
||||
// context resolution ignores the BEADS_DIR environment variable (DMN-001)
|
||||
func TestGetRepoContextForWorkspace_IgnoresBEADS_DIR(t *testing.T) {
|
||||
// Save original env var
|
||||
originalBeadsDir := os.Getenv("BEADS_DIR")
|
||||
t.Cleanup(func() {
|
||||
if originalBeadsDir != "" {
|
||||
os.Setenv("BEADS_DIR", originalBeadsDir)
|
||||
} else {
|
||||
os.Unsetenv("BEADS_DIR")
|
||||
}
|
||||
ResetCaches()
|
||||
git.ResetCaches()
|
||||
})
|
||||
|
||||
// Create two separate repos: repo1 and repo2
|
||||
tmpDir := t.TempDir()
|
||||
repo1 := filepath.Join(tmpDir, "repo1")
|
||||
repo2 := filepath.Join(tmpDir, "repo2")
|
||||
|
||||
for _, repo := range []string{repo1, repo2} {
|
||||
if err := os.MkdirAll(repo, 0750); err != nil {
|
||||
t.Fatalf("failed to create repo dir: %v", err)
|
||||
}
|
||||
if err := initGitRepo(repo); err != nil {
|
||||
t.Fatalf("failed to init git repo in %s: %v", repo, err)
|
||||
}
|
||||
beadsDir := filepath.Join(repo, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||
t.Fatalf("failed to create .beads in %s: %v", repo, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte{}, 0644); err != nil {
|
||||
t.Fatalf("failed to create beads.db: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set BEADS_DIR to repo2's .beads
|
||||
os.Setenv("BEADS_DIR", filepath.Join(repo2, ".beads"))
|
||||
|
||||
// Get context for repo1 - should find repo1's .beads, NOT repo2's
|
||||
rc, err := GetRepoContextForWorkspace(repo1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRepoContextForWorkspace failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify we got repo1, not repo2
|
||||
expectedBeadsDir := resolveSymlinks(filepath.Join(repo1, ".beads"))
|
||||
if rc.BeadsDir != expectedBeadsDir {
|
||||
t.Errorf("BEADS_DIR was not ignored: expected %s, got %s", expectedBeadsDir, rc.BeadsDir)
|
||||
}
|
||||
expectedRepoRoot := resolveSymlinks(repo1)
|
||||
if rc.RepoRoot != expectedRepoRoot {
|
||||
t.Errorf("RepoRoot mismatch: expected %s, got %s", expectedRepoRoot, rc.RepoRoot)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRepoContextForWorkspace_NonexistentPath tests handling of invalid workspace paths
|
||||
func TestGetRepoContextForWorkspace_NonexistentPath(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
ResetCaches()
|
||||
git.ResetCaches()
|
||||
})
|
||||
|
||||
_, err := GetRepoContextForWorkspace("/nonexistent/path/that/does/not/exist")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent workspace path")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRepoContextForWorkspace_NonGitDirectory tests handling of non-git directories
|
||||
func TestGetRepoContextForWorkspace_NonGitDirectory(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
t.Cleanup(func() {
|
||||
ResetCaches()
|
||||
git.ResetCaches()
|
||||
})
|
||||
|
||||
// Don't initialize git - just a plain directory
|
||||
_, err := GetRepoContextForWorkspace(tmpDir)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-git directory")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRepoContextForWorkspace_MissingBeadsDir tests error when .beads doesn't exist
|
||||
func TestGetRepoContextForWorkspace_MissingBeadsDir(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
if err := initGitRepo(tmpDir); err != nil {
|
||||
t.Fatalf("failed to init git repo: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
ResetCaches()
|
||||
git.ResetCaches()
|
||||
})
|
||||
|
||||
// No .beads directory created
|
||||
_, err := GetRepoContextForWorkspace(tmpDir)
|
||||
if err == nil {
|
||||
t.Error("expected error when .beads directory is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepoContext_Validate tests the Validate method for detecting stale contexts
|
||||
func TestRepoContext_Validate(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
if err := initGitRepo(tmpDir); err != nil {
|
||||
t.Fatalf("failed to init git repo: %v", err)
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||
t.Fatalf("failed to create .beads: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte{}, 0644); err != nil {
|
||||
t.Fatalf("failed to create beads.db: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
ResetCaches()
|
||||
git.ResetCaches()
|
||||
})
|
||||
|
||||
// Get initial context
|
||||
rc, err := GetRepoContextForWorkspace(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRepoContextForWorkspace failed: %v", err)
|
||||
}
|
||||
|
||||
// Validate should pass initially
|
||||
if err := rc.Validate(); err != nil {
|
||||
t.Errorf("Validate should pass for fresh context: %v", err)
|
||||
}
|
||||
|
||||
// Remove the .beads directory to make context stale
|
||||
if err := os.RemoveAll(beadsDir); err != nil {
|
||||
t.Fatalf("failed to remove .beads: %v", err)
|
||||
}
|
||||
|
||||
// Validate should now fail (stale context)
|
||||
if err := rc.Validate(); err == nil {
|
||||
t.Error("Validate should fail when BeadsDir no longer exists")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepoContext_Validate_RepoRootRemoved tests Validate when repo root is removed
|
||||
func TestRepoContext_Validate_RepoRootRemoved(t *testing.T) {
|
||||
// Create repo inside a removable parent
|
||||
parentDir := t.TempDir()
|
||||
repoDir := filepath.Join(parentDir, "removable-repo")
|
||||
if err := os.MkdirAll(repoDir, 0750); err != nil {
|
||||
t.Fatalf("failed to create repo dir: %v", err)
|
||||
}
|
||||
if err := initGitRepo(repoDir); err != nil {
|
||||
t.Fatalf("failed to init git repo: %v", err)
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(repoDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||
t.Fatalf("failed to create .beads: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte{}, 0644); err != nil {
|
||||
t.Fatalf("failed to create beads.db: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
ResetCaches()
|
||||
git.ResetCaches()
|
||||
})
|
||||
|
||||
// Get context
|
||||
rc, err := GetRepoContextForWorkspace(repoDir)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRepoContextForWorkspace failed: %v", err)
|
||||
}
|
||||
|
||||
// Validate should pass
|
||||
if err := rc.Validate(); err != nil {
|
||||
t.Errorf("Validate should pass for fresh context: %v", err)
|
||||
}
|
||||
|
||||
// Remove the entire repo
|
||||
if err := os.RemoveAll(repoDir); err != nil {
|
||||
t.Fatalf("failed to remove repo: %v", err)
|
||||
}
|
||||
|
||||
// Validate should now fail (both BeadsDir and RepoRoot are gone)
|
||||
if err := rc.Validate(); err == nil {
|
||||
t.Error("Validate should fail when RepoRoot no longer exists")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRepoContextForWorkspace_CacheReset verifies that multiple calls return fresh contexts
|
||||
func TestGetRepoContextForWorkspace_CacheReset(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
if err := initGitRepo(tmpDir); err != nil {
|
||||
t.Fatalf("failed to init git repo: %v", err)
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||
t.Fatalf("failed to create .beads: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte{}, 0644); err != nil {
|
||||
t.Fatalf("failed to create beads.db: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
ResetCaches()
|
||||
git.ResetCaches()
|
||||
})
|
||||
|
||||
// First call
|
||||
rc1, err := GetRepoContextForWorkspace(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("first GetRepoContextForWorkspace failed: %v", err)
|
||||
}
|
||||
|
||||
// Second call - should still work (fresh resolution)
|
||||
rc2, err := GetRepoContextForWorkspace(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("second GetRepoContextForWorkspace failed: %v", err)
|
||||
}
|
||||
|
||||
// Both should return valid contexts
|
||||
if rc1.BeadsDir != rc2.BeadsDir {
|
||||
t.Errorf("BeadsDir mismatch between calls: %s vs %s", rc1.BeadsDir, rc2.BeadsDir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRepoContextForWorkspace_RelativePath tests handling of relative workspace paths
|
||||
func TestGetRepoContextForWorkspace_RelativePath(t *testing.T) {
|
||||
// Get original working directory
|
||||
originalWd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get working directory: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
os.Chdir(originalWd)
|
||||
ResetCaches()
|
||||
git.ResetCaches()
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
if err := initGitRepo(tmpDir); err != nil {
|
||||
t.Fatalf("failed to init git repo: %v", err)
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||
t.Fatalf("failed to create .beads: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte{}, 0644); err != nil {
|
||||
t.Fatalf("failed to create beads.db: %v", err)
|
||||
}
|
||||
|
||||
// Change to parent directory
|
||||
parentDir := filepath.Dir(tmpDir)
|
||||
os.Chdir(parentDir)
|
||||
|
||||
// Use relative path
|
||||
relPath := filepath.Base(tmpDir)
|
||||
rc, err := GetRepoContextForWorkspace(relPath)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRepoContextForWorkspace with relative path failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify we got the correct absolute path
|
||||
expectedBeadsDir := resolveSymlinks(beadsDir)
|
||||
if rc.BeadsDir != expectedBeadsDir {
|
||||
t.Errorf("BeadsDir mismatch: expected %s, got %s", expectedBeadsDir, rc.BeadsDir)
|
||||
}
|
||||
}
|
||||
|
||||
// initGitRepo initializes a git repository in the given directory
|
||||
func initGitRepo(dir string) error {
|
||||
cmd := exec.Command("git", "init")
|
||||
cmd.Dir = dir
|
||||
// Suppress git output
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// TestIsPathInSafeBoundary tests security boundary validation (TS-SEC-003)
|
||||
// This ensures redirect paths cannot escape to sensitive system directories.
|
||||
func TestIsPathInSafeBoundary(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
ResetCaches()
|
||||
git.ResetCaches()
|
||||
})
|
||||
|
||||
// Get user home directory for test comparisons
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get home directory: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expected bool
|
||||
}{
|
||||
// Unsafe system directories - should be rejected
|
||||
{"system /etc", "/etc/beads", false},
|
||||
{"system /usr", "/usr/local/beads", false},
|
||||
{"system /var", "/var/lib/beads", false},
|
||||
{"system /root", "/root/.beads", false},
|
||||
{"system /bin", "/bin/.beads", false},
|
||||
{"system /sbin", "/sbin/.beads", false},
|
||||
{"system /opt", "/opt/beads", false},
|
||||
{"macOS /System", "/System/Library/.beads", false},
|
||||
{"macOS /Library", "/Library/Application Support/.beads", false},
|
||||
{"macOS /private", "/private/etc/.beads", false},
|
||||
|
||||
// Safe paths - should be accepted
|
||||
{"user home directory", filepath.Join(homeDir, "projects/.beads"), true},
|
||||
{"temp directory", os.TempDir(), true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isPathInSafeBoundary(tt.path)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isPathInSafeBoundary(%q) = %v, want %v", tt.path, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRepoContextForWorkspace_RedirectToUnsafeLocation tests that redirects
|
||||
// to unsafe locations are rejected (TS-SEC-003 integration test).
|
||||
func TestGetRepoContextForWorkspace_RedirectToUnsafeLocation(t *testing.T) {
|
||||
// Save original env
|
||||
originalBeadsDir := os.Getenv("BEADS_DIR")
|
||||
t.Cleanup(func() {
|
||||
if originalBeadsDir != "" {
|
||||
os.Setenv("BEADS_DIR", originalBeadsDir)
|
||||
} else {
|
||||
os.Unsetenv("BEADS_DIR")
|
||||
}
|
||||
ResetCaches()
|
||||
git.ResetCaches()
|
||||
})
|
||||
|
||||
// Create a temporary git repo
|
||||
tmpDir := t.TempDir()
|
||||
if err := initGitRepo(tmpDir); err != nil {
|
||||
t.Fatalf("failed to init git repo: %v", err)
|
||||
}
|
||||
|
||||
// Create .beads directory with a redirect file pointing outside safe boundary
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||
t.Fatalf("failed to create .beads dir: %v", err)
|
||||
}
|
||||
|
||||
// Write redirect pointing to /etc (unsafe location)
|
||||
// Note: FollowRedirect won't follow non-existent paths, but if /etc/.beads existed
|
||||
// and contained beads files, this security check would catch it
|
||||
redirectFile := filepath.Join(beadsDir, "redirect")
|
||||
if err := os.WriteFile(redirectFile, []byte("/etc/.beads\n"), 0644); err != nil {
|
||||
t.Fatalf("failed to write redirect file: %v", err)
|
||||
}
|
||||
|
||||
// Since /etc/.beads doesn't exist, FollowRedirect returns original path
|
||||
// So create a valid beads.db in the local .beads to get past initial validation,
|
||||
// then test the boundary check directly via the isPathInSafeBoundary function
|
||||
if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte{}, 0644); err != nil {
|
||||
t.Fatalf("failed to create beads.db: %v", err)
|
||||
}
|
||||
|
||||
// GetRepoContextForWorkspace should succeed because FollowRedirect
|
||||
// returns the original safe path when target doesn't exist
|
||||
rc, err := GetRepoContextForWorkspace(tmpDir)
|
||||
if err != nil {
|
||||
// This is expected if the implementation catches the redirect attempt
|
||||
// Even though target doesn't exist, the test verifies the security boundary
|
||||
t.Logf("GetRepoContextForWorkspace correctly rejected unsafe redirect: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// If we get here, the context was created with the safe local path
|
||||
// (because /etc/.beads doesn't exist and FollowRedirect fell back)
|
||||
// Verify it's using the local beads dir, not the unsafe redirect target
|
||||
expectedBeadsDir := resolveSymlinks(beadsDir)
|
||||
if rc.BeadsDir != expectedBeadsDir {
|
||||
t.Errorf("BeadsDir = %q, want safe local path %q", rc.BeadsDir, expectedBeadsDir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRepoContextForWorkspace_RedirectWithinRepo tests that redirects
|
||||
// staying within the same repo or user's directories are allowed.
|
||||
func TestGetRepoContextForWorkspace_RedirectWithinRepo(t *testing.T) {
|
||||
originalBeadsDir := os.Getenv("BEADS_DIR")
|
||||
t.Cleanup(func() {
|
||||
if originalBeadsDir != "" {
|
||||
os.Setenv("BEADS_DIR", originalBeadsDir)
|
||||
} else {
|
||||
os.Unsetenv("BEADS_DIR")
|
||||
}
|
||||
ResetCaches()
|
||||
git.ResetCaches()
|
||||
})
|
||||
|
||||
// Create two git repos in temp directory
|
||||
tmpDir := t.TempDir()
|
||||
repo1 := filepath.Join(tmpDir, "repo1")
|
||||
repo2 := filepath.Join(tmpDir, "repo2")
|
||||
|
||||
for _, repo := range []string{repo1, repo2} {
|
||||
if err := os.MkdirAll(repo, 0750); err != nil {
|
||||
t.Fatalf("failed to create repo dir: %v", err)
|
||||
}
|
||||
if err := initGitRepo(repo); err != nil {
|
||||
t.Fatalf("failed to init git repo in %s: %v", repo, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create .beads with actual files in repo2
|
||||
beadsDir2 := filepath.Join(repo2, ".beads")
|
||||
if err := os.MkdirAll(beadsDir2, 0750); err != nil {
|
||||
t.Fatalf("failed to create .beads in repo2: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(beadsDir2, "beads.db"), []byte{}, 0644); err != nil {
|
||||
t.Fatalf("failed to create beads.db in repo2: %v", err)
|
||||
}
|
||||
|
||||
// Create .beads in repo1 with redirect to repo2
|
||||
beadsDir1 := filepath.Join(repo1, ".beads")
|
||||
if err := os.MkdirAll(beadsDir1, 0750); err != nil {
|
||||
t.Fatalf("failed to create .beads in repo1: %v", err)
|
||||
}
|
||||
redirectFile := filepath.Join(beadsDir1, "redirect")
|
||||
if err := os.WriteFile(redirectFile, []byte(beadsDir2+"\n"), 0644); err != nil {
|
||||
t.Fatalf("failed to write redirect file: %v", err)
|
||||
}
|
||||
|
||||
// GetRepoContextForWorkspace for repo1 should work
|
||||
// Note: GetRepoContextForWorkspace ignores BEADS_DIR and looks for .beads in workspace
|
||||
// But it does follow the redirect file in the local .beads
|
||||
rc, err := GetRepoContextForWorkspace(repo1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRepoContextForWorkspace failed for safe redirect: %v", err)
|
||||
}
|
||||
|
||||
// Verify the redirect was followed to repo2's .beads
|
||||
expectedBeadsDir := resolveSymlinks(beadsDir2)
|
||||
if rc.BeadsDir != expectedBeadsDir {
|
||||
t.Errorf("BeadsDir = %q, want redirected path %q", rc.BeadsDir, expectedBeadsDir)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveSymlinks resolves symlinks and returns the canonical path
|
||||
// This handles macOS temp directory symlinks (/var -> /private/var)
|
||||
func resolveSymlinks(path string) string {
|
||||
resolved, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
@@ -1,15 +1,29 @@
|
||||
package compact
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/beads/internal/beads"
|
||||
)
|
||||
|
||||
var gitExec = func(name string, args ...string) ([]byte, error) {
|
||||
return exec.Command(name, args...).Output()
|
||||
// gitExec is a function hook for executing git commands.
|
||||
// In production, it uses RepoContext. In tests, it can be swapped for mocking.
|
||||
var gitExec = defaultGitExec
|
||||
|
||||
// defaultGitExec uses RepoContext to execute git commands in the beads repository.
|
||||
func defaultGitExec(name string, args ...string) ([]byte, error) {
|
||||
// name is always "git" when called from GetCurrentCommitHash
|
||||
rc, err := beads.GetRepoContext()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd := rc.GitCmd(context.Background(), args...)
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
// GetCurrentCommitHash returns the current git HEAD commit hash.
|
||||
// GetCurrentCommitHash returns the current git HEAD commit hash for the beads repository.
|
||||
// Returns empty string if not in a git repository or if git command fails.
|
||||
func GetCurrentCommitHash() string {
|
||||
output, err := gitExec("git", "rev-parse", "HEAD")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,11 +3,11 @@ package daemon
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/beads"
|
||||
"github.com/steveyegge/beads/internal/lockfile"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
"github.com/steveyegge/beads/internal/utils"
|
||||
@@ -252,44 +252,37 @@ func FindDaemonByWorkspace(workspacePath string) (*DaemonInfo, error) {
|
||||
return nil, fmt.Errorf("no daemon found for workspace: %s", workspacePath)
|
||||
}
|
||||
|
||||
// findBeadsDirForWorkspace determines the correct .beads directory for a workspace
|
||||
// For worktrees, this is the main repository root; for regular repos, it's the workspace itself
|
||||
// findBeadsDirForWorkspace determines the correct .beads directory for a workspace.
|
||||
// For worktrees, this is the main repository root; for regular repos, it's the workspace itself.
|
||||
//
|
||||
// This function delegates to beads.GetRepoContextForWorkspace() for proper resolution
|
||||
// including worktree detection and path validation (DMN-001).
|
||||
func findBeadsDirForWorkspace(workspacePath string) string {
|
||||
// Change to the workspace directory to check if it's a worktree
|
||||
originalDir, err := os.Getwd()
|
||||
// Use the centralized RepoContext API for workspace resolution
|
||||
rc, err := beads.GetRepoContextForWorkspace(workspacePath)
|
||||
if err != nil {
|
||||
return filepath.Join(workspacePath, ".beads") // fallback
|
||||
// Fallback to simple path join if context resolution fails
|
||||
// This maintains backward compatibility for edge cases
|
||||
return filepath.Join(workspacePath, ".beads")
|
||||
}
|
||||
defer func() {
|
||||
_ = os.Chdir(originalDir) // restore original directory
|
||||
}()
|
||||
return rc.BeadsDir
|
||||
}
|
||||
|
||||
if err := os.Chdir(workspacePath); err != nil {
|
||||
return filepath.Join(workspacePath, ".beads") // fallback
|
||||
}
|
||||
|
||||
// Check if we're in a git worktree
|
||||
cmd := exec.Command("git", "rev-parse", "--git-dir", "--git-common-dir")
|
||||
output, err := cmd.Output()
|
||||
// getRepoContextForWorkspace returns the full RepoContext for a workspace.
|
||||
// This provides access to RepoRoot, BeadsDir, and worktree status for operations
|
||||
// that need more than just the .beads directory path.
|
||||
//
|
||||
// Returns an error if the workspace cannot be resolved or validated.
|
||||
func getRepoContextForWorkspace(workspacePath string) (*beads.RepoContext, error) {
|
||||
rc, err := beads.GetRepoContextForWorkspace(workspacePath)
|
||||
if err != nil {
|
||||
return filepath.Join(workspacePath, ".beads") // fallback
|
||||
return nil, fmt.Errorf("cannot resolve workspace context: %w", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(lines) >= 2 {
|
||||
gitDir := strings.TrimSpace(lines[0])
|
||||
commonDir := strings.TrimSpace(lines[1])
|
||||
|
||||
// If git-dir != git-common-dir, we're in a worktree
|
||||
if gitDir != commonDir {
|
||||
// Worktree: .beads is in main repo root (parent of git-common-dir)
|
||||
mainRepoRoot := filepath.Dir(commonDir)
|
||||
return filepath.Join(mainRepoRoot, ".beads")
|
||||
}
|
||||
// Validate the context is still valid (paths exist)
|
||||
if err := rc.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("workspace context is stale: %w", err)
|
||||
}
|
||||
|
||||
// Regular repository: .beads is in the workspace
|
||||
return filepath.Join(workspacePath, ".beads")
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
// checkDaemonErrorFile checks for a daemon-error file in the .beads directory
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -972,6 +972,10 @@ func getRemoteForBranch(ctx context.Context, worktreePath, branch string) string
|
||||
// GetRepoRoot returns the git repository root directory
|
||||
// For worktrees, this returns the main repository root (not the worktree root)
|
||||
// The returned path is canonicalized to fix case on case-insensitive filesystems (GH#880)
|
||||
//
|
||||
// Deprecated: Use beads.GetRepoContext().RepoRoot instead. GetRepoContext provides
|
||||
// a unified API that correctly handles BEADS_DIR, worktrees, and redirects.
|
||||
// This function will be removed in a future release.
|
||||
func GetRepoRoot(ctx context.Context) (string, error) {
|
||||
var repoRoot string
|
||||
|
||||
|
||||
@@ -187,3 +187,18 @@ func NormalizePathForComparison(path string) string {
|
||||
func PathsEqual(path1, path2 string) bool {
|
||||
return NormalizePathForComparison(path1) == NormalizePathForComparison(path2)
|
||||
}
|
||||
|
||||
// CanonicalizeIfRelative ensures a path is absolute for filepath.Rel() compatibility.
|
||||
// If the path is non-empty and relative, it is canonicalized using CanonicalizePath.
|
||||
// Absolute paths and empty strings are returned unchanged.
|
||||
//
|
||||
// This guards against code paths that might set paths to relative values,
|
||||
// which would cause filepath.Rel() to fail or produce incorrect results.
|
||||
//
|
||||
// See GH#959 for root cause analysis of the original autoflush bug.
|
||||
func CanonicalizeIfRelative(path string) string {
|
||||
if path != "" && !filepath.IsAbs(path) {
|
||||
return CanonicalizePath(path)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -356,6 +356,106 @@ func TestCanonicalizePathCase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizeIfRelative(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
validate func(t *testing.T, result string)
|
||||
}{
|
||||
{
|
||||
name: "empty string returns empty",
|
||||
input: "",
|
||||
validate: func(t *testing.T, result string) {
|
||||
if result != "" {
|
||||
t.Errorf("expected empty string, got %q", result)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "absolute path returns unchanged",
|
||||
input: "/tmp/test/path",
|
||||
validate: func(t *testing.T, result string) {
|
||||
if result != "/tmp/test/path" {
|
||||
t.Errorf("expected /tmp/test/path, got %q", result)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative path returns canonicalized absolute",
|
||||
input: ".",
|
||||
validate: func(t *testing.T, result string) {
|
||||
if !filepath.IsAbs(result) {
|
||||
t.Errorf("expected absolute path, got %q", result)
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get cwd: %v", err)
|
||||
}
|
||||
// Result should be related to cwd
|
||||
canonicalCwd := CanonicalizePath(cwd)
|
||||
if result != canonicalCwd {
|
||||
t.Errorf("expected %q, got %q", canonicalCwd, result)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative subdirectory path",
|
||||
input: "subdir/file.txt",
|
||||
validate: func(t *testing.T, result string) {
|
||||
if !filepath.IsAbs(result) {
|
||||
t.Errorf("expected absolute path, got %q", result)
|
||||
}
|
||||
if !strings.HasSuffix(result, "subdir/file.txt") && !strings.HasSuffix(result, "subdir\\file.txt") {
|
||||
t.Errorf("expected path to end with subdir/file.txt, got %q", result)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := CanonicalizeIfRelative(tt.input)
|
||||
tt.validate(t, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizeIfRelativeTilde(t *testing.T) {
|
||||
// Test tilde expansion - CanonicalizeIfRelative does NOT expand tilde
|
||||
// because ~ is not detected as relative by filepath.IsAbs()
|
||||
// This documents the current behavior - tilde paths need separate handling
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip("could not get home directory")
|
||||
}
|
||||
|
||||
// ~/path is technically not absolute, but filepath.IsAbs treats it as relative
|
||||
tildeInput := "~/testpath"
|
||||
result := CanonicalizeIfRelative(tildeInput)
|
||||
|
||||
// The function should canonicalize it since ~ is not absolute
|
||||
// But CanonicalizePath doesn't expand tilde, so we get cwd + ~/testpath
|
||||
// This documents the expected behavior - tilde expansion must happen before
|
||||
// calling CanonicalizeIfRelative
|
||||
if result == tildeInput {
|
||||
t.Logf("tilde path returned unchanged - tilde expansion happens elsewhere")
|
||||
} else if strings.Contains(result, home) {
|
||||
t.Logf("tilde was expanded to home directory")
|
||||
}
|
||||
// Either behavior is acceptable - this test documents it
|
||||
|
||||
// Verify absolute tilde-expanded paths work correctly
|
||||
absoluteWithHome := filepath.Join(home, "testpath")
|
||||
result = CanonicalizeIfRelative(absoluteWithHome)
|
||||
if result != absoluteWithHome {
|
||||
// May differ on macOS due to /var -> /private/var symlink resolution
|
||||
// Just verify it's still absolute and contains the expected path
|
||||
if !filepath.IsAbs(result) {
|
||||
t.Errorf("expected absolute path, got %q", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathsEqual(t *testing.T) {
|
||||
t.Run("identical paths", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
Reference in New Issue
Block a user