feat: add workspace detection by walking up directory tree
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 <noreply@anthropic.com>
This commit is contained in:
115
internal/workspace/find.go
Normal file
115
internal/workspace/find.go
Normal file
@@ -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
|
||||
}
|
||||
177
internal/workspace/find_test.go
Normal file
177
internal/workspace/find_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user