fix(handoff): use env var fallback when town root detection fails
When the repo is in a broken state (wrong branch, detached HEAD, deleted worktree), gt handoff would fail with "cannot detect town root" error. This is exactly when handoff is most needed - to recover and hand off to a fresh session. Changes: - detectTownRootFromCwd() now falls back to GT_TOWN_ROOT and GT_ROOT environment variables when cwd-based detection fails - buildRestartCommand() now propagates GT_ROOT to ensure subsequent handoffs can also use the fallback - Added tests for the fallback behavior Fixes gt-x2q81. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -411,6 +411,10 @@ func buildRestartCommand(sessionName string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Propagate GT_ROOT so subsequent handoffs can use it as fallback
|
||||||
|
// when cwd-based detection fails (broken state recovery)
|
||||||
|
exports = append(exports, "GT_ROOT="+townRoot)
|
||||||
|
|
||||||
// Preserve GT_AGENT across handoff so agent override persists
|
// Preserve GT_AGENT across handoff so agent override persists
|
||||||
if currentAgent != "" {
|
if currentAgent != "" {
|
||||||
exports = append(exports, "GT_AGENT="+currentAgent)
|
exports = append(exports, "GT_AGENT="+currentAgent)
|
||||||
@@ -498,14 +502,33 @@ func sessionToGTRole(sessionName string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// detectTownRootFromCwd walks up from the current directory to find the town root.
|
// detectTownRootFromCwd walks up from the current directory to find the town root.
|
||||||
|
// Falls back to GT_TOWN_ROOT or GT_ROOT env vars if cwd detection fails (broken state recovery).
|
||||||
func detectTownRootFromCwd() string {
|
func detectTownRootFromCwd() string {
|
||||||
// Use workspace.FindFromCwd which handles both primary (mayor/town.json)
|
// Use workspace.FindFromCwd which handles both primary (mayor/town.json)
|
||||||
// and secondary (mayor/ directory) markers
|
// and secondary (mayor/ directory) markers
|
||||||
townRoot, err := workspace.FindFromCwd()
|
townRoot, err := workspace.FindFromCwd()
|
||||||
if err != nil {
|
if err == nil && townRoot != "" {
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return townRoot
|
return townRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try environment variables for town root
|
||||||
|
// GT_TOWN_ROOT is set by shell integration, GT_ROOT is set by session manager
|
||||||
|
// This enables handoff to work even when cwd detection fails due to
|
||||||
|
// detached HEAD, wrong branch, deleted worktree, etc.
|
||||||
|
for _, envName := range []string{"GT_TOWN_ROOT", "GT_ROOT"} {
|
||||||
|
if envRoot := os.Getenv(envName); envRoot != "" {
|
||||||
|
// Verify it's actually a workspace
|
||||||
|
if _, statErr := os.Stat(filepath.Join(envRoot, workspace.PrimaryMarker)); statErr == nil {
|
||||||
|
return envRoot
|
||||||
|
}
|
||||||
|
// Try secondary marker too
|
||||||
|
if info, statErr := os.Stat(filepath.Join(envRoot, workspace.SecondaryMarker)); statErr == nil && info.IsDir() {
|
||||||
|
return envRoot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// handoffRemoteSession respawns a different session and optionally switches to it.
|
// handoffRemoteSession respawns a different session and optionally switches to it.
|
||||||
|
|||||||
124
internal/cmd/handoff_test.go
Normal file
124
internal/cmd/handoff_test.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetectTownRootFromCwd_EnvFallback(t *testing.T) {
|
||||||
|
// Save original env vars and restore after test
|
||||||
|
origTownRoot := os.Getenv("GT_TOWN_ROOT")
|
||||||
|
origRoot := os.Getenv("GT_ROOT")
|
||||||
|
defer func() {
|
||||||
|
os.Setenv("GT_TOWN_ROOT", origTownRoot)
|
||||||
|
os.Setenv("GT_ROOT", origRoot)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create a temp directory that looks like a valid town
|
||||||
|
tmpTown := t.TempDir()
|
||||||
|
mayorDir := filepath.Join(tmpTown, "mayor")
|
||||||
|
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||||
|
t.Fatalf("creating mayor dir: %v", err)
|
||||||
|
}
|
||||||
|
townJSON := filepath.Join(mayorDir, "town.json")
|
||||||
|
if err := os.WriteFile(townJSON, []byte(`{"name": "test-town"}`), 0644); err != nil {
|
||||||
|
t.Fatalf("creating town.json: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear both env vars initially
|
||||||
|
os.Setenv("GT_TOWN_ROOT", "")
|
||||||
|
os.Setenv("GT_ROOT", "")
|
||||||
|
|
||||||
|
t.Run("uses GT_TOWN_ROOT when cwd detection fails", func(t *testing.T) {
|
||||||
|
// Set GT_TOWN_ROOT to our temp town
|
||||||
|
os.Setenv("GT_TOWN_ROOT", tmpTown)
|
||||||
|
os.Setenv("GT_ROOT", "")
|
||||||
|
|
||||||
|
// Save cwd, cd to a non-town directory, and restore after
|
||||||
|
origCwd, _ := os.Getwd()
|
||||||
|
os.Chdir(os.TempDir())
|
||||||
|
defer os.Chdir(origCwd)
|
||||||
|
|
||||||
|
result := detectTownRootFromCwd()
|
||||||
|
if result != tmpTown {
|
||||||
|
t.Errorf("detectTownRootFromCwd() = %q, want %q (should use GT_TOWN_ROOT fallback)", result, tmpTown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses GT_ROOT when GT_TOWN_ROOT not set", func(t *testing.T) {
|
||||||
|
// Set only GT_ROOT
|
||||||
|
os.Setenv("GT_TOWN_ROOT", "")
|
||||||
|
os.Setenv("GT_ROOT", tmpTown)
|
||||||
|
|
||||||
|
// Save cwd, cd to a non-town directory, and restore after
|
||||||
|
origCwd, _ := os.Getwd()
|
||||||
|
os.Chdir(os.TempDir())
|
||||||
|
defer os.Chdir(origCwd)
|
||||||
|
|
||||||
|
result := detectTownRootFromCwd()
|
||||||
|
if result != tmpTown {
|
||||||
|
t.Errorf("detectTownRootFromCwd() = %q, want %q (should use GT_ROOT fallback)", result, tmpTown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("prefers GT_TOWN_ROOT over GT_ROOT", func(t *testing.T) {
|
||||||
|
// Create another temp town for GT_ROOT
|
||||||
|
anotherTown := t.TempDir()
|
||||||
|
anotherMayor := filepath.Join(anotherTown, "mayor")
|
||||||
|
os.MkdirAll(anotherMayor, 0755)
|
||||||
|
os.WriteFile(filepath.Join(anotherMayor, "town.json"), []byte(`{"name": "other-town"}`), 0644)
|
||||||
|
|
||||||
|
// Set both env vars
|
||||||
|
os.Setenv("GT_TOWN_ROOT", tmpTown)
|
||||||
|
os.Setenv("GT_ROOT", anotherTown)
|
||||||
|
|
||||||
|
// Save cwd, cd to a non-town directory, and restore after
|
||||||
|
origCwd, _ := os.Getwd()
|
||||||
|
os.Chdir(os.TempDir())
|
||||||
|
defer os.Chdir(origCwd)
|
||||||
|
|
||||||
|
result := detectTownRootFromCwd()
|
||||||
|
if result != tmpTown {
|
||||||
|
t.Errorf("detectTownRootFromCwd() = %q, want %q (should prefer GT_TOWN_ROOT)", result, tmpTown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ignores invalid GT_TOWN_ROOT", func(t *testing.T) {
|
||||||
|
// Set GT_TOWN_ROOT to non-existent path, GT_ROOT to valid
|
||||||
|
os.Setenv("GT_TOWN_ROOT", "/nonexistent/path/to/town")
|
||||||
|
os.Setenv("GT_ROOT", tmpTown)
|
||||||
|
|
||||||
|
// Save cwd, cd to a non-town directory, and restore after
|
||||||
|
origCwd, _ := os.Getwd()
|
||||||
|
os.Chdir(os.TempDir())
|
||||||
|
defer os.Chdir(origCwd)
|
||||||
|
|
||||||
|
result := detectTownRootFromCwd()
|
||||||
|
if result != tmpTown {
|
||||||
|
t.Errorf("detectTownRootFromCwd() = %q, want %q (should skip invalid GT_TOWN_ROOT and use GT_ROOT)", result, tmpTown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses secondary marker when primary missing", func(t *testing.T) {
|
||||||
|
// Create a temp town with only mayor/ directory (no town.json)
|
||||||
|
secondaryTown := t.TempDir()
|
||||||
|
mayorOnlyDir := filepath.Join(secondaryTown, workspace.SecondaryMarker)
|
||||||
|
os.MkdirAll(mayorOnlyDir, 0755)
|
||||||
|
|
||||||
|
os.Setenv("GT_TOWN_ROOT", secondaryTown)
|
||||||
|
os.Setenv("GT_ROOT", "")
|
||||||
|
|
||||||
|
// Save cwd, cd to a non-town directory, and restore after
|
||||||
|
origCwd, _ := os.Getwd()
|
||||||
|
os.Chdir(os.TempDir())
|
||||||
|
defer os.Chdir(origCwd)
|
||||||
|
|
||||||
|
result := detectTownRootFromCwd()
|
||||||
|
if result != secondaryTown {
|
||||||
|
t.Errorf("detectTownRootFromCwd() = %q, want %q (should accept secondary marker)", result, secondaryTown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user