diff --git a/internal/workspace/find.go b/internal/workspace/find.go index 8d817177..471eceaf 100644 --- a/internal/workspace/find.go +++ b/internal/workspace/find.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/steveyegge/gastown/internal/config" ) @@ -26,54 +27,49 @@ const ( ) // Find locates the town root by walking up from the given directory. -// It looks for mayor/town.json (primary marker) or mayor/ directory (secondary marker). -// -// To avoid matching rig-level mayor directories, we continue searching -// upward after finding a secondary marker, preferring primary matches. +// It prefers mayor/town.json over mayor/ directory as workspace marker. +// When in a worktree path (polecats/ or crew/), continues to outermost workspace. +// Does not resolve symlinks to stay consistent with os.Getwd(). func Find(startDir string) (string, error) { - // Resolve to absolute path and follow symlinks absDir, err := filepath.Abs(startDir) if err != nil { return "", fmt.Errorf("resolving path: %w", err) } - absDir, err = filepath.EvalSymlinks(absDir) - if err != nil { - return "", fmt.Errorf("evaluating symlinks: %w", err) - } + inWorktree := isInWorktreePath(absDir) + var primaryMatch, secondaryMatch string - // Track the first secondary match in case no primary is found - var secondaryMatch string - - // Walk up the directory tree current := absDir for { - // Check for primary marker (mayor/town.json) - primaryPath := filepath.Join(current, PrimaryMarker) - if _, err := os.Stat(primaryPath); err == nil { - return current, nil + if _, err := os.Stat(filepath.Join(current, PrimaryMarker)); err == nil { + if !inWorktree { + return current, nil + } + primaryMatch = current } - // Check for secondary marker (mayor/ directory) - // Don't return immediately - continue searching for primary markers if secondaryMatch == "" { - secondaryPath := filepath.Join(current, SecondaryMarker) - info, err := os.Stat(secondaryPath) - if err == nil && info.IsDir() { + if info, err := os.Stat(filepath.Join(current, SecondaryMarker)); err == nil && info.IsDir() { secondaryMatch = current } } - // Move to parent directory parent := filepath.Dir(current) if parent == current { - // Reached filesystem root - return secondary match if found + if primaryMatch != "" { + return primaryMatch, nil + } return secondaryMatch, nil } current = parent } } +func isInWorktreePath(path string) bool { + sep := string(filepath.Separator) + return strings.Contains(path, sep+"polecats"+sep) || strings.Contains(path, sep+"crew"+sep) +} + // FindOrError is like Find but returns a user-friendly error if not found. func FindOrError(startDir string) (string, error) { root, err := Find(startDir) diff --git a/internal/workspace/find_test.go b/internal/workspace/find_test.go index 6b71745a..504e0dfd 100644 --- a/internal/workspace/find_test.go +++ b/internal/workspace/find_test.go @@ -143,8 +143,7 @@ func TestIsWorkspace(t *testing.T) { } } -func TestFindFollowsSymlinks(t *testing.T) { - // Create workspace +func TestFindFromSymlinkedDir(t *testing.T) { root := realPath(t, t.TempDir()) mayorDir := filepath.Join(root, "mayor") if err := os.MkdirAll(mayorDir, 0755); err != nil { @@ -155,7 +154,6 @@ func TestFindFollowsSymlinks(t *testing.T) { t.Fatalf("write: %v", err) } - // Create a symlinked directory linkTarget := filepath.Join(root, "actual") if err := os.MkdirAll(linkTarget, 0755); err != nil { t.Fatalf("mkdir: %v", err) @@ -166,7 +164,6 @@ func TestFindFollowsSymlinks(t *testing.T) { t.Skipf("symlink not supported: %v", err) } - // Find from symlinked dir should work found, err := Find(linkName) if err != nil { t.Fatalf("Find: %v", err) @@ -175,3 +172,109 @@ func TestFindFollowsSymlinks(t *testing.T) { t.Errorf("Find = %q, want %q", found, root) } } + +func TestFindPreservesSymlinkPath(t *testing.T) { + realRoot := t.TempDir() + resolved, err := filepath.EvalSymlinks(realRoot) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + + symRoot := filepath.Join(t.TempDir(), "symlink-workspace") + if err := os.Symlink(resolved, symRoot); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + mayorDir := filepath.Join(symRoot, "mayor") + if err := os.MkdirAll(mayorDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + townFile := filepath.Join(mayorDir, "town.json") + if err := os.WriteFile(townFile, []byte(`{}`), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + subdir := filepath.Join(symRoot, "rigs", "project", "polecats", "worker") + if err := os.MkdirAll(subdir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + townRoot, err := Find(subdir) + if err != nil { + t.Fatalf("Find: %v", err) + } + + if townRoot != symRoot { + t.Errorf("Find returned %q, want %q (symlink path preserved)", townRoot, symRoot) + } + + relPath, err := filepath.Rel(townRoot, subdir) + if err != nil { + t.Fatalf("Rel: %v", err) + } + + if relPath != "rigs/project/polecats/worker" { + t.Errorf("Rel = %q, want 'rigs/project/polecats/worker'", relPath) + } +} + +func TestFindSkipsNestedWorkspaceInWorktree(t *testing.T) { + root := realPath(t, t.TempDir()) + + if err := os.MkdirAll(filepath.Join(root, "mayor"), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "mayor", "town.json"), []byte(`{"name":"outer"}`), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + polecatDir := filepath.Join(root, "myrig", "polecats", "worker") + if err := os.MkdirAll(filepath.Join(polecatDir, "mayor"), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(polecatDir, "mayor", "town.json"), []byte(`{"name":"inner"}`), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + found, err := Find(polecatDir) + if err != nil { + t.Fatalf("Find: %v", err) + } + + if found != root { + t.Errorf("Find = %q, want %q (should skip nested workspace in polecats/)", found, root) + } + + rel, _ := filepath.Rel(found, polecatDir) + if rel != "myrig/polecats/worker" { + t.Errorf("Rel = %q, want 'myrig/polecats/worker'", rel) + } +} + +func TestFindSkipsNestedWorkspaceInCrew(t *testing.T) { + root := realPath(t, t.TempDir()) + + if err := os.MkdirAll(filepath.Join(root, "mayor"), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "mayor", "town.json"), []byte(`{"name":"outer"}`), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + crewDir := filepath.Join(root, "myrig", "crew", "worker") + if err := os.MkdirAll(filepath.Join(crewDir, "mayor"), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(crewDir, "mayor", "town.json"), []byte(`{"name":"inner"}`), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + found, err := Find(crewDir) + if err != nil { + t.Fatalf("Find: %v", err) + } + + if found != root { + t.Errorf("Find = %q, want %q (should skip nested workspace in crew/)", found, root) + } +}