|
8 | 8 | "regexp" |
9 | 9 | "strings" |
10 | 10 |
|
| 11 | + "github.com/manifoldco/promptui" |
11 | 12 | "github.com/spf13/cobra" |
12 | 13 | ) |
13 | 14 |
|
@@ -154,15 +155,125 @@ func printCDMarker(path string) { |
154 | 155 | fmt.Printf("TREE_ME_CD:%s\n", path) |
155 | 156 | } |
156 | 157 |
|
| 158 | +func getAvailableBranches() ([]string, error) { |
| 159 | + // Get all branches |
| 160 | + cmd := exec.Command("git", "branch", "-a", "--format=%(refname:short)") |
| 161 | + output, err := cmd.Output() |
| 162 | + if err != nil { |
| 163 | + return nil, err |
| 164 | + } |
| 165 | + |
| 166 | + branches := []string{} |
| 167 | + for _, line := range strings.Split(string(output), "\n") { |
| 168 | + branch := strings.TrimSpace(line) |
| 169 | + if branch == "" || strings.HasPrefix(branch, "origin/HEAD") { |
| 170 | + continue |
| 171 | + } |
| 172 | + // Remove origin/ prefix for remote branches |
| 173 | + branch = strings.TrimPrefix(branch, "origin/") |
| 174 | + // Check if this branch doesn't already have a worktree |
| 175 | + if _, exists := worktreeExists(branch); !exists { |
| 176 | + branches = append(branches, branch) |
| 177 | + } |
| 178 | + } |
| 179 | + return branches, nil |
| 180 | +} |
| 181 | + |
| 182 | +func getExistingWorktreeBranches() ([]string, error) { |
| 183 | + cmd := exec.Command("git", "worktree", "list") |
| 184 | + output, err := cmd.Output() |
| 185 | + if err != nil { |
| 186 | + return nil, err |
| 187 | + } |
| 188 | + |
| 189 | + branches := []string{} |
| 190 | + lines := strings.Split(string(output), "\n") |
| 191 | + for _, line := range lines[1:] { // Skip first line (main worktree) |
| 192 | + if line == "" { |
| 193 | + continue |
| 194 | + } |
| 195 | + // Extract branch name from [branch] format |
| 196 | + if matches := regexp.MustCompile(`\[([^\]]+)\]`).FindStringSubmatch(line); matches != nil { |
| 197 | + branches = append(branches, matches[1]) |
| 198 | + } |
| 199 | + } |
| 200 | + return branches, nil |
| 201 | +} |
| 202 | + |
| 203 | +func getOpenPRs() ([]string, []string, error) { |
| 204 | + cmd := exec.Command("gh", "pr", "list", "--json", "number,title", "--jq", ".[] | \"\\(.number)\\t\\(.title)\"") |
| 205 | + output, err := cmd.Output() |
| 206 | + if err != nil { |
| 207 | + return nil, nil, err |
| 208 | + } |
| 209 | + |
| 210 | + var numbers []string |
| 211 | + var labels []string |
| 212 | + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { |
| 213 | + if line == "" { |
| 214 | + continue |
| 215 | + } |
| 216 | + parts := strings.SplitN(line, "\t", 2) |
| 217 | + if len(parts) == 2 { |
| 218 | + numbers = append(numbers, parts[0]) |
| 219 | + labels = append(labels, fmt.Sprintf("#%s: %s", parts[0], parts[1])) |
| 220 | + } |
| 221 | + } |
| 222 | + return numbers, labels, nil |
| 223 | +} |
| 224 | + |
| 225 | +func getOpenMRs() ([]string, []string, error) { |
| 226 | + cmd := exec.Command("glab", "mr", "list") |
| 227 | + output, err := cmd.Output() |
| 228 | + if err != nil { |
| 229 | + return nil, nil, err |
| 230 | + } |
| 231 | + |
| 232 | + var numbers []string |
| 233 | + var labels []string |
| 234 | + // Parse glab output: !123 title (branch) ← (target) |
| 235 | + mrRegex := regexp.MustCompile(`^!(\d+)\s+[^\s]+\s+(.+?)\s+\(`) |
| 236 | + for _, line := range strings.Split(string(output), "\n") { |
| 237 | + if matches := mrRegex.FindStringSubmatch(line); matches != nil { |
| 238 | + numbers = append(numbers, matches[1]) |
| 239 | + labels = append(labels, fmt.Sprintf("!%s: %s", matches[1], strings.TrimSpace(matches[2]))) |
| 240 | + } |
| 241 | + } |
| 242 | + return numbers, labels, nil |
| 243 | +} |
| 244 | + |
157 | 245 | // Commands |
158 | 246 |
|
159 | 247 | var checkoutCmd = &cobra.Command{ |
160 | | - Use: "checkout <branch>", |
| 248 | + Use: "checkout [branch]", |
161 | 249 | Aliases: []string{"co"}, |
162 | 250 | Short: "Checkout existing branch in new worktree", |
163 | | - Args: cobra.ExactArgs(1), |
| 251 | + Args: cobra.RangeArgs(0, 1), |
164 | 252 | RunE: func(cmd *cobra.Command, args []string) error { |
165 | | - branch := args[0] |
| 253 | + var branch string |
| 254 | + |
| 255 | + // Interactive selection if no branch provided |
| 256 | + if len(args) == 0 { |
| 257 | + branches, err := getAvailableBranches() |
| 258 | + if err != nil { |
| 259 | + return fmt.Errorf("failed to get branches: %w", err) |
| 260 | + } |
| 261 | + if len(branches) == 0 { |
| 262 | + return fmt.Errorf("no available branches to checkout") |
| 263 | + } |
| 264 | + |
| 265 | + prompt := promptui.Select{ |
| 266 | + Label: "Select branch to checkout", |
| 267 | + Items: branches, |
| 268 | + } |
| 269 | + _, result, err := prompt.Run() |
| 270 | + if err != nil { |
| 271 | + return fmt.Errorf("selection cancelled") |
| 272 | + } |
| 273 | + branch = result |
| 274 | + } else { |
| 275 | + branch = args[0] |
| 276 | + } |
166 | 277 | repo, err := getRepoName() |
167 | 278 | if err != nil { |
168 | 279 | return err |
@@ -236,36 +347,88 @@ var createCmd = &cobra.Command{ |
236 | 347 | } |
237 | 348 |
|
238 | 349 | var prCmd = &cobra.Command{ |
239 | | - Use: "pr <number|url>", |
| 350 | + Use: "pr [number|url]", |
240 | 351 | Short: "Checkout GitHub PR in worktree (uses gh CLI)", |
241 | 352 | Long: `Checkout a GitHub Pull Request in a worktree. |
242 | 353 |
|
243 | 354 | Uses the 'gh' CLI to fetch and checkout pull requests. |
244 | 355 | For GitLab Merge Requests, use 'wt mr' instead. |
245 | 356 |
|
246 | 357 | Examples: |
| 358 | + wt pr # Interactive PR selection |
247 | 359 | wt pr 123 # GitHub PR number |
248 | 360 | wt pr https://github.com/org/repo/pull/123 # GitHub PR URL`, |
249 | | - Args: cobra.ExactArgs(1), |
| 361 | + Args: cobra.RangeArgs(0, 1), |
250 | 362 | RunE: func(cmd *cobra.Command, args []string) error { |
251 | | - return checkoutPROrMR(args[0], RemoteGitHub) |
| 363 | + var input string |
| 364 | + |
| 365 | + // Interactive selection if no PR provided |
| 366 | + if len(args) == 0 { |
| 367 | + numbers, labels, err := getOpenPRs() |
| 368 | + if err != nil { |
| 369 | + return fmt.Errorf("failed to get PRs: %w (is 'gh' CLI installed?)", err) |
| 370 | + } |
| 371 | + if len(labels) == 0 { |
| 372 | + return fmt.Errorf("no open PRs found") |
| 373 | + } |
| 374 | + |
| 375 | + prompt := promptui.Select{ |
| 376 | + Label: "Select Pull Request", |
| 377 | + Items: labels, |
| 378 | + } |
| 379 | + idx, _, err := prompt.Run() |
| 380 | + if err != nil { |
| 381 | + return fmt.Errorf("selection cancelled") |
| 382 | + } |
| 383 | + input = numbers[idx] |
| 384 | + } else { |
| 385 | + input = args[0] |
| 386 | + } |
| 387 | + |
| 388 | + return checkoutPROrMR(input, RemoteGitHub) |
252 | 389 | }, |
253 | 390 | } |
254 | 391 |
|
255 | 392 | var mrCmd = &cobra.Command{ |
256 | | - Use: "mr <number|url>", |
| 393 | + Use: "mr [number|url]", |
257 | 394 | Short: "Checkout GitLab MR in worktree (uses glab CLI)", |
258 | 395 | Long: `Checkout a GitLab Merge Request in a worktree. |
259 | 396 |
|
260 | 397 | Uses the 'glab' CLI to fetch and checkout merge requests. |
261 | 398 | For GitHub Pull Requests, use 'wt pr' instead. |
262 | 399 |
|
263 | 400 | Examples: |
| 401 | + wt mr # Interactive MR selection |
264 | 402 | wt mr 123 # GitLab MR number |
265 | 403 | wt mr https://gitlab.com/org/repo/-/merge_requests/123 # GitLab MR URL`, |
266 | | - Args: cobra.ExactArgs(1), |
| 404 | + Args: cobra.RangeArgs(0, 1), |
267 | 405 | RunE: func(cmd *cobra.Command, args []string) error { |
268 | | - return checkoutPROrMR(args[0], RemoteGitLab) |
| 406 | + var input string |
| 407 | + |
| 408 | + // Interactive selection if no MR provided |
| 409 | + if len(args) == 0 { |
| 410 | + numbers, labels, err := getOpenMRs() |
| 411 | + if err != nil { |
| 412 | + return fmt.Errorf("failed to get MRs: %w (is 'glab' CLI installed?)", err) |
| 413 | + } |
| 414 | + if len(labels) == 0 { |
| 415 | + return fmt.Errorf("no open MRs found") |
| 416 | + } |
| 417 | + |
| 418 | + prompt := promptui.Select{ |
| 419 | + Label: "Select Merge Request", |
| 420 | + Items: labels, |
| 421 | + } |
| 422 | + idx, _, err := prompt.Run() |
| 423 | + if err != nil { |
| 424 | + return fmt.Errorf("selection cancelled") |
| 425 | + } |
| 426 | + input = numbers[idx] |
| 427 | + } else { |
| 428 | + input = args[0] |
| 429 | + } |
| 430 | + |
| 431 | + return checkoutPROrMR(input, RemoteGitLab) |
269 | 432 | }, |
270 | 433 | } |
271 | 434 |
|
@@ -340,12 +503,35 @@ var listCmd = &cobra.Command{ |
340 | 503 | } |
341 | 504 |
|
342 | 505 | var removeCmd = &cobra.Command{ |
343 | | - Use: "remove <branch>", |
| 506 | + Use: "remove [branch]", |
344 | 507 | Aliases: []string{"rm"}, |
345 | 508 | Short: "Remove a worktree", |
346 | | - Args: cobra.ExactArgs(1), |
| 509 | + Args: cobra.RangeArgs(0, 1), |
347 | 510 | RunE: func(cmd *cobra.Command, args []string) error { |
348 | | - branch := args[0] |
| 511 | + var branch string |
| 512 | + |
| 513 | + // Interactive selection if no branch provided |
| 514 | + if len(args) == 0 { |
| 515 | + branches, err := getExistingWorktreeBranches() |
| 516 | + if err != nil { |
| 517 | + return fmt.Errorf("failed to get worktrees: %w", err) |
| 518 | + } |
| 519 | + if len(branches) == 0 { |
| 520 | + return fmt.Errorf("no worktrees to remove") |
| 521 | + } |
| 522 | + |
| 523 | + prompt := promptui.Select{ |
| 524 | + Label: "Select worktree to remove", |
| 525 | + Items: branches, |
| 526 | + } |
| 527 | + _, result, err := prompt.Run() |
| 528 | + if err != nil { |
| 529 | + return fmt.Errorf("selection cancelled") |
| 530 | + } |
| 531 | + branch = result |
| 532 | + } else { |
| 533 | + branch = args[0] |
| 534 | + } |
349 | 535 |
|
350 | 536 | existingPath, exists := worktreeExists(branch) |
351 | 537 | if !exists { |
|
0 commit comments