Files
gastown/internal/cmd/worktree.go
Martin Emde 201ef3a9c8 Replace gt rigs with gt rig list in templates and docs (#217)
The command was renamed from `gt rigs` to `gt rig` with subcommands.
This updates all references to use `gt rig list` for listing rigs.
2026-01-06 12:59:49 -08:00

341 lines
11 KiB
Go

package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
// Worktree command flags
var (
worktreeNoCD bool
)
var worktreeCmd = &cobra.Command{
Use: "worktree <rig>",
GroupID: GroupWorkspace,
Short: "Create worktree in another rig for cross-rig work",
Long: `Create a git worktree in another rig for cross-rig work.
This command is for crew workers who need to work on another rig's codebase
while maintaining their identity. It creates a worktree in the target rig's
crew/ directory with a name that identifies your source rig and identity.
The worktree is created at: ~/gt/<target-rig>/crew/<source-rig>-<name>/
For example, if you're gastown/crew/joe and run 'gt worktree beads':
- Creates worktree at ~/gt/beads/crew/gastown-joe/
- The worktree checks out main branch
- Your identity (BD_ACTOR, GT_ROLE) remains gastown/crew/joe
Use --no-cd to just print the path without printing shell commands.
Examples:
gt worktree beads # Create worktree in beads rig
gt worktree gastown # Create worktree in gastown rig (from another rig)
gt worktree beads --no-cd # Just print the path`,
Args: cobra.ExactArgs(1),
RunE: runWorktree,
}
var worktreeListCmd = &cobra.Command{
Use: "list",
Short: "List all cross-rig worktrees owned by current crew member",
Long: `List all git worktrees created for cross-rig work.
This command scans all rigs in the workspace and finds worktrees
that belong to the current crew member. Each worktree is shown with
its git status summary.
Example output:
Cross-rig worktrees for gastown/crew/joe:
beads ~/gt/beads/crew/gastown-joe/ (clean)
mayor ~/gt/mayor/crew/gastown-joe/ (2 uncommitted)`,
RunE: runWorktreeList,
}
// Worktree remove command flags
var (
worktreeRemoveForce bool
)
var worktreeRemoveCmd = &cobra.Command{
Use: "remove <rig>",
Short: "Remove a cross-rig worktree",
Long: `Remove a git worktree created for cross-rig work.
This command removes a worktree that was previously created with 'gt worktree <rig>'.
It will refuse to remove a worktree with uncommitted changes unless --force is used.
Examples:
gt worktree remove beads # Remove beads worktree
gt worktree remove beads --force # Force remove even with uncommitted changes`,
Args: cobra.ExactArgs(1),
RunE: runWorktreeRemove,
}
func init() {
worktreeCmd.Flags().BoolVar(&worktreeNoCD, "no-cd", false, "Just print path (don't print cd command)")
worktreeCmd.AddCommand(worktreeListCmd)
worktreeRemoveCmd.Flags().BoolVarP(&worktreeRemoveForce, "force", "f", false, "Force remove even with uncommitted changes")
worktreeCmd.AddCommand(worktreeRemoveCmd)
rootCmd.AddCommand(worktreeCmd)
}
func runWorktree(cmd *cobra.Command, args []string) error {
targetRig := args[0]
// Detect current crew identity from cwd
detected, err := detectCrewFromCwd()
if err != nil {
return fmt.Errorf("must be in a crew workspace to use this command: %w", err)
}
sourceRig := detected.rigName
crewName := detected.crewName
// Cannot create worktree in your own rig
if targetRig == sourceRig {
return fmt.Errorf("already in rig '%s' - use gt worktree to work in a different rig", targetRig)
}
// Verify target rig exists
_, targetRigInfo, err := getRig(targetRig)
if err != nil {
return fmt.Errorf("rig '%s' not found - run 'gt rig list' to see available rigs", targetRig)
}
// Compute worktree path: ~/gt/<target-rig>/crew/<source-rig>-<name>/
worktreeName := fmt.Sprintf("%s-%s", sourceRig, crewName)
worktreePath := filepath.Join(constants.RigCrewPath(targetRigInfo.Path), worktreeName)
// Check if worktree already exists
if _, err := os.Stat(worktreePath); err == nil {
// Worktree exists
if worktreeNoCD {
fmt.Println(worktreePath)
} else {
fmt.Printf("%s Worktree already exists at %s\n", style.Success.Render("✓"), worktreePath)
fmt.Printf("cd %s\n", worktreePath)
}
return nil
}
// Get the source rig's git repository (the bare repo for worktrees)
// For cross-rig work, we need to use the target rig's repository
// The target rig's mayor/rig is the main clone we create worktrees from
targetMayorRig := constants.RigMayorPath(targetRigInfo.Path)
g := git.NewGit(targetMayorRig)
// Ensure crew directory exists in target rig
crewDir := constants.RigCrewPath(targetRigInfo.Path)
if err := os.MkdirAll(crewDir, 0755); err != nil {
return fmt.Errorf("creating crew directory: %w", err)
}
// Fetch latest from remote before creating worktree
if err := g.Fetch("origin"); err != nil {
// Non-fatal - continue with local state
fmt.Printf("%s Warning: could not fetch from origin: %v\n", style.Warning.Render("⚠"), err)
}
// Create the worktree on main branch
// Use WorktreeAddExistingForce because main may already be checked out
// in other worktrees (e.g., mayor/rig). This is safe for cross-rig work.
if err := g.WorktreeAddExistingForce(worktreePath, "main"); err != nil {
return fmt.Errorf("creating worktree: %w", err)
}
// Configure git author for identity preservation
worktreeGit := git.NewGit(worktreePath)
bdActor := fmt.Sprintf("%s/crew/%s", sourceRig, crewName)
// Set local git config for this worktree
if err := setGitConfig(worktreePath, "user.name", bdActor); err != nil {
fmt.Printf("%s Warning: could not set git author name: %v\n", style.Warning.Render("⚠"), err)
}
fmt.Printf("%s Created worktree for cross-rig work\n", style.Success.Render("✓"))
fmt.Printf(" Source: %s/crew/%s\n", sourceRig, crewName)
fmt.Printf(" Target: %s\n", worktreePath)
fmt.Printf(" Branch: main\n")
fmt.Println()
// Pull latest main in the new worktree
if err := worktreeGit.Pull("origin", "main"); err != nil {
fmt.Printf("%s Warning: could not pull latest: %v\n", style.Warning.Render("⚠"), err)
}
if worktreeNoCD {
fmt.Println(worktreePath)
} else {
fmt.Printf("To enter the worktree:\n")
fmt.Printf(" cd %s\n", worktreePath)
fmt.Println()
fmt.Printf("Environment variables to preserve your identity:\n")
fmt.Printf(" export BD_ACTOR=%s\n", bdActor)
fmt.Printf(" export GT_ROLE=crew\n")
fmt.Printf(" export GT_RIG=%s\n", sourceRig)
fmt.Printf(" export GT_CREW=%s\n", crewName)
}
return nil
}
// setGitConfig sets a git config value in the specified worktree.
func setGitConfig(worktreePath, key, value string) error {
cmd := exec.Command("git", "-C", worktreePath, "config", key, value)
return cmd.Run()
}
func runWorktreeList(cmd *cobra.Command, args []string) error {
// Detect current crew identity from cwd
detected, err := detectCrewFromCwd()
if err != nil {
return fmt.Errorf("must be in a crew workspace to use this command: %w", err)
}
sourceRig := detected.rigName
crewName := detected.crewName
worktreeName := fmt.Sprintf("%s-%s", sourceRig, crewName)
// Find town root
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Load rigs config to list all rigs
rigsConfigPath := constants.MayorRigsPath(townRoot)
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
return fmt.Errorf("loading rigs config: %w", err)
}
fmt.Printf("Cross-rig worktrees for %s/crew/%s:\n\n", sourceRig, crewName)
found := false
for rigName := range rigsConfig.Rigs {
// Skip our own rig - worktrees are for cross-rig work
if rigName == sourceRig {
continue
}
// Rig path is simply townRoot/<rigName>
rigPath := filepath.Join(townRoot, rigName)
// Check if worktree exists: <rig>/crew/<source-rig>-<name>/
worktreePath := filepath.Join(constants.RigCrewPath(rigPath), worktreeName)
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
continue
}
// Worktree exists - get git status
statusSummary := getGitStatusSummary(worktreePath)
// Format the path for display (use ~ for home directory)
displayPath := worktreePath
if home, err := os.UserHomeDir(); err == nil {
if rel, err := filepath.Rel(home, worktreePath); err == nil && !filepath.IsAbs(rel) {
displayPath = "~/" + rel
}
}
fmt.Printf(" %-10s %s (%s)\n", rigName, displayPath, statusSummary)
found = true
}
if !found {
fmt.Printf(" (none)\n")
fmt.Printf("\nCreate a worktree with: gt worktree <rig>\n")
}
return nil
}
// getGitStatusSummary returns a brief status summary for a git directory.
func getGitStatusSummary(dir string) string {
g := git.NewGit(dir)
// Check for uncommitted changes
status, err := g.Status()
if err != nil {
return "error"
}
if status.Clean {
return "clean"
}
// Count uncommitted files (modified, added, deleted, untracked)
uncommitted := len(status.Modified) + len(status.Added) + len(status.Deleted) + len(status.Untracked)
return fmt.Sprintf("%d uncommitted", uncommitted)
}
func runWorktreeRemove(cmd *cobra.Command, args []string) error {
targetRig := args[0]
// Detect current crew identity from cwd
detected, err := detectCrewFromCwd()
if err != nil {
return fmt.Errorf("must be in a crew workspace to use this command: %w", err)
}
sourceRig := detected.rigName
crewName := detected.crewName
// Cannot remove worktree in your own rig (doesn't make sense)
if targetRig == sourceRig {
return fmt.Errorf("cannot remove worktree in your own rig '%s'", targetRig)
}
// Verify target rig exists
_, targetRigInfo, err := getRig(targetRig)
if err != nil {
return fmt.Errorf("rig '%s' not found - run 'gt rig list' to see available rigs", targetRig)
}
// Compute worktree path: ~/gt/<target-rig>/crew/<source-rig>-<name>/
worktreeName := fmt.Sprintf("%s-%s", sourceRig, crewName)
worktreePath := filepath.Join(constants.RigCrewPath(targetRigInfo.Path), worktreeName)
// Check if worktree exists
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
return fmt.Errorf("worktree does not exist at %s", worktreePath)
}
// Check for uncommitted changes (unless --force)
if !worktreeRemoveForce {
statusSummary := getGitStatusSummary(worktreePath)
if statusSummary != "clean" && statusSummary != "error" {
return fmt.Errorf("worktree has %s - use --force to remove anyway", statusSummary)
}
}
// Get the target rig's mayor path (where the main git repo is)
targetMayorRig := constants.RigMayorPath(targetRigInfo.Path)
g := git.NewGit(targetMayorRig)
// Remove the worktree
if err := g.WorktreeRemove(worktreePath, worktreeRemoveForce); err != nil {
return fmt.Errorf("removing worktree: %w", err)
}
fmt.Printf("%s Removed worktree at %s\n", style.Success.Render("✓"), worktreePath)
return nil
}