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,161 @@
package doctor
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// PreCheckoutHookCheck verifies that the pre-checkout hook is installed in the
// town root to prevent accidental branch switches.
type PreCheckoutHookCheck struct {
FixableCheck
hookMissing bool // Cached during Run for use in Fix
}
// NewPreCheckoutHookCheck creates a new pre-checkout hook check.
func NewPreCheckoutHookCheck() *PreCheckoutHookCheck {
return &PreCheckoutHookCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "pre-checkout-hook",
CheckDescription: "Verify pre-checkout hook prevents branch switches",
},
},
}
}
// PreCheckoutHookScript is the expected content marker for our hook.
const preCheckoutHookMarker = "Gas Town pre-checkout hook"
// preCheckoutHookScript is the full hook script content.
// This matches the script in cmd/gitinit.go.
const preCheckoutHookScript = `#!/bin/bash
# Gas Town pre-checkout hook
# Prevents accidental branch switches in the town root (HQ).
# The town root must stay on main to avoid breaking gt commands.
# Only check branch checkouts (not file checkouts)
# $3 is 1 for file checkout, 0 for branch checkout
if [ "$3" = "1" ]; then
exit 0
fi
# Get the target branch name
TARGET_BRANCH=$(git rev-parse --abbrev-ref "$2" 2>/dev/null)
# Allow checkout to main or master
if [ "$TARGET_BRANCH" = "main" ] || [ "$TARGET_BRANCH" = "master" ]; then
exit 0
fi
# Get current branch
CURRENT_BRANCH=$(git branch --show-current)
# If already not on main, allow (might be fixing the situation)
if [ "$CURRENT_BRANCH" != "main" ] && [ "$CURRENT_BRANCH" != "master" ]; then
exit 0
fi
# Block the checkout with a warning
echo ""
echo "⚠️ BLOCKED: Town root must stay on main branch"
echo ""
echo " You're trying to switch from '$CURRENT_BRANCH' to '$TARGET_BRANCH'"
echo " in the Gas Town HQ directory."
echo ""
echo " The town root (~/gt) should always be on main. Switching branches"
echo " can break gt commands (missing rigs.json, wrong configs, etc.)."
echo ""
echo " If you really need to switch branches, you can:"
echo " 1. Temporarily rename .git/hooks/pre-checkout"
echo " 2. Do your work"
echo " 3. Switch back to main"
echo " 4. Restore the hook"
echo ""
exit 1
`
// Run checks if the pre-checkout hook is installed.
func (c *PreCheckoutHookCheck) Run(ctx *CheckContext) *CheckResult {
gitDir := filepath.Join(ctx.TownRoot, ".git")
// Check if town root is a git repo
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "Town root is not a git repository (skipped)",
}
}
hookPath := filepath.Join(gitDir, "hooks", "pre-checkout")
// Check if hook exists
content, err := os.ReadFile(hookPath)
if os.IsNotExist(err) {
c.hookMissing = true
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: "Pre-checkout hook not installed",
Details: []string{
"The pre-checkout hook prevents accidental branch switches in the town root",
"Without it, a git checkout in ~/gt could switch to a polecat branch",
"This can break gt commands (missing rigs.json, wrong configs)",
},
FixHint: "Run 'gt doctor --fix' or 'gt git-init' to install the hook",
}
}
if err != nil {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: fmt.Sprintf("Failed to read pre-checkout hook: %v", err),
}
}
// Check if it's our hook
if !strings.Contains(string(content), preCheckoutHookMarker) {
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: "Pre-checkout hook exists but is not Gas Town's",
Details: []string{
"A pre-checkout hook exists but doesn't contain the Gas Town marker",
"Consider adding branch protection manually or replacing it",
},
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "Pre-checkout hook installed",
}
}
// Fix installs the pre-checkout hook.
func (c *PreCheckoutHookCheck) Fix(ctx *CheckContext) error {
if !c.hookMissing {
return nil
}
hooksDir := filepath.Join(ctx.TownRoot, ".git", "hooks")
// Ensure hooks directory exists
if err := os.MkdirAll(hooksDir, 0755); err != nil {
return fmt.Errorf("creating hooks directory: %w", err)
}
hookPath := filepath.Join(hooksDir, "pre-checkout")
// Install the hook
if err := os.WriteFile(hookPath, []byte(preCheckoutHookScript), 0755); err != nil {
return fmt.Errorf("writing hook: %w", err)
}
return nil
}