diff --git a/internal/cmd/convoy.go b/internal/cmd/convoy.go index 4b6e30a7..57e33f8e 100644 --- a/internal/cmd/convoy.go +++ b/internal/cmd/convoy.go @@ -107,20 +107,6 @@ Examples: RunE: runConvoyList, } -var convoyAddCmd = &cobra.Command{ - Use: "add [issue-id...]", - Short: "Add issues to an existing convoy", - Long: `Add issues to an existing convoy. - -If the convoy is closed, it will be automatically reopened. - -Examples: - gt convoy add hq-cv-abc gt-new-issue - gt convoy add hq-cv-abc gt-issue1 gt-issue2 gt-issue3`, - Args: cobra.MinimumNArgs(2), - RunE: runConvoyAdd, -} - func init() { // Create flags convoyCreateCmd.Flags().StringVar(&convoyMolecule, "molecule", "", "Associated molecule ID") @@ -138,7 +124,6 @@ func init() { convoyCmd.AddCommand(convoyCreateCmd) convoyCmd.AddCommand(convoyStatusCmd) convoyCmd.AddCommand(convoyListCmd) - convoyCmd.AddCommand(convoyAddCmd) rootCmd.AddCommand(convoyCmd) } @@ -237,86 +222,6 @@ func runConvoyCreate(cmd *cobra.Command, args []string) error { return nil } -func runConvoyAdd(cmd *cobra.Command, args []string) error { - convoyID := args[0] - issuesToAdd := args[1:] - - townBeads, err := getTownBeadsDir() - if err != nil { - return err - } - - // Validate convoy exists and get its status - showArgs := []string{"show", convoyID, "--json"} - showCmd := exec.Command("bd", showArgs...) - showCmd.Dir = townBeads - var stdout bytes.Buffer - showCmd.Stdout = &stdout - - if err := showCmd.Run(); err != nil { - return fmt.Errorf("convoy '%s' not found", convoyID) - } - - var convoys []struct { - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Type string `json:"issue_type"` - } - if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil { - return fmt.Errorf("parsing convoy data: %w", err) - } - - if len(convoys) == 0 { - return fmt.Errorf("convoy '%s' not found", convoyID) - } - - convoy := convoys[0] - - // Verify it's actually a convoy type - if convoy.Type != "convoy" { - return fmt.Errorf("'%s' is not a convoy (type: %s)", convoyID, convoy.Type) - } - - // If convoy is closed, reopen it - reopened := false - if convoy.Status == "closed" { - reopenArgs := []string{"update", convoyID, "--status=open"} - reopenCmd := exec.Command("bd", reopenArgs...) - reopenCmd.Dir = townBeads - if err := reopenCmd.Run(); err != nil { - return fmt.Errorf("couldn't reopen convoy: %w", err) - } - reopened = true - fmt.Printf("%s Reopened convoy %s\n", style.Bold.Render("↺"), convoyID) - } - - // Add 'tracks' relations for each issue - addedCount := 0 - for _, issueID := range issuesToAdd { - depArgs := []string{"dep", "add", convoyID, issueID, "--type=tracks"} - depCmd := exec.Command("bd", depArgs...) - depCmd.Dir = townBeads - - if err := depCmd.Run(); err != nil { - style.PrintWarning("couldn't add %s: %v", issueID, err) - } else { - addedCount++ - } - } - - // Output - if reopened { - fmt.Println() - } - fmt.Printf("%s Added %d issue(s) to convoy 🚚 %s\n", style.Bold.Render("✓"), addedCount, convoyID) - if addedCount > 0 { - fmt.Printf(" Issues: %s\n", strings.Join(issuesToAdd[:addedCount], ", ")) - } - - return nil -} - func runConvoyStatus(cmd *cobra.Command, args []string) error { townBeads, err := getTownBeadsDir() if err != nil { diff --git a/internal/cmd/worktree.go b/internal/cmd/worktree.go index 76ae6142..1827b35b 100644 --- a/internal/cmd/worktree.go +++ b/internal/cmd/worktree.go @@ -63,10 +63,33 @@ Example output: 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) } @@ -128,8 +151,9 @@ func runWorktree(cmd *cobra.Command, args []string) error { } // Create the worktree on main branch - // Use WorktreeAddExisting to checkout an existing branch (main) - if err := g.WorktreeAddExisting(worktreePath, "main"); err != nil { + // 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) } @@ -260,3 +284,57 @@ func getGitStatusSummary(dir string) string { 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 +} diff --git a/internal/git/git.go b/internal/git/git.go index 2f270f1d..165e8015 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -476,6 +476,13 @@ func (g *Git) WorktreeAddExisting(path, branch string) error { return err } +// WorktreeAddExistingForce creates a new worktree even if the branch is already checked out elsewhere. +// This is useful for cross-rig worktrees where multiple clones need to be on main. +func (g *Git) WorktreeAddExistingForce(path, branch string) error { + _, err := g.run("worktree", "add", "--force", path, branch) + return err +} + // WorktreeRemove removes a worktree. func (g *Git) WorktreeRemove(path string, force bool) error { args := []string{"worktree", "remove", path} diff --git a/internal/tui/feed/model.go b/internal/tui/feed/model.go index d4d491cb..24bb737e 100644 --- a/internal/tui/feed/model.go +++ b/internal/tui/feed/model.go @@ -281,11 +281,6 @@ func (m *Model) addEvent(e Event) { } } - // Filter out events with empty bead IDs (malformed mutations) - if e.Type == "update" && e.Target == "" { - return - } - // Filter out noisy agent session updates from the event feed. // Agent session molecules (like gt-gastown-crew-joe) update frequently // for status tracking. These updates are visible in the agent tree, @@ -298,18 +293,6 @@ func (m *Model) addEvent(e Event) { return } - // Deduplicate rapid updates to the same bead within 2 seconds. - // This prevents spam when multiple deps/labels are added to one issue. - if e.Type == "update" && e.Target != "" && len(m.events) > 0 { - lastEvent := m.events[len(m.events)-1] - if lastEvent.Type == "update" && lastEvent.Target == e.Target { - // Same bead updated within 2 seconds - skip duplicate - if e.Time.Sub(lastEvent.Time) < 2*time.Second { - return - } - } - } - // Add to event feed m.events = append(m.events, e)