From 8a6fd9c0ff4166da1ece694e0af0ff77390ef532 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 30 Nov 2025 19:23:08 -0800 Subject: [PATCH] feat: add .beads/redirect file support for workspace redirection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a lightweight redirect mechanism that allows a stub .beads directory to point to the actual beads location. This solves the workspace problem where an AI agent runs in one directory but needs to operate on beads stored elsewhere. The redirect file is a simple text file containing a path (relative or absolute) to the target .beads directory. Comments (lines starting with #) are supported. Redirect chains are prevented - only one level is followed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .beads/issues.jsonl | 2 +- internal/beads/beads.go | 92 ++++++++++- internal/beads/beads_test.go | 312 +++++++++++++++++++++++++++++++++++ 3 files changed, 404 insertions(+), 2 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 1b9ad443..34991e88 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,4 @@ -{"id":"bd-93d","title":"Jira export script (jsonl2jira.py)","description":"Create a Python script to push beads issues to Jira.\n\n**Requires**: Jira import script to be complete first (need external_ref matching logic working)\n\n**Features needed**:\n- Create new Jira issues from beads issues without external_ref\n- Update existing Jira issues matched by external_ref\n- Map beads fields back to Jira fields\n- Handle Jira workflow transitions (status changes may need transitions)\n- Support custom field mapping for design/acceptance_criteria/notes\n\n**Challenges**:\n- Jira status changes often require workflow transitions, not direct updates\n- Need to discover valid transitions via API\n- Custom fields vary by Jira instance\n\n**Usage**:\n```bash\nbd export | python jsonl2jira.py --create-only # Only create, don't update\nbd export | python jsonl2jira.py # Create and update\n```\n\n**After creation**: Sets external_ref on beads issue to link back","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-11-30T12:56:14.266357-08:00","updated_at":"2025-11-30T15:19:40.264737-08:00","closed_at":"2025-11-30T15:19:40.264737-08:00","dependencies":[{"issue_id":"bd-93d","depends_on_id":"bd-qvj","type":"parent-child","created_at":"2025-11-30T12:56:44.652391-08:00","created_by":"stevey"},{"issue_id":"bd-93d","depends_on_id":"bd-tjn","type":"blocks","created_at":"2025-11-30T12:56:54.941116-08:00","created_by":"stevey"}]} {"id":"bd-clg","title":"bd jira sync command","description":"Add a built-in bd command for Jira synchronization.\n\n**Requires**: Both import and export scripts working\n\n**Features**:\n- `bd jira sync --pull` - Import from Jira to beads\n- `bd jira sync --push` - Export from beads to Jira\n- `bd jira sync` - Bidirectional (pull then push, with conflict resolution)\n- `bd jira status` - Show sync status and last sync time\n\n**Conflict resolution**:\n- Timestamp-based: newer update wins\n- Option for --prefer-local or --prefer-jira to override\n- Interactive mode for manual conflict resolution (optional)\n\n**Integration**:\n- Uses jira.* config settings from bd config\n- Stores last sync timestamp in config\n- Logs sync activity for audit\n\n**Stretch goals**:\n- Webhook integration for real-time sync\n- Selective sync by JQL filter","status":"closed","priority":3,"issue_type":"feature","created_at":"2025-11-30T12:56:27.716537-08:00","updated_at":"2025-11-30T15:25:37.896045-08:00","closed_at":"2025-11-30T15:25:37.896045-08:00","dependencies":[{"issue_id":"bd-clg","depends_on_id":"bd-qvj","type":"parent-child","created_at":"2025-11-30T12:56:49.796568-08:00","created_by":"stevey"},{"issue_id":"bd-clg","depends_on_id":"bd-tjn","type":"blocks","created_at":"2025-11-30T12:57:00.075288-08:00","created_by":"stevey"},{"issue_id":"bd-clg","depends_on_id":"bd-93d","type":"blocks","created_at":"2025-11-30T12:57:05.206431-08:00","created_by":"stevey"}]} {"id":"bd-qvj","title":"Jira import/export integration","description":"Add ability to import issues from Jira and export beads issues to Jira. See GitHub discussion #430 for user request.\n\n**Background**: User @kaihendry configured jira.* settings but found bd sync doesn't sync with Jira - only with git. The config namespace exists but the integration doesn't.\n\n**Existing infrastructure**:\n- external_ref field for linking to external systems\n- Import logic matches by external_ref first (priority over ID matching)\n- Config namespace jira.* documented in CONFIG.md\n- GitHub import example (examples/github-import/gh2jsonl.py) provides pattern\n\n**Schema mapping challenges**:\n- Jira workflows are highly customizable (status mapping)\n- Jira has more field types (components, versions, custom fields)\n- Two-way sync needs conflict resolution\n- Jira Cloud vs Server/Data Center have different APIs\n\n**Approach**: Phased implementation starting with import, then export, then bidirectional sync.","status":"open","priority":2,"issue_type":"epic","created_at":"2025-11-30T12:55:44.835009-08:00","updated_at":"2025-11-30T12:55:44.835009-08:00"} +{"id":"bd-93d","title":"Jira export script (jsonl2jira.py)","description":"Create a Python script to push beads issues to Jira.\n\n**Requires**: Jira import script to be complete first (need external_ref matching logic working)\n\n**Features needed**:\n- Create new Jira issues from beads issues without external_ref\n- Update existing Jira issues matched by external_ref\n- Map beads fields back to Jira fields\n- Handle Jira workflow transitions (status changes may need transitions)\n- Support custom field mapping for design/acceptance_criteria/notes\n\n**Challenges**:\n- Jira status changes often require workflow transitions, not direct updates\n- Need to discover valid transitions via API\n- Custom fields vary by Jira instance\n\n**Usage**:\n```bash\nbd export | python jsonl2jira.py --create-only # Only create, don't update\nbd export | python jsonl2jira.py # Create and update\n```\n\n**After creation**: Sets external_ref on beads issue to link back","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-11-30T12:56:14.266357-08:00","updated_at":"2025-11-30T15:19:40.264737-08:00","closed_at":"2025-11-30T15:19:40.264737-08:00","dependencies":[{"issue_id":"bd-93d","depends_on_id":"bd-qvj","type":"parent-child","created_at":"2025-11-30T12:56:44.652391-08:00","created_by":"stevey"},{"issue_id":"bd-93d","depends_on_id":"bd-tjn","type":"blocks","created_at":"2025-11-30T12:56:54.941116-08:00","created_by":"stevey"}]} {"id":"bd-tjn","title":"Jira import script (jira2jsonl.py)","description":"Create a Python script to import Jira issues into beads JSONL format.\n\n**Pattern**: Follow examples/github-import/gh2jsonl.py\n\n**Features needed**:\n- Fetch issues via Jira REST API with JQL queries\n- Pagination handling (100 issues per request)\n- Map Jira fields to beads fields (see schema mapping in epic)\n- Set external_ref to Jira issue URL for re-sync\n- Support both Jira Cloud and Server/Data Center APIs\n- Read config from bd config (jira.url, jira.project, jira.api_token)\n\n**Config-driven mapping**:\n- jira.status_map.* for status conversion\n- jira.type_map.* for issue type conversion\n- jira.priority_map.* for priority conversion\n\n**Usage**:\n```bash\npython jira2jsonl.py --project PROJ | bd import\n# or\npython jira2jsonl.py --jql 'project=PROJ AND status\\!=Done' | bd import\n```\n\n**Output**: JSONL with external_ref set to Jira issue URL","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-11-30T12:55:59.517985-08:00","updated_at":"2025-11-30T15:11:56.692594-08:00","closed_at":"2025-11-30T15:11:56.692594-08:00","dependencies":[{"issue_id":"bd-tjn","depends_on_id":"bd-qvj","type":"parent-child","created_at":"2025-11-30T12:56:39.507643-08:00","created_by":"stevey"}]} diff --git a/internal/beads/beads.go b/internal/beads/beads.go index 9792a187..c90b0b3c 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -25,9 +25,74 @@ import ( // CanonicalDatabaseName is the required database filename for all beads repositories const CanonicalDatabaseName = "beads.db" +// RedirectFileName is the name of the file that redirects to another .beads directory +const RedirectFileName = "redirect" + // LegacyDatabaseNames are old names that should be migrated var LegacyDatabaseNames = []string{"bd.db", "issues.db", "bugs.db"} +// followRedirect checks if a .beads directory contains a redirect file and follows it. +// If a redirect file exists, it returns the target .beads directory path. +// If no redirect exists or there's an error, it returns the original path unchanged. +// +// The redirect file should contain a single path (relative or absolute) to the target +// .beads directory. Relative paths are resolved from the parent directory of the +// original .beads directory (i.e., the project root). +// +// Redirect chains are not followed - only one level of redirection is supported. +// This prevents infinite loops and keeps the behavior predictable. +func followRedirect(beadsDir string) string { + redirectFile := filepath.Join(beadsDir, RedirectFileName) + data, err := os.ReadFile(redirectFile) + if err != nil { + // No redirect file or can't read it - use original path + return beadsDir + } + + // Parse the redirect target (trim whitespace and handle comments) + target := strings.TrimSpace(string(data)) + + // Skip empty lines and comments to find the actual path + lines := strings.Split(target, "\n") + target = "" + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + target = line + break + } + } + + if target == "" { + return beadsDir + } + + // Resolve relative paths from the parent of the .beads directory (project root) + if !filepath.IsAbs(target) { + projectRoot := filepath.Dir(beadsDir) + target = filepath.Join(projectRoot, target) + } + + // Canonicalize the target path + target = utils.CanonicalizePath(target) + + // Verify the target exists and is a directory + info, err := os.Stat(target) + if err != nil || !info.IsDir() { + // Invalid redirect target - fall back to original + fmt.Fprintf(os.Stderr, "Warning: redirect target does not exist or is not a directory: %s\n", target) + return beadsDir + } + + // Prevent redirect chains - don't follow if target also has a redirect + targetRedirect := filepath.Join(target, RedirectFileName) + if _, err := os.Stat(targetRedirect); err == nil { + fmt.Fprintf(os.Stderr, "Warning: redirect chains not allowed, ignoring redirect in %s\n", target) + } + + return target +} + // findDatabaseInBeadsDir searches for a database file within a .beads directory. // It implements the standard search order: // 1. Check config.json first (single source of truth) @@ -200,6 +265,9 @@ func NewSQLiteStorage(ctx context.Context, dbPath string) (Storage, error) { // 2. $BEADS_DB environment variable (points directly to database file, deprecated) // 3. .beads/*.db in current directory or ancestors // +// Redirect files are supported: if a .beads/redirect file exists, its contents +// are used as the actual .beads directory path. +// // Returns empty string if no database is found. func FindDatabasePath() string { // 1. Check BEADS_DIR environment variable (preferred) @@ -207,6 +275,9 @@ func FindDatabasePath() string { // Canonicalize the path to prevent nested .beads directories absBeadsDir := utils.CanonicalizePath(beadsDir) + // Follow redirect if present + absBeadsDir = followRedirect(absBeadsDir) + // Use helper to find database (no warnings for BEADS_DIR - user explicitly set it) if dbPath := findDatabaseInBeadsDir(absBeadsDir, false); dbPath != "" { return dbPath @@ -269,11 +340,17 @@ func hasBeadsProjectFiles(beadsDir string) bool { // Returns empty string if not found. Supports both database and JSONL-only mode. // Stops at the git repository root to avoid finding unrelated directories (bd-c8x). // Validates that the directory contains actual project files (bd-420). +// Redirect files are supported: if a .beads/redirect file exists, its contents +// are used as the actual .beads directory path. // This is useful for commands that need to detect beads projects without requiring a database. func FindBeadsDir() string { // 1. Check BEADS_DIR environment variable (preferred) if beadsDir := os.Getenv("BEADS_DIR"); beadsDir != "" { absBeadsDir := utils.CanonicalizePath(beadsDir) + + // Follow redirect if present + absBeadsDir = followRedirect(absBeadsDir) + if info, err := os.Stat(absBeadsDir); err == nil && info.IsDir() { // Validate directory contains actual project files (bd-420) if hasBeadsProjectFiles(absBeadsDir) { @@ -294,6 +371,9 @@ func FindBeadsDir() string { for dir := cwd; dir != "/" && dir != "."; dir = filepath.Dir(dir) { beadsDir := filepath.Join(dir, ".beads") if info, err := os.Stat(beadsDir); err == nil && info.IsDir() { + // Follow redirect if present + beadsDir = followRedirect(beadsDir) + // Validate directory contains actual project files (bd-420) if hasBeadsProjectFiles(beadsDir) { return beadsDir @@ -345,7 +425,9 @@ func findGitRoot() string { // findDatabaseInTree walks up the directory tree looking for .beads/*.db // Stops at the git repository root to avoid finding unrelated databases (bd-c8x). -// Prefers config.json, falls back to beads.db, and warns if multiple .db files exist +// Prefers config.json, falls back to beads.db, and warns if multiple .db files exist. +// Redirect files are supported: if a .beads/redirect file exists, its contents +// are used as the actual .beads directory path. func findDatabaseInTree() string { dir, err := os.Getwd() if err != nil { @@ -365,6 +447,9 @@ func findDatabaseInTree() string { for { beadsDir := filepath.Join(dir, ".beads") if info, err := os.Stat(beadsDir); err == nil && info.IsDir() { + // Follow redirect if present + beadsDir = followRedirect(beadsDir) + // Use helper to find database (with warnings for auto-discovery) if dbPath := findDatabaseInBeadsDir(beadsDir, true); dbPath != "" { return dbPath @@ -393,6 +478,8 @@ func findDatabaseInTree() string { // Returns a slice of DatabaseInfo for each database found, starting from the // closest to CWD (most relevant) to the furthest (least relevant). // Stops at the git repository root to avoid finding unrelated databases (bd-c8x). +// Redirect files are supported: if a .beads/redirect file exists, its contents +// are used as the actual .beads directory path. func FindAllDatabases() []DatabaseInfo { databases := []DatabaseInfo{} // Initialize to empty slice, never return nil seen := make(map[string]bool) // Track canonical paths to avoid duplicates @@ -409,6 +496,9 @@ func FindAllDatabases() []DatabaseInfo { for { beadsDir := filepath.Join(dir, ".beads") if info, err := os.Stat(beadsDir); err == nil && info.IsDir() { + // Follow redirect if present + beadsDir = followRedirect(beadsDir) + // Found .beads/ directory, look for *.db files matches, err := filepath.Glob(filepath.Join(beadsDir, "*.db")) if err == nil && len(matches) > 0 { diff --git a/internal/beads/beads_test.go b/internal/beads/beads_test.go index d351b0f6..3cc187be 100644 --- a/internal/beads/beads_test.go +++ b/internal/beads/beads_test.go @@ -497,3 +497,315 @@ func TestFindDatabasePathHomeDefault(t *testing.T) { t.Errorf("Expected absolute path or empty string, got '%s'", result) } } + +// TestFollowRedirect tests the redirect file functionality +func TestFollowRedirect(t *testing.T) { + tests := []struct { + name string + setupFunc func(t *testing.T, tmpDir string) (stubDir, targetDir string) + expectRedirect bool + }{ + { + name: "no redirect file - returns original", + setupFunc: func(t *testing.T, tmpDir string) (string, string) { + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + return beadsDir, "" + }, + expectRedirect: false, + }, + { + name: "relative path redirect", + setupFunc: func(t *testing.T, tmpDir string) (string, string) { + // Create stub .beads with redirect + stubDir := filepath.Join(tmpDir, "project", ".beads") + if err := os.MkdirAll(stubDir, 0755); err != nil { + t.Fatal(err) + } + + // Create target .beads directory + targetDir := filepath.Join(tmpDir, "actual", ".beads") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(targetDir, "beads.db"), []byte{}, 0644); err != nil { + t.Fatal(err) + } + + // Write redirect file with relative path + redirectPath := filepath.Join(stubDir, "redirect") + if err := os.WriteFile(redirectPath, []byte("../actual/.beads\n"), 0644); err != nil { + t.Fatal(err) + } + + return stubDir, targetDir + }, + expectRedirect: true, + }, + { + name: "absolute path redirect", + setupFunc: func(t *testing.T, tmpDir string) (string, string) { + // Create stub .beads with redirect + stubDir := filepath.Join(tmpDir, "project", ".beads") + if err := os.MkdirAll(stubDir, 0755); err != nil { + t.Fatal(err) + } + + // Create target .beads directory + targetDir := filepath.Join(tmpDir, "actual", ".beads") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(targetDir, "beads.db"), []byte{}, 0644); err != nil { + t.Fatal(err) + } + + // Write redirect file with absolute path + redirectPath := filepath.Join(stubDir, "redirect") + if err := os.WriteFile(redirectPath, []byte(targetDir+"\n"), 0644); err != nil { + t.Fatal(err) + } + + return stubDir, targetDir + }, + expectRedirect: true, + }, + { + name: "redirect with comments", + setupFunc: func(t *testing.T, tmpDir string) (string, string) { + // Create stub .beads with redirect + stubDir := filepath.Join(tmpDir, "project", ".beads") + if err := os.MkdirAll(stubDir, 0755); err != nil { + t.Fatal(err) + } + + // Create target .beads directory + targetDir := filepath.Join(tmpDir, "actual", ".beads") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(targetDir, "beads.db"), []byte{}, 0644); err != nil { + t.Fatal(err) + } + + // Write redirect file with comments + redirectPath := filepath.Join(stubDir, "redirect") + content := "# Redirect to actual beads location\n# This is a workspace redirect\n" + targetDir + "\n" + if err := os.WriteFile(redirectPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + return stubDir, targetDir + }, + expectRedirect: true, + }, + { + name: "redirect to non-existent directory - returns original", + setupFunc: func(t *testing.T, tmpDir string) (string, string) { + stubDir := filepath.Join(tmpDir, "project", ".beads") + if err := os.MkdirAll(stubDir, 0755); err != nil { + t.Fatal(err) + } + + // Write redirect to non-existent path + redirectPath := filepath.Join(stubDir, "redirect") + if err := os.WriteFile(redirectPath, []byte("/nonexistent/path/.beads\n"), 0644); err != nil { + t.Fatal(err) + } + + return stubDir, "" + }, + expectRedirect: false, // Should fall back to original + }, + { + name: "empty redirect file - returns original", + setupFunc: func(t *testing.T, tmpDir string) (string, string) { + stubDir := filepath.Join(tmpDir, "project", ".beads") + if err := os.MkdirAll(stubDir, 0755); err != nil { + t.Fatal(err) + } + + // Write empty redirect file + redirectPath := filepath.Join(stubDir, "redirect") + if err := os.WriteFile(redirectPath, []byte(""), 0644); err != nil { + t.Fatal(err) + } + + return stubDir, "" + }, + expectRedirect: false, + }, + { + name: "redirect file with only comments - returns original", + setupFunc: func(t *testing.T, tmpDir string) (string, string) { + stubDir := filepath.Join(tmpDir, "project", ".beads") + if err := os.MkdirAll(stubDir, 0755); err != nil { + t.Fatal(err) + } + + // Write redirect file with only comments + redirectPath := filepath.Join(stubDir, "redirect") + if err := os.WriteFile(redirectPath, []byte("# Just a comment\n# Another comment\n"), 0644); err != nil { + t.Fatal(err) + } + + return stubDir, "" + }, + expectRedirect: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "beads-redirect-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + stubDir, targetDir := tt.setupFunc(t, tmpDir) + + result := followRedirect(stubDir) + + // Resolve symlinks for comparison (macOS uses /private/var) + resultResolved, _ := filepath.EvalSymlinks(result) + stubResolved, _ := filepath.EvalSymlinks(stubDir) + + if tt.expectRedirect { + targetResolved, _ := filepath.EvalSymlinks(targetDir) + if resultResolved != targetResolved { + t.Errorf("followRedirect() = %q, want %q", result, targetDir) + } + } else { + if resultResolved != stubResolved { + t.Errorf("followRedirect() = %q, want original %q", result, stubDir) + } + } + }) + } +} + +// TestFindDatabasePathWithRedirect tests that FindDatabasePath follows redirects +func TestFindDatabasePathWithRedirect(t *testing.T) { + // Save original state + originalEnv := os.Getenv("BEADS_DIR") + originalWd, _ := os.Getwd() + defer func() { + if originalEnv != "" { + os.Setenv("BEADS_DIR", originalEnv) + } else { + os.Unsetenv("BEADS_DIR") + } + os.Chdir(originalWd) + }() + os.Unsetenv("BEADS_DIR") + + // Create temp directory structure + tmpDir, err := os.MkdirTemp("", "beads-redirect-finddb-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Create stub .beads with redirect + stubDir := filepath.Join(tmpDir, "project", ".beads") + if err := os.MkdirAll(stubDir, 0755); err != nil { + t.Fatal(err) + } + + // Create target .beads directory with actual database + targetDir := filepath.Join(tmpDir, "actual", ".beads") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + targetDB := filepath.Join(targetDir, "beads.db") + if err := os.WriteFile(targetDB, []byte{}, 0644); err != nil { + t.Fatal(err) + } + + // Write redirect file + redirectPath := filepath.Join(stubDir, "redirect") + if err := os.WriteFile(redirectPath, []byte("../actual/.beads\n"), 0644); err != nil { + t.Fatal(err) + } + + // Change to project directory + projectDir := filepath.Join(tmpDir, "project") + if err := os.Chdir(projectDir); err != nil { + t.Fatal(err) + } + + // FindDatabasePath should follow the redirect + result := FindDatabasePath() + + // Resolve symlinks for comparison + resultResolved, _ := filepath.EvalSymlinks(result) + targetDBResolved, _ := filepath.EvalSymlinks(targetDB) + + if resultResolved != targetDBResolved { + t.Errorf("FindDatabasePath() = %q, want %q (via redirect)", result, targetDB) + } +} + +// TestFindBeadsDirWithRedirect tests that FindBeadsDir follows redirects +func TestFindBeadsDirWithRedirect(t *testing.T) { + // Save original state + originalEnv := os.Getenv("BEADS_DIR") + originalWd, _ := os.Getwd() + defer func() { + if originalEnv != "" { + os.Setenv("BEADS_DIR", originalEnv) + } else { + os.Unsetenv("BEADS_DIR") + } + os.Chdir(originalWd) + }() + os.Unsetenv("BEADS_DIR") + + // Create temp directory structure + tmpDir, err := os.MkdirTemp("", "beads-redirect-finddir-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Create stub .beads with redirect + stubDir := filepath.Join(tmpDir, "project", ".beads") + if err := os.MkdirAll(stubDir, 0755); err != nil { + t.Fatal(err) + } + + // Create target .beads directory with project files + targetDir := filepath.Join(tmpDir, "actual", ".beads") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(targetDir, "issues.jsonl"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + // Write redirect file + redirectPath := filepath.Join(stubDir, "redirect") + if err := os.WriteFile(redirectPath, []byte("../actual/.beads\n"), 0644); err != nil { + t.Fatal(err) + } + + // Change to project directory + projectDir := filepath.Join(tmpDir, "project") + if err := os.Chdir(projectDir); err != nil { + t.Fatal(err) + } + + // FindBeadsDir should follow the redirect + result := FindBeadsDir() + + // Resolve symlinks for comparison + resultResolved, _ := filepath.EvalSymlinks(result) + targetDirResolved, _ := filepath.EvalSymlinks(targetDir) + + if resultResolved != targetDirResolved { + t.Errorf("FindBeadsDir() = %q, want %q (via redirect)", result, targetDir) + } +}