The command was renamed from `gt rigs` to `gt rig` with subcommands. This updates all references to use `gt rig list` for listing rigs.
341 lines
11 KiB
Go
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
|
|
}
|