fix: preserve symlink paths in workspace detection (#3) (#75)

Fixes nested workspace detection and symlink path issues in workspace.Find()

- Remove filepath.EvalSymlinks() for consistency with os.Getwd()
- Add isInWorktreePath() to detect polecats/crew directories
- Continue walking up to outermost workspace when in worktree paths
- Add integration tests for symlink and nested workspace scenarios
This commit is contained in:
Subhrajit Makur
2026-01-04 05:40:53 +05:30
committed by GitHub
parent 29058f321c
commit 62848065e3
2 changed files with 127 additions and 28 deletions

View File

@@ -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)

View File

@@ -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)
}
}