From eea443526972efb71eefb7844bb6c3d4b8cfae7d Mon Sep 17 00:00:00 2001 From: gastown/crew/jack Date: Sun, 4 Jan 2026 14:00:48 -0800 Subject: [PATCH] feat(rig): Add --branch flag for custom default branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --branch flag to `gt rig add` to specify a custom default branch instead of auto-detecting from remote. This supports repositories that use non-standard default branches like `develop` or `release`. Changes: - Add --branch flag to `gt rig add` command - Store default_branch in rig config.json - Propagate default branch to refinery, witness, daemon, and all commands - Rename ensureMainBranch to ensureDefaultBranch for clarity - Add Rig.DefaultBranch() method for consistent access - Update crew/manager.go and swarm/manager.go to use rig config Based on PR #49 by @kustrun - rebased and extended with additional fixes. Co-authored-by: kustrun 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/crew_at.go | 4 ++-- internal/cmd/crew_helpers.go | 36 +++++++++++++++++++++-------------- internal/cmd/done.go | 17 +++++++++++------ internal/cmd/mq_submit.go | 16 ++++++++++++---- internal/cmd/rig.go | 11 +++++++---- internal/cmd/start.go | 8 ++++---- internal/crew/manager.go | 2 +- internal/daemon/lifecycle.go | 17 ++++++++++++++++- internal/refinery/engineer.go | 6 +++++- internal/refinery/manager.go | 9 ++++++--- internal/rig/manager.go | 28 +++++++++++++++++++++------ internal/rig/types.go | 10 ++++++++++ internal/swarm/manager.go | 2 +- internal/witness/handlers.go | 25 +++++++++++++++--------- 14 files changed, 135 insertions(+), 56 deletions(-) diff --git a/internal/cmd/crew_at.go b/internal/cmd/crew_at.go index 0457a048..51947ff4 100644 --- a/internal/cmd/crew_at.go +++ b/internal/cmd/crew_at.go @@ -52,8 +52,8 @@ func runCrewAt(cmd *cobra.Command, args []string) error { return fmt.Errorf("getting crew worker: %w", err) } - // Ensure crew workspace is on main branch (persistent roles should not use feature branches) - ensureMainBranch(worker.ClonePath, fmt.Sprintf("Crew workspace %s/%s", r.Name, name)) + // Ensure crew workspace is on default branch (persistent roles should not use feature branches) + ensureDefaultBranch(worker.ClonePath, fmt.Sprintf("Crew workspace %s/%s", r.Name, name), r.Path) // If --no-tmux, just print the path if crewNoTmux { diff --git a/internal/cmd/crew_helpers.go b/internal/cmd/crew_helpers.go index df7bab63..d65b0eea 100644 --- a/internal/cmd/crew_helpers.go +++ b/internal/cmd/crew_helpers.go @@ -205,10 +205,11 @@ func attachToTmuxSession(sessionID string) error { return cmd.Run() } -// ensureMainBranch checks if a git directory is on main branch. +// ensureDefaultBranch checks if a git directory is on the default branch. // If not, warns the user and offers to switch. -// Returns true if on main (or switched to main), false if user declined. -func ensureMainBranch(dir, roleName string) bool { //nolint:unparam // bool return kept for future callers to check +// Returns true if on default branch (or switched to it), false if user declined. +// The rigPath parameter is used to look up the configured default branch. +func ensureDefaultBranch(dir, roleName, rigPath string) bool { //nolint:unparam // bool return kept for future callers to check g := git.NewGit(dir) branch, err := g.CurrentBranch() @@ -217,31 +218,38 @@ func ensureMainBranch(dir, roleName string) bool { //nolint:unparam // bool retu return true } - if branch == "main" || branch == "master" { + // Get configured default branch for this rig + defaultBranch := "main" // fallback + if rigCfg, err := rig.LoadRigConfig(rigPath); err == nil && rigCfg.DefaultBranch != "" { + defaultBranch = rigCfg.DefaultBranch + } + + if branch == defaultBranch || branch == "master" { return true } // Warn about wrong branch - fmt.Printf("\n%s %s is on branch '%s', not main\n", + fmt.Printf("\n%s %s is on branch '%s', not %s\n", style.Warning.Render("⚠"), roleName, - branch) - fmt.Println(" Persistent roles should work on main to avoid orphaned work.") + branch, + defaultBranch) + fmt.Printf(" Persistent roles should work on %s to avoid orphaned work.\n", defaultBranch) fmt.Println() - // Auto-switch to main - fmt.Printf(" Switching to main...\n") - if err := g.Checkout("main"); err != nil { - fmt.Printf(" %s Could not switch to main: %v\n", style.Error.Render("✗"), err) - fmt.Println(" Please manually run: git checkout main && git pull") + // Auto-switch to default branch + fmt.Printf(" Switching to %s...\n", defaultBranch) + if err := g.Checkout(defaultBranch); err != nil { + fmt.Printf(" %s Could not switch to %s: %v\n", style.Error.Render("✗"), defaultBranch, err) + fmt.Printf(" Please manually run: git checkout %s && git pull\n", defaultBranch) return false } // Pull latest - if err := g.Pull("origin", "main"); err != nil { + if err := g.Pull("origin", defaultBranch); err != nil { fmt.Printf(" %s Pull failed (continuing anyway): %v\n", style.Warning.Render("⚠"), err) } else { - fmt.Printf(" %s Switched to main and pulled latest\n", style.Success.Render("✓")) + fmt.Printf(" %s Switched to %s and pulled latest\n", style.Success.Render("✓"), defaultBranch) } return true diff --git a/internal/cmd/done.go b/internal/cmd/done.go index cafe34b4..edb1f53f 100644 --- a/internal/cmd/done.go +++ b/internal/cmd/done.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "path/filepath" "strings" "github.com/spf13/cobra" @@ -10,6 +11,7 @@ import ( "github.com/steveyegge/gastown/internal/events" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/mail" + "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" ) @@ -147,11 +149,17 @@ func runDone(cmd *cobra.Command, args []string) error { agentBeadID = getAgentBeadID(ctx) } - // For COMPLETED, we need an issue ID and branch must not be main + // Get configured default branch for this rig + defaultBranch := "main" // fallback + if rigCfg, err := rig.LoadRigConfig(filepath.Join(townRoot, rigName)); err == nil && rigCfg.DefaultBranch != "" { + defaultBranch = rigCfg.DefaultBranch + } + + // For COMPLETED, we need an issue ID and branch must not be the default branch var mrID string if exitType == ExitCompleted { - if branch == "main" || branch == "master" { - return fmt.Errorf("cannot submit main/master branch to merge queue") + if branch == defaultBranch || branch == "master" { + return fmt.Errorf("cannot submit %s/master branch to merge queue", defaultBranch) } // Check for unpushed commits - branch must be pushed before MR creation @@ -164,9 +172,6 @@ func runDone(cmd *cobra.Command, args []string) error { return fmt.Errorf("branch has %d unpushed commit(s); run 'git push -u origin %s' first", unpushedCount, branch) } - // Detect the repo's default branch (main vs master) - defaultBranch := g.RemoteDefaultBranch() - // Check that branch has commits ahead of default branch (prevents submitting stale branches) aheadCount, err := g.CommitsAhead(defaultBranch, branch) if err != nil { diff --git a/internal/cmd/mq_submit.go b/internal/cmd/mq_submit.go index 46191dff..0e46dff0 100644 --- a/internal/cmd/mq_submit.go +++ b/internal/cmd/mq_submit.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "regexp" "strings" "time" @@ -11,6 +12,7 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/git" + "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" ) @@ -78,8 +80,14 @@ func runMqSubmit(cmd *cobra.Command, args []string) error { } } - if branch == "main" || branch == "master" { - return fmt.Errorf("cannot submit main/master branch to merge queue") + // Get configured default branch for this rig + defaultBranch := "main" // fallback + if rigCfg, err := rig.LoadRigConfig(filepath.Join(townRoot, rigName)); err == nil && rigCfg.DefaultBranch != "" { + defaultBranch = rigCfg.DefaultBranch + } + + if branch == defaultBranch || branch == "master" { + return fmt.Errorf("cannot submit %s/master branch to merge queue", defaultBranch) } // CRITICAL: Verify branch is pushed before creating MR bead @@ -111,7 +119,7 @@ func runMqSubmit(cmd *cobra.Command, args []string) error { bd := beads.New(cwd) // Determine target branch - target := "main" + target := defaultBranch if mqSubmitEpic != "" { // Explicit --epic flag takes precedence target = "integration/" + mqSubmitEpic @@ -119,7 +127,7 @@ func runMqSubmit(cmd *cobra.Command, args []string) error { // Auto-detect: check if source issue has a parent epic with an integration branch autoTarget, err := detectIntegrationBranch(bd, g, issueID) if err != nil { - // Non-fatal: log and continue with main as target + // Non-fatal: log and continue with default branch as target fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("(note: %v)", err))) } else if autoTarget != "" { target = autoTarget diff --git a/internal/cmd/rig.go b/internal/cmd/rig.go index 049a9333..ffdd60bf 100644 --- a/internal/cmd/rig.go +++ b/internal/cmd/rig.go @@ -252,6 +252,7 @@ Examples: var ( rigAddPrefix string rigAddLocalRepo string + rigAddBranch string rigResetHandoff bool rigResetMail bool rigResetStale bool @@ -281,6 +282,7 @@ func init() { rigAddCmd.Flags().StringVar(&rigAddPrefix, "prefix", "", "Beads issue prefix (default: derived from name)") rigAddCmd.Flags().StringVar(&rigAddLocalRepo, "local-repo", "", "Local repo path to share git objects (optional)") + rigAddCmd.Flags().StringVar(&rigAddBranch, "branch", "", "Default branch name (default: auto-detected from remote)") rigResetCmd.Flags().BoolVar(&rigResetHandoff, "handoff", false, "Clear handoff content") rigResetCmd.Flags().BoolVar(&rigResetMail, "mail", false, "Clear stale mail messages") @@ -340,10 +342,11 @@ func runRigAdd(cmd *cobra.Command, args []string) error { // Add the rig newRig, err := mgr.AddRig(rig.AddRigOptions{ - Name: name, - GitURL: gitURL, - BeadsPrefix: rigAddPrefix, - LocalRepo: rigAddLocalRepo, + Name: name, + GitURL: gitURL, + BeadsPrefix: rigAddPrefix, + LocalRepo: rigAddLocalRepo, + DefaultBranch: rigAddBranch, }) if err != nil { return fmt.Errorf("adding rig: %w", err) diff --git a/internal/cmd/start.go b/internal/cmd/start.go index 936fbc93..1e275458 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -750,8 +750,8 @@ func runStartCrew(cmd *cobra.Command, args []string) error { fmt.Printf("Crew workspace %s/%s exists\n", rigName, name) } - // Ensure crew workspace is on main branch - ensureMainBranch(worker.ClonePath, fmt.Sprintf("Crew workspace %s/%s", rigName, name)) + // Ensure crew workspace is on default branch + ensureDefaultBranch(worker.ClonePath, fmt.Sprintf("Crew workspace %s/%s", rigName, name), r.Path) // Resolve account for Claude config accountsPath := constants.MayorAccountsPath(townRoot) @@ -933,8 +933,8 @@ func startCrewMember(rigName, crewName, townRoot string) error { return fmt.Errorf("getting crew worker: %w", err) } - // Ensure crew workspace is on main branch - ensureMainBranch(worker.ClonePath, fmt.Sprintf("Crew workspace %s/%s", rigName, crewName)) + // Ensure crew workspace is on default branch + ensureDefaultBranch(worker.ClonePath, fmt.Sprintf("Crew workspace %s/%s", rigName, crewName), r.Path) // Create tmux session t := tmux.NewTmux() diff --git a/internal/crew/manager.go b/internal/crew/manager.go index 1380ac8c..bd7abf98 100644 --- a/internal/crew/manager.go +++ b/internal/crew/manager.go @@ -85,7 +85,7 @@ func (m *Manager) Add(name string, createBranch bool) (*CrewWorker, error) { } crewGit := git.NewGit(crewPath) - branchName := "main" + branchName := m.rig.DefaultBranch() // Optionally create a working branch if createBranch { diff --git a/internal/daemon/lifecycle.go b/internal/daemon/lifecycle.go index 0be31759..92579dbb 100644 --- a/internal/daemon/lifecycle.go +++ b/internal/daemon/lifecycle.go @@ -11,6 +11,7 @@ import ( "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/constants" + "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/tmux" ) @@ -467,6 +468,20 @@ func (d *Daemon) applySessionTheme(sessionName string, parsed *ParsedIdentity) { // syncWorkspace syncs a git workspace before starting a new session. // This ensures agents with persistent clones (like refinery) start with current code. func (d *Daemon) syncWorkspace(workDir string) { + // Determine default branch from rig config + // workDir is like ///rig or //crew/ + defaultBranch := "main" // fallback + rel, err := filepath.Rel(d.config.TownRoot, workDir) + if err == nil { + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) > 0 { + rigPath := filepath.Join(d.config.TownRoot, parts[0]) + if rigCfg, err := rig.LoadRigConfig(rigPath); err == nil && rigCfg.DefaultBranch != "" { + defaultBranch = rigCfg.DefaultBranch + } + } + } + // Fetch latest from origin fetchCmd := exec.Command("git", "fetch", "origin") fetchCmd.Dir = workDir @@ -475,7 +490,7 @@ func (d *Daemon) syncWorkspace(workDir string) { } // Pull with rebase to incorporate changes - pullCmd := exec.Command("git", "pull", "--rebase", "origin", "main") + pullCmd := exec.Command("git", "pull", "--rebase", "origin", defaultBranch) pullCmd.Dir = workDir if err := pullCmd.Run(); err != nil { d.logger.Printf("Warning: git pull failed in %s: %v", workDir, err) diff --git a/internal/refinery/engineer.go b/internal/refinery/engineer.go index 84a54484..b98de335 100644 --- a/internal/refinery/engineer.go +++ b/internal/refinery/engineer.go @@ -87,12 +87,16 @@ type Engineer struct { // NewEngineer creates a new Engineer for the given rig. func NewEngineer(r *rig.Rig) *Engineer { + cfg := DefaultMergeQueueConfig() + // Override target branch with rig's configured default branch + cfg.TargetBranch = r.DefaultBranch() + return &Engineer{ rig: r, beads: beads.New(r.Path), mrQueue: mrqueue.New(r.Path), git: git.NewGit(r.Path), - config: DefaultMergeQueueConfig(), + config: cfg, workDir: r.Path, output: os.Stdout, eventLogger: mrqueue.NewEventLoggerFromRig(r.Path), diff --git a/internal/refinery/manager.go b/internal/refinery/manager.go index e35619ce..799d38d6 100644 --- a/internal/refinery/manager.go +++ b/internal/refinery/manager.go @@ -359,6 +359,9 @@ func (m *Manager) issueToMR(issue *beads.Issue) *MergeRequest { return nil } + // Get configured default branch for this rig + defaultBranch := m.rig.DefaultBranch() + fields := beads.ParseMRFields(issue) if fields == nil { // No MR fields in description, construct from title/ID @@ -367,14 +370,14 @@ func (m *Manager) issueToMR(issue *beads.Issue) *MergeRequest { IssueID: issue.ID, Status: MROpen, CreatedAt: parseTime(issue.CreatedAt), - TargetBranch: "main", + TargetBranch: defaultBranch, } } - // Default target to main if not specified + // Default target to rig's default branch if not specified target := fields.Target if target == "" { - target = "main" + target = defaultBranch } return &MergeRequest{ diff --git a/internal/rig/manager.go b/internal/rig/manager.go index 76e25b04..2bf4f09b 100644 --- a/internal/rig/manager.go +++ b/internal/rig/manager.go @@ -156,10 +156,11 @@ func (m *Manager) loadRig(name string, entry config.RigEntry) (*Rig, error) { // AddRigOptions configures rig creation. type AddRigOptions struct { - Name string // Rig name (directory name) - GitURL string // Repository URL - BeadsPrefix string // Beads issue prefix (defaults to derived from name) - LocalRepo string // Optional local repo for reference clones + Name string // Rig name (directory name) + GitURL string // Repository URL + BeadsPrefix string // Beads issue prefix (defaults to derived from name) + LocalRepo string // Optional local repo for reference clones + DefaultBranch string // Default branch (defaults to auto-detected from remote) } func resolveLocalRepo(path, gitURL string) (string, string) { @@ -285,8 +286,17 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) { fmt.Printf(" ✓ Created shared bare repo\n") bareGit := git.NewGitWithDir(bareRepoPath, "") - // Detect default branch (main, master, etc.) - defaultBranch := bareGit.DefaultBranch() + // Determine default branch: use provided value or auto-detect from remote + var defaultBranch string + if opts.DefaultBranch != "" { + defaultBranch = opts.DefaultBranch + } else { + // Try to get default branch from remote first, fall back to local detection + defaultBranch = bareGit.RemoteDefaultBranch() + if defaultBranch == "" { + defaultBranch = bareGit.DefaultBranch() + } + } rigConfig.DefaultBranch = defaultBranch // Re-save config with default branch if err := m.saveRigConfig(rigPath, rigConfig); err != nil { @@ -314,6 +324,12 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) { return nil, fmt.Errorf("cloning for mayor: %w", err) } } + + // Checkout the default branch for mayor (clone defaults to remote's HEAD, not our configured branch) + mayorGit := git.NewGitWithDir("", mayorRigPath) + if err := mayorGit.Checkout(defaultBranch); err != nil { + return nil, fmt.Errorf("checking out default branch for mayor: %w", err) + } fmt.Printf(" ✓ Created mayor clone\n") // Check if source repo has .beads/ with its own prefix - if so, use that prefix. diff --git a/internal/rig/types.go b/internal/rig/types.go index 2c580a3b..dfd386c2 100644 --- a/internal/rig/types.go +++ b/internal/rig/types.go @@ -79,3 +79,13 @@ func (r *Rig) BeadsPath() string { } return r.Path } + +// DefaultBranch returns the configured default branch for this rig. +// Falls back to "main" if not configured or if config cannot be loaded. +func (r *Rig) DefaultBranch() string { + cfg, err := LoadRigConfig(r.Path) + if err != nil || cfg.DefaultBranch == "" { + return "main" + } + return cfg.DefaultBranch +} diff --git a/internal/swarm/manager.go b/internal/swarm/manager.go index 0f825cf2..d44d87d1 100644 --- a/internal/swarm/manager.go +++ b/internal/swarm/manager.go @@ -88,7 +88,7 @@ func (m *Manager) LoadSwarm(epicID string) (*Swarm, error) { EpicID: epicID, BaseCommit: baseCommit, Integration: fmt.Sprintf("swarm/%s", epicID), - TargetBranch: "main", + TargetBranch: m.rig.DefaultBranch(), State: state, Workers: []string{}, // Discovered from active tasks Tasks: []SwarmTask{}, diff --git a/internal/witness/handlers.go b/internal/witness/handlers.go index da908882..88113230 100644 --- a/internal/witness/handlers.go +++ b/internal/witness/handlers.go @@ -12,6 +12,7 @@ import ( "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/mail" + "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" ) @@ -746,12 +747,12 @@ func AutoNukeIfClean(workDir, rigName, polecatName string) *NukePolecatResult { return result } -// verifyCommitOnMain checks if the polecat's current commit is on main. +// verifyCommitOnMain checks if the polecat's current commit is on the default branch. // This prevents nuking a polecat whose work wasn't actually merged. // // Returns: -// - true, nil: commit is verified on main -// - false, nil: commit is NOT on main (don't nuke!) +// - true, nil: commit is verified on default branch +// - false, nil: commit is NOT on default branch (don't nuke!) // - false, error: couldn't verify (treat as unsafe) func verifyCommitOnMain(workDir, rigName, polecatName string) (bool, error) { // Find town root from workDir @@ -760,6 +761,12 @@ func verifyCommitOnMain(workDir, rigName, polecatName string) (bool, error) { return false, fmt.Errorf("finding town root: %v", err) } + // Get configured default branch for this rig + defaultBranch := "main" // fallback + if rigCfg, err := rig.LoadRigConfig(filepath.Join(townRoot, rigName)); err == nil && rigCfg.DefaultBranch != "" { + defaultBranch = rigCfg.DefaultBranch + } + // Construct polecat path: //polecats/ polecatPath := filepath.Join(townRoot, rigName, "polecats", polecatName) @@ -772,16 +779,16 @@ func verifyCommitOnMain(workDir, rigName, polecatName string) (bool, error) { return false, fmt.Errorf("getting polecat HEAD: %w", err) } - // Verify it's an ancestor of main (i.e., it's been merged) - // We use the polecat's git context to check main - isOnMain, err := g.IsAncestor(commitSHA, "origin/main") + // Verify it's an ancestor of default branch (i.e., it's been merged) + // We use the polecat's git context to check + isOnDefaultBranch, err := g.IsAncestor(commitSHA, "origin/"+defaultBranch) if err != nil { // Try without origin/ prefix in case remote isn't set up - isOnMain, err = g.IsAncestor(commitSHA, "main") + isOnDefaultBranch, err = g.IsAncestor(commitSHA, defaultBranch) if err != nil { - return false, fmt.Errorf("checking if commit is on main: %w", err) + return false, fmt.Errorf("checking if commit is on %s: %w", defaultBranch, err) } } - return isOnMain, nil + return isOnDefaultBranch, nil }