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:
@@ -32,6 +32,11 @@ Workspace checks:
|
||||
- rigs-registry-valid Check registered rigs exist (fixable)
|
||||
- mayor-exists Check mayor/ directory structure
|
||||
|
||||
Town root protection:
|
||||
- town-git Verify town root is under version control
|
||||
- town-root-branch Verify town root is on main branch (fixable)
|
||||
- pre-checkout-hook Verify pre-checkout hook prevents branch switches (fixable)
|
||||
|
||||
Infrastructure checks:
|
||||
- daemon Check if daemon is running (fixable)
|
||||
- repo-fingerprint Check database has valid repo fingerprint (fixable)
|
||||
@@ -112,6 +117,8 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Register built-in checks
|
||||
d.Register(doctor.NewTownGitCheck())
|
||||
d.Register(doctor.NewTownRootBranchCheck())
|
||||
d.Register(doctor.NewPreCheckoutHookCheck())
|
||||
d.Register(doctor.NewDaemonCheck())
|
||||
d.Register(doctor.NewRepoFingerprintCheck())
|
||||
d.Register(doctor.NewBootHealthCheck())
|
||||
|
||||
@@ -267,6 +267,11 @@ func InitGitForHarness(hqRoot string, github string, private bool) error {
|
||||
fmt.Printf(" ✓ Git repository already exists\n")
|
||||
}
|
||||
|
||||
// Install pre-checkout hook to prevent accidental branch switches
|
||||
if err := InstallPreCheckoutHook(hqRoot); err != nil {
|
||||
fmt.Printf(" %s Could not install pre-checkout hook: %v\n", style.Dim.Render("⚠"), err)
|
||||
}
|
||||
|
||||
// Create GitHub repo if requested
|
||||
if github != "" {
|
||||
if err := createGitHubRepo(hqRoot, github, private); err != nil {
|
||||
@@ -276,3 +281,102 @@ func InitGitForHarness(hqRoot string, github string, private bool) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PreCheckoutHookScript is the git pre-checkout hook that prevents accidental
|
||||
// branch switches in the town root. The town root should always stay on main.
|
||||
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
|
||||
`
|
||||
|
||||
// InstallPreCheckoutHook installs the pre-checkout hook in the town root.
|
||||
// This prevents accidental branch switches that can break gt commands.
|
||||
func InstallPreCheckoutHook(hqRoot string) error {
|
||||
hooksDir := filepath.Join(hqRoot, ".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")
|
||||
|
||||
// Check if hook already exists
|
||||
if _, err := os.Stat(hookPath); err == nil {
|
||||
// Read existing hook to see if it's ours
|
||||
content, err := os.ReadFile(hookPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading existing hook: %w", err)
|
||||
}
|
||||
|
||||
if strings.Contains(string(content), "Gas Town pre-checkout hook") {
|
||||
fmt.Printf(" ✓ Pre-checkout hook already installed\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// There's an existing hook that's not ours - don't overwrite
|
||||
fmt.Printf(" %s Pre-checkout hook exists but is not Gas Town's (skipping)\n", style.Dim.Render("⚠"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Install the hook
|
||||
if err := os.WriteFile(hookPath, []byte(PreCheckoutHookScript), 0755); err != nil {
|
||||
return fmt.Errorf("writing hook: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf(" ✓ Installed pre-checkout hook (prevents accidental branch switches)\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsPreCheckoutHookInstalled checks if the Gas Town pre-checkout hook is installed.
|
||||
func IsPreCheckoutHookInstalled(hqRoot string) bool {
|
||||
hookPath := filepath.Join(hqRoot, ".git", "hooks", "pre-checkout")
|
||||
|
||||
content, err := os.ReadFile(hookPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.Contains(string(content), "Gas Town pre-checkout hook")
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
@@ -16,7 +20,7 @@ var rootCmd = &cobra.Command{
|
||||
|
||||
It coordinates agent spawning, work distribution, and communication
|
||||
across distributed teams of AI agents working on shared codebases.`,
|
||||
PersistentPreRunE: checkBeadsDependency,
|
||||
PersistentPreRunE: persistentPreRun,
|
||||
}
|
||||
|
||||
// Commands that don't require beads to be installed/checked.
|
||||
@@ -27,8 +31,74 @@ var beadsExemptCommands = map[string]bool{
|
||||
"completion": true,
|
||||
}
|
||||
|
||||
// Commands exempt from the town root branch warning.
|
||||
// These are commands that help fix the problem or are diagnostic.
|
||||
var branchCheckExemptCommands = map[string]bool{
|
||||
"version": true,
|
||||
"help": true,
|
||||
"completion": true,
|
||||
"doctor": true, // Used to fix the problem
|
||||
"install": true, // Initial setup
|
||||
"git-init": true, // Git setup
|
||||
}
|
||||
|
||||
// persistentPreRun runs before every command.
|
||||
func persistentPreRun(cmd *cobra.Command, args []string) error {
|
||||
// Get the root command name being run
|
||||
cmdName := cmd.Name()
|
||||
|
||||
// Check town root branch (warning only, non-blocking)
|
||||
if !branchCheckExemptCommands[cmdName] {
|
||||
warnIfTownRootOffMain()
|
||||
}
|
||||
|
||||
// Skip beads check for exempt commands
|
||||
if beadsExemptCommands[cmdName] {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check beads version
|
||||
return CheckBeadsVersion()
|
||||
}
|
||||
|
||||
// warnIfTownRootOffMain prints a warning if the town root is not on main branch.
|
||||
// This is a non-blocking warning to help catch accidental branch switches.
|
||||
func warnIfTownRootOffMain() {
|
||||
// Find town root (silently - don't error if not in workspace)
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil || townRoot == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it's a git repo
|
||||
gitDir := townRoot + "/.git"
|
||||
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get current branch
|
||||
gitCmd := exec.Command("git", "branch", "--show-current")
|
||||
gitCmd.Dir = townRoot
|
||||
out, err := gitCmd.Output()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
branch := strings.TrimSpace(string(out))
|
||||
if branch == "" || branch == "main" || branch == "master" {
|
||||
return
|
||||
}
|
||||
|
||||
// Town root is on wrong branch - warn the user
|
||||
fmt.Fprintf(os.Stderr, "\n%s Town root is on branch '%s' (should be 'main')\n",
|
||||
style.Bold.Render("⚠️ WARNING:"), branch)
|
||||
fmt.Fprintf(os.Stderr, " This can cause gt commands to fail. Run: %s\n\n",
|
||||
style.Dim.Render("gt doctor --fix"))
|
||||
}
|
||||
|
||||
// checkBeadsDependency verifies beads meets minimum version requirements.
|
||||
// Skips check for exempt commands (version, help, completion).
|
||||
// Deprecated: Use persistentPreRun instead, which calls CheckBeadsVersion.
|
||||
func checkBeadsDependency(cmd *cobra.Command, args []string) error {
|
||||
// Get the root command name being run
|
||||
cmdName := cmd.Name()
|
||||
|
||||
161
internal/doctor/precheckout_hook_check.go
Normal file
161
internal/doctor/precheckout_hook_check.go
Normal 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
|
||||
}
|
||||
117
internal/doctor/town_root_branch_check.go
Normal file
117
internal/doctor/town_root_branch_check.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user