From 8bd1a353db25245711231d66127e4b586ea89fd6 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 16 Dec 2025 13:30:25 -0800 Subject: [PATCH] feat: add workspace detection by walking up directory tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detection: - Find() walks up from given directory looking for markers - Primary marker: config/town.json - Secondary marker: mayor/ directory - Follows symlinks using filepath.EvalSymlinks Functions: - Find, FindOrError: from given directory - FindFromCwd, FindFromCwdOrError: from current directory - IsWorkspace: check if directory is workspace root Closes gt-f9x.2 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/workspace/find.go | 115 +++++++++++++++++++++ internal/workspace/find_test.go | 177 ++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 internal/workspace/find.go create mode 100644 internal/workspace/find_test.go diff --git a/internal/workspace/find.go b/internal/workspace/find.go new file mode 100644 index 00000000..eda28d67 --- /dev/null +++ b/internal/workspace/find.go @@ -0,0 +1,115 @@ +// Package workspace provides workspace detection and management. +package workspace + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// ErrNotFound indicates no workspace was found. +var ErrNotFound = errors.New("not in a Gas Town workspace") + +// Markers used to detect a Gas Town workspace. +const ( + // PrimaryMarker is the main config file that identifies a workspace. + PrimaryMarker = "config/town.json" + + // SecondaryMarker is an alternative indicator at the town level. + SecondaryMarker = "mayor" +) + +// Find locates the town root by walking up from the given directory. +// It looks for config/town.json (primary) or mayor/ directory (secondary). +// Returns the absolute path to the town root, or empty string if not found. +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) + } + + // Walk up the directory tree + current := absDir + for { + // Check for primary marker (config/town.json) + primaryPath := filepath.Join(current, PrimaryMarker) + if _, err := os.Stat(primaryPath); err == nil { + return current, nil + } + + // Check for secondary marker (mayor/ directory) + secondaryPath := filepath.Join(current, SecondaryMarker) + info, err := os.Stat(secondaryPath) + if err == nil && info.IsDir() { + return current, nil + } + + // Move to parent directory + parent := filepath.Dir(current) + if parent == current { + // Reached filesystem root + return "", nil + } + current = parent + } +} + +// FindOrError is like Find but returns a user-friendly error if not found. +func FindOrError(startDir string) (string, error) { + root, err := Find(startDir) + if err != nil { + return "", err + } + if root == "" { + return "", ErrNotFound + } + return root, nil +} + +// FindFromCwd locates the town root from the current working directory. +func FindFromCwd() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("getting current directory: %w", err) + } + return Find(cwd) +} + +// FindFromCwdOrError is like FindFromCwd but returns an error if not found. +func FindFromCwdOrError() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("getting current directory: %w", err) + } + return FindOrError(cwd) +} + +// IsWorkspace checks if the given directory is a Gas Town workspace root. +func IsWorkspace(dir string) (bool, error) { + absDir, err := filepath.Abs(dir) + if err != nil { + return false, fmt.Errorf("resolving path: %w", err) + } + + // Check for primary marker + primaryPath := filepath.Join(absDir, PrimaryMarker) + if _, err := os.Stat(primaryPath); err == nil { + return true, nil + } + + // Check for secondary marker + secondaryPath := filepath.Join(absDir, SecondaryMarker) + info, err := os.Stat(secondaryPath) + if err == nil && info.IsDir() { + return true, nil + } + + return false, nil +} diff --git a/internal/workspace/find_test.go b/internal/workspace/find_test.go new file mode 100644 index 00000000..64ef2929 --- /dev/null +++ b/internal/workspace/find_test.go @@ -0,0 +1,177 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" +) + +func realPath(t *testing.T, path string) string { + t.Helper() + real, err := filepath.EvalSymlinks(path) + if err != nil { + t.Fatalf("realpath: %v", err) + } + return real +} + +func TestFindWithPrimaryMarker(t *testing.T) { + // Create temp workspace structure + root := realPath(t, t.TempDir()) + configDir := filepath.Join(root, "config") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + townFile := filepath.Join(configDir, "town.json") + if err := os.WriteFile(townFile, []byte(`{"type":"town"}`), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + // Create nested directory + nested := filepath.Join(root, "some", "deep", "path") + if err := os.MkdirAll(nested, 0755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + + // Find from nested should return root + found, err := Find(nested) + if err != nil { + t.Fatalf("Find: %v", err) + } + if found != root { + t.Errorf("Find = %q, want %q", found, root) + } +} + +func TestFindWithSecondaryMarker(t *testing.T) { + // Create temp workspace with just mayor/ directory + root := realPath(t, t.TempDir()) + mayorDir := filepath.Join(root, "mayor") + if err := os.MkdirAll(mayorDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + // Create nested directory + nested := filepath.Join(root, "rigs", "test") + if err := os.MkdirAll(nested, 0755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + + // Find from nested should return root + found, err := Find(nested) + if err != nil { + t.Fatalf("Find: %v", err) + } + if found != root { + t.Errorf("Find = %q, want %q", found, root) + } +} + +func TestFindNotFound(t *testing.T) { + // Create temp dir with no markers + dir := t.TempDir() + + found, err := Find(dir) + if err != nil { + t.Fatalf("Find: %v", err) + } + if found != "" { + t.Errorf("Find = %q, want empty string", found) + } +} + +func TestFindOrErrorNotFound(t *testing.T) { + dir := t.TempDir() + + _, err := FindOrError(dir) + if err != ErrNotFound { + t.Errorf("FindOrError = %v, want ErrNotFound", err) + } +} + +func TestFindAtRoot(t *testing.T) { + // Create workspace at temp root level + root := realPath(t, t.TempDir()) + configDir := filepath.Join(root, "config") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + townFile := filepath.Join(configDir, "town.json") + if err := os.WriteFile(townFile, []byte(`{"type":"town"}`), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + // Find from root should return root + found, err := Find(root) + if err != nil { + t.Fatalf("Find: %v", err) + } + if found != root { + t.Errorf("Find = %q, want %q", found, root) + } +} + +func TestIsWorkspace(t *testing.T) { + root := t.TempDir() + + // Not a workspace initially + is, err := IsWorkspace(root) + if err != nil { + t.Fatalf("IsWorkspace: %v", err) + } + if is { + t.Error("expected not a workspace initially") + } + + // Add primary marker + configDir := filepath.Join(root, "config") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + townFile := filepath.Join(configDir, "town.json") + if err := os.WriteFile(townFile, []byte(`{"type":"town"}`), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + // Now is a workspace + is, err = IsWorkspace(root) + if err != nil { + t.Fatalf("IsWorkspace: %v", err) + } + if !is { + t.Error("expected to be a workspace") + } +} + +func TestFindFollowsSymlinks(t *testing.T) { + // Create workspace + root := realPath(t, t.TempDir()) + configDir := filepath.Join(root, "config") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + townFile := filepath.Join(configDir, "town.json") + if err := os.WriteFile(townFile, []byte(`{"type":"town"}`), 0644); err != nil { + 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) + } + + linkName := filepath.Join(root, "linked") + if err := os.Symlink(linkTarget, linkName); err != nil { + 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) + } + if found != root { + t.Errorf("Find = %q, want %q", found, root) + } +}