Files
gastown/internal/workspace/find.go
dementus f9ca7bb87b fix(done): handle getcwd errors when worktree deleted (hq-3xaxy)
gt done now completes successfully even if the polecat's worktree is
deleted mid-operation by the Witness or another process.

Changes:
- Add FindFromCwdWithFallback() that returns townRoot from GT_TOWN_ROOT
  env var when getcwd fails
- Update runDone() to use fallback paths and env vars (GT_BRANCH,
  GT_POLECAT) when cwd is unavailable
- Update updateAgentStateOnDone() to use env vars (GT_ROLE, GT_RIG,
  GT_POLECAT) for role detection fallback
- All bead operations are now explicitly non-fatal with warnings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 00:17:59 -08:00

191 lines
5.7 KiB
Go

// Package workspace provides workspace detection and management.
package workspace
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/steveyegge/gastown/internal/config"
)
// 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.
// The town.json file lives in mayor/ along with other mayor config.
PrimaryMarker = "mayor/town.json"
// SecondaryMarker is an alternative indicator at the town level.
// Note: This can match rig-level mayors too, so we continue searching
// upward after finding this to look for primary markers.
SecondaryMarker = "mayor"
)
// Find locates the town root by walking up from the given directory.
// 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) {
absDir, err := filepath.Abs(startDir)
if err != nil {
return "", fmt.Errorf("resolving path: %w", err)
}
inWorktree := isInWorktreePath(absDir)
var primaryMatch, secondaryMatch string
current := absDir
for {
if _, err := os.Stat(filepath.Join(current, PrimaryMarker)); err == nil {
if !inWorktree {
return current, nil
}
primaryMatch = current
}
if secondaryMatch == "" {
if info, err := os.Stat(filepath.Join(current, SecondaryMarker)); err == nil && info.IsDir() {
secondaryMatch = current
}
}
parent := filepath.Dir(current)
if parent == current {
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)
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.
// If getcwd fails (e.g., worktree deleted), falls back to GT_TOWN_ROOT env var.
func FindFromCwdOrError() (string, error) {
cwd, err := os.Getwd()
if err != nil {
// Fallback: try GT_TOWN_ROOT env var (set by polecat sessions)
if townRoot := os.Getenv("GT_TOWN_ROOT"); townRoot != "" {
// Verify it's actually a workspace
if _, statErr := os.Stat(filepath.Join(townRoot, PrimaryMarker)); statErr == nil {
return townRoot, nil
}
}
return "", fmt.Errorf("getting current directory: %w", err)
}
return FindOrError(cwd)
}
// FindFromCwdWithFallback is like FindFromCwdOrError but returns (townRoot, cwd, error).
// If getcwd fails, returns (townRoot, "", nil) using GT_TOWN_ROOT fallback.
// This is useful for commands like `gt done` that need to continue even if the
// working directory is deleted (e.g., polecat worktree nuked by Witness).
func FindFromCwdWithFallback() (townRoot string, cwd string, err error) {
cwd, err = os.Getwd()
if err != nil {
// Fallback: try GT_TOWN_ROOT env var
if townRoot = os.Getenv("GT_TOWN_ROOT"); townRoot != "" {
// Verify it's actually a workspace
if _, statErr := os.Stat(filepath.Join(townRoot, PrimaryMarker)); statErr == nil {
return townRoot, "", nil // cwd is gone but townRoot is valid
}
}
return "", "", fmt.Errorf("getting current directory: %w", err)
}
townRoot, err = FindOrError(cwd)
if err != nil {
return "", "", err
}
return townRoot, cwd, nil
}
// IsWorkspace checks if the given directory is a Gas Town workspace root.
// A directory is a workspace if it has a primary marker (mayor/town.json)
// or a secondary marker (mayor/ directory).
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 (mayor/town.json)
primaryPath := filepath.Join(absDir, PrimaryMarker)
if _, err := os.Stat(primaryPath); err == nil {
return true, nil
}
// Check for secondary marker (mayor/ directory)
secondaryPath := filepath.Join(absDir, SecondaryMarker)
info, err := os.Stat(secondaryPath)
if err == nil && info.IsDir() {
return true, nil
}
return false, nil
}
// GetTownName loads the town name from the workspace's town.json config.
// This is used for generating unique tmux session names that avoid collisions
// when running multiple Gas Town instances.
func GetTownName(townRoot string) (string, error) {
townConfigPath := filepath.Join(townRoot, PrimaryMarker)
townConfig, err := config.LoadTownConfig(townConfigPath)
if err != nil {
return "", fmt.Errorf("loading town config: %w", err)
}
return townConfig.Name, nil
}
// GetTownNameFromCwd locates the town root from the current working directory
// and returns the town name from its configuration.
func GetTownNameFromCwd() (string, error) {
townRoot, err := FindFromCwdOrError()
if err != nil {
return "", err
}
return GetTownName(townRoot)
}
// MustGetTownName returns the town name or panics if it cannot be loaded.
// Use sparingly - prefer GetTownName with proper error handling.
func MustGetTownName(townRoot string) string {
name, err := GetTownName(townRoot)
if err != nil {
panic(fmt.Sprintf("failed to get town name: %v", err))
}
return name
}