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 ", 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//crew/-/ 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 ", 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 '. 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 rigs' to see available rigs", targetRig) } // Compute worktree path: ~/gt//crew/-/ 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/ rigPath := filepath.Join(townRoot, rigName) // Check if worktree exists: /crew/-/ 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 \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 rigs' to see available rigs", targetRig) } // Compute worktree path: ~/gt//crew/-/ 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 }