feat(doctor): add safeguards for town root branch protection

Add three layers of protection to prevent accidental branch switches in
the town root (~/gt), which should always stay on main:

1. Doctor check `town-root-branch`: Verifies town root is on main/master.
   Fixable via `gt doctor --fix` to switch back to main.

2. Doctor check `pre-checkout-hook`: Verifies git pre-checkout hook is
   installed. The hook blocks checkout from main to any other branch.
   Fixable via `gt doctor --fix` or `gt git-init`.

3. Runtime warning in all gt commands: Non-blocking warning if town root
   is on wrong branch, with fix instructions.

The root cause of this issue was git commands running in the wrong
directory, switching the town root to a polecat branch. This broke gt
commands because rigs.json and other configs were on main, not the
polecat branch.

Closes: hq-1kwuj

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
slit
2026-01-09 13:16:37 -08:00
committed by beads/crew/giles
parent 692d6819f2
commit ba76bf1232
5 changed files with 460 additions and 1 deletions

View File

@@ -0,0 +1,117 @@
package doctor
import (
"fmt"
"os/exec"
"strings"
)
// TownRootBranchCheck verifies that the town root directory is on the main branch.
// The town root should always stay on main to avoid confusion and broken gt commands.
// Accidental branch switches can happen when git commands run in the wrong directory.
type TownRootBranchCheck struct {
FixableCheck
currentBranch string // Cached during Run for use in Fix
}
// NewTownRootBranchCheck creates a new town root branch check.
func NewTownRootBranchCheck() *TownRootBranchCheck {
return &TownRootBranchCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "town-root-branch",
CheckDescription: "Verify town root is on main branch",
},
},
}
}
// Run checks if the town root is on the main branch.
func (c *TownRootBranchCheck) Run(ctx *CheckContext) *CheckResult {
// Get current branch
cmd := exec.Command("git", "branch", "--show-current")
cmd.Dir = ctx.TownRoot
out, err := cmd.Output()
if err != nil {
// Not a git repo - skip this check (handled by town-git check)
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "Town root is not a git repository (skipped)",
}
}
branch := strings.TrimSpace(string(out))
c.currentBranch = branch
// Empty branch means detached HEAD
if branch == "" {
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: "Town root is in detached HEAD state",
Details: []string{
"The town root should be on the main branch",
"Detached HEAD can cause gt commands to fail",
},
FixHint: "Run 'gt doctor --fix' or manually: cd ~/gt && git checkout main",
}
}
// Accept main or master
if branch == "main" || branch == "master" {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: fmt.Sprintf("Town root is on %s branch", branch),
}
}
// On wrong branch - this is the problem we're trying to prevent
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: fmt.Sprintf("Town root is on wrong branch: %s", branch),
Details: []string{
"The town root (~/gt) must stay on main branch",
fmt.Sprintf("Currently on: %s", branch),
"This can cause gt commands to fail (missing rigs.json, etc.)",
"The branch switch was likely accidental (git command in wrong dir)",
},
FixHint: "Run 'gt doctor --fix' or manually: cd ~/gt && git checkout main",
}
}
// Fix switches the town root back to main branch.
func (c *TownRootBranchCheck) Fix(ctx *CheckContext) error {
// Only fix if we're not already on main
if c.currentBranch == "main" || c.currentBranch == "master" {
return nil
}
// Check for uncommitted changes that would block checkout
cmd := exec.Command("git", "status", "--porcelain")
cmd.Dir = ctx.TownRoot
out, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to check git status: %w", err)
}
if strings.TrimSpace(string(out)) != "" {
return fmt.Errorf("cannot switch to main: uncommitted changes in town root (stash or commit first)")
}
// Switch to main
cmd = exec.Command("git", "checkout", "main")
cmd.Dir = ctx.TownRoot
if err := cmd.Run(); err != nil {
// Try master if main doesn't exist
cmd = exec.Command("git", "checkout", "master")
cmd.Dir = ctx.TownRoot
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to checkout main: %w", err)
}
}
return nil
}