bitp 1.0.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,889 @@
1
+ #
2
+ # Copyright (C) 2025 Bruce Ashfield <bruce.ashfield@gmail.com>
3
+ #
4
+ # SPDX-License-Identifier: GPL-2.0-only
5
+ #
6
+ """Branch command - view and switch branches across repos."""
7
+
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ from typing import Dict, List, Optional, Set, Tuple
14
+
15
+ from ..core import (
16
+ Colors,
17
+ current_branch,
18
+ current_head,
19
+ fzf_available,
20
+ get_fzf_color_args,
21
+ get_fzf_preview_resize_bindings,
22
+ load_defaults,
23
+ repo_is_clean,
24
+ save_defaults,
25
+ )
26
+ from .common import (
27
+ collect_repos,
28
+ repo_display_name,
29
+ prompt_action,
30
+ run_cmd,
31
+ create_pull_branch,
32
+ copy_to_clipboard,
33
+ )
34
+
35
+ def fzf_branch_repos(repos: List[str]) -> int:
36
+ """Interactive fzf-based branch management. Returns exit code."""
37
+ if not repos:
38
+ print("No repos found.")
39
+ return 1
40
+
41
+ def get_branches(repo: str) -> List[str]:
42
+ """Get list of local and remote branches for a repo."""
43
+ try:
44
+ # Get local branches
45
+ local = subprocess.check_output(
46
+ ["git", "-C", repo, "branch", "--format=%(refname:short)"],
47
+ text=True,
48
+ stderr=subprocess.DEVNULL,
49
+ ).strip().splitlines()
50
+
51
+ # Get remote branches (strip origin/ prefix for display)
52
+ remote = subprocess.check_output(
53
+ ["git", "-C", repo, "branch", "-r", "--format=%(refname:short)"],
54
+ text=True,
55
+ stderr=subprocess.DEVNULL,
56
+ ).strip().splitlines()
57
+
58
+ # Combine: local first, then remote (excluding HEAD)
59
+ branches = local[:]
60
+ for r in remote:
61
+ if r.endswith("/HEAD"):
62
+ continue
63
+ # Strip origin/ prefix if it matches a local branch
64
+ short = r.replace("origin/", "", 1) if r.startswith("origin/") else r
65
+ if short not in branches:
66
+ branches.append(r) # Keep full name for remote-only
67
+
68
+ return branches
69
+ except subprocess.CalledProcessError:
70
+ return []
71
+
72
+ def checkout_branch(repo: str, target: str) -> Tuple[bool, str]:
73
+ """Checkout a branch in a repo. Returns (success, message)."""
74
+ if not repo_is_clean(repo):
75
+ return False, "dirty (has uncommitted changes)"
76
+
77
+ # Handle origin/branch format
78
+ if target.startswith("origin/"):
79
+ local_name = target[7:] # Strip origin/
80
+ # Check if local branch exists
81
+ local_exists = subprocess.run(
82
+ ["git", "-C", repo, "rev-parse", "--verify", local_name],
83
+ stdout=subprocess.DEVNULL,
84
+ stderr=subprocess.DEVNULL,
85
+ ).returncode == 0
86
+ if local_exists:
87
+ target = local_name
88
+ else:
89
+ # Create tracking branch
90
+ try:
91
+ subprocess.run(
92
+ ["git", "-C", repo, "checkout", "-b", local_name, "--track", target],
93
+ check=True,
94
+ capture_output=True,
95
+ text=True,
96
+ )
97
+ return True, f"created and switched to {local_name} (tracking {target})"
98
+ except subprocess.CalledProcessError as e:
99
+ return False, e.stderr.strip() or "checkout failed"
100
+
101
+ try:
102
+ subprocess.run(
103
+ ["git", "-C", repo, "checkout", target],
104
+ check=True,
105
+ capture_output=True,
106
+ text=True,
107
+ )
108
+ return True, f"switched to {target}"
109
+ except subprocess.CalledProcessError as e:
110
+ return False, e.stderr.strip() or "checkout failed"
111
+
112
+ def show_branch_picker(repo: str) -> Optional[str]:
113
+ """Show fzf picker for branches. Returns selected branch or None."""
114
+ display = repo_display_name(repo)
115
+ current = current_branch(repo) or ""
116
+ branches = get_branches(repo)
117
+
118
+ if not branches:
119
+ print(f"\n No branches found in {display}")
120
+ return None
121
+
122
+ menu_lines = []
123
+ for branch in branches:
124
+ if branch == current:
125
+ menu_lines.append(f"{branch}\t● {branch} (current)")
126
+ elif branch.startswith("origin/"):
127
+ menu_lines.append(f"{branch}\t {branch} (remote)")
128
+ else:
129
+ menu_lines.append(f"{branch}\t {branch}")
130
+
131
+ try:
132
+ result = subprocess.run(
133
+ [
134
+ "fzf",
135
+ "--no-multi",
136
+ "--no-sort",
137
+ "--no-info",
138
+ "--height", "~50%",
139
+ "--header", f"Switch {display} to: (←=back)",
140
+ "--prompt", "Branch: ",
141
+ "--with-nth", "2..",
142
+ "--delimiter", "\t",
143
+ "--bind", "esc:become(echo BACK)",
144
+ "--bind", "left:become(echo BACK)",
145
+ "--bind", "q:become(echo BACK)",
146
+ ] + get_fzf_color_args(),
147
+ input="\n".join(menu_lines),
148
+ stdout=subprocess.PIPE,
149
+ text=True,
150
+ )
151
+ except FileNotFoundError:
152
+ return None
153
+
154
+ if result.returncode != 0 or not result.stdout.strip():
155
+ return None
156
+
157
+ output = result.stdout.strip()
158
+ if output == "BACK":
159
+ return None
160
+
161
+ # Extract branch name (first field)
162
+ return output.split("\t")[0]
163
+
164
+ def build_menu_lines() -> str:
165
+ """Build fzf menu input with header as last line (appears at top in fzf)."""
166
+ max_name_len = 20
167
+
168
+ # First pass to get max name length
169
+ for repo in repos:
170
+ display = repo_display_name(repo)
171
+ if len(display) > max_name_len:
172
+ max_name_len = len(display)
173
+
174
+ # Data lines
175
+ data_lines = []
176
+ for idx, repo in enumerate(repos, start=1):
177
+ display = repo_display_name(repo)
178
+ branch = current_branch(repo) or "(detached)"
179
+ is_clean = repo_is_clean(repo)
180
+ status = f"{Colors.green('[clean]')}" if is_clean else f"{Colors.red('[DIRTY]')}"
181
+ line = f"{repo}\t{idx:<4} {display:<{max_name_len}} {branch:<20} {status}"
182
+ data_lines.append(line)
183
+
184
+ # Reverse so item N is first (appears at bottom), item 1 near end
185
+ menu_lines = list(reversed(data_lines))
186
+
187
+ # Column header as LAST line (appears at TOP in default fzf layout)
188
+ header_line = f"HEADER\t{'#':<4} {'Name':<{max_name_len}} {'Branch':<20} Status"
189
+ # Separator line under header (second-to-last, appears just below header)
190
+ sep_len = 4 + 1 + max_name_len + 1 + 20 + 1 + 8 # rough width matching columns
191
+ separator = f"SEPARATOR\t{'─' * sep_len}"
192
+ menu_lines.append(separator)
193
+ menu_lines.append(header_line)
194
+
195
+ return "\n".join(menu_lines)
196
+
197
+ header = "Enter/→=switch branch | B=switch all | q=quit"
198
+
199
+ def switch_all_repos(target_branch: str) -> None:
200
+ """Switch all repos to the same branch."""
201
+ print()
202
+ for repo in repos:
203
+ display = repo_display_name(repo)
204
+ current = current_branch(repo)
205
+ if current == target_branch:
206
+ print(f" {display}: already on {target_branch}")
207
+ continue
208
+ success, msg = checkout_branch(repo, target_branch)
209
+ if success:
210
+ print(f" {Colors.green(display)}: {msg}")
211
+ else:
212
+ print(f" {display}: {Colors.red(msg)}")
213
+ print()
214
+
215
+ while True:
216
+ menu_input = build_menu_lines()
217
+
218
+ try:
219
+ result = subprocess.run(
220
+ [
221
+ "fzf",
222
+ "--no-multi",
223
+ "--no-sort",
224
+ "--no-info",
225
+ "--ansi",
226
+ "--height", "~50%",
227
+ "--header", header,
228
+ "--prompt", "Branch: ",
229
+ "--with-nth", "2..",
230
+ "--delimiter", "\t",
231
+ "--bind", "q:become(echo QUIT)",
232
+ "--bind", "B:become(echo BRANCH_ALL)",
233
+ "--bind", "right:accept",
234
+ ] + get_fzf_color_args(),
235
+ input=menu_input,
236
+ stdout=subprocess.PIPE,
237
+ text=True,
238
+ )
239
+ except FileNotFoundError:
240
+ print("fzf not found. Use CLI: bit branch <repo> <branch>")
241
+ return 1
242
+
243
+ if result.returncode != 0 or not result.stdout.strip():
244
+ break
245
+
246
+ output = result.stdout.strip()
247
+
248
+ if output == "QUIT":
249
+ break
250
+ elif output == "BRANCH_ALL":
251
+ # Prompt for branch name and switch all repos
252
+ print()
253
+ try:
254
+ target_branch = input("Switch all repos to branch: ").strip()
255
+ except (EOFError, KeyboardInterrupt):
256
+ print("\nCancelled.")
257
+ continue
258
+ if not target_branch:
259
+ print("No branch specified.")
260
+ continue
261
+ switch_all_repos(target_branch)
262
+ continue
263
+ elif "\t" in output:
264
+ # Enter/right was pressed - show branch picker (ignore header/separator lines)
265
+ repo_path = output.split("\t")[0]
266
+ if repo_path in ("HEADER", "SEPARATOR"):
267
+ continue
268
+ selected_branch = show_branch_picker(repo_path)
269
+ if selected_branch:
270
+ display = repo_display_name(repo_path)
271
+ current = current_branch(repo_path)
272
+ if selected_branch == current:
273
+ print(f"\n {display}: already on {selected_branch}")
274
+ else:
275
+ success, msg = checkout_branch(repo_path, selected_branch)
276
+ if success:
277
+ print(f"\n {Colors.green(display)}: {msg}")
278
+ else:
279
+ print(f"\n {display}: {Colors.red(msg)}")
280
+
281
+ return 0
282
+
283
+
284
+
285
+ def run_branch(args) -> int:
286
+ """View and switch branches across repos."""
287
+ defaults = load_defaults(args.defaults_file)
288
+ repos, _repo_sets = collect_repos(args.bblayers, defaults)
289
+
290
+ def checkout_branch(repo: str, target: str) -> Tuple[bool, str]:
291
+ """Checkout a branch in a repo. Returns (success, message)."""
292
+ if not repo_is_clean(repo):
293
+ return False, "dirty (has uncommitted changes)"
294
+
295
+ # Check if branch exists locally
296
+ local_exists = subprocess.run(
297
+ ["git", "-C", repo, "rev-parse", "--verify", target],
298
+ stdout=subprocess.DEVNULL,
299
+ stderr=subprocess.DEVNULL,
300
+ ).returncode == 0
301
+
302
+ # Check if branch exists on origin
303
+ remote_ref = f"origin/{target}"
304
+ remote_exists = subprocess.run(
305
+ ["git", "-C", repo, "rev-parse", "--verify", remote_ref],
306
+ stdout=subprocess.DEVNULL,
307
+ stderr=subprocess.DEVNULL,
308
+ ).returncode == 0
309
+
310
+ if not local_exists and not remote_exists:
311
+ return False, f"branch '{target}' not found locally or on origin"
312
+
313
+ try:
314
+ subprocess.run(
315
+ ["git", "-C", repo, "checkout", target],
316
+ check=True,
317
+ capture_output=True,
318
+ text=True,
319
+ )
320
+ return True, f"switched to {target}"
321
+ except subprocess.CalledProcessError as e:
322
+ return False, e.stderr.strip() or "checkout failed"
323
+
324
+ # Handle --all mode
325
+ if args.all_repos:
326
+ if not args.target_branch:
327
+ print("Error: --all requires a branch name")
328
+ print("Usage: bit branch --all <branch>")
329
+ return 1
330
+
331
+ print(f"Switching all repos to branch: {args.target_branch}\n")
332
+ had_errors = False
333
+ for repo in repos:
334
+ display = repo_display_name(repo)
335
+ success, msg = checkout_branch(repo, args.target_branch)
336
+ if success:
337
+ print(f"→ {Colors.green(display)}: {msg}")
338
+ else:
339
+ print(f"→ {display}: {Colors.red(msg)}")
340
+ had_errors = True
341
+ return 1 if had_errors else 0
342
+
343
+ # If no repo specified, use fzf interactive interface
344
+ if args.repo is None:
345
+ return fzf_branch_repos(repos)
346
+
347
+ # Find the target repo by index, display name, or path
348
+ target_repo = None
349
+ try:
350
+ idx = int(args.repo)
351
+ if 1 <= idx <= len(repos):
352
+ target_repo = repos[idx - 1]
353
+ else:
354
+ print(f"Invalid index {idx}. Valid range: 1-{len(repos)}")
355
+ return 1
356
+ except ValueError:
357
+ # Try matching by display name first
358
+ for repo in repos:
359
+ if repo_display_name(repo).lower() == args.repo.lower():
360
+ target_repo = repo
361
+ break
362
+ # Then try as path
363
+ if not target_repo and os.path.isdir(args.repo):
364
+ target_repo = os.path.abspath(args.repo)
365
+ # Finally try partial path match
366
+ if not target_repo:
367
+ for repo in repos:
368
+ if args.repo in repo or repo.endswith(args.repo):
369
+ target_repo = repo
370
+ break
371
+
372
+ if not target_repo:
373
+ print(f"Repo not found: {args.repo}")
374
+ return 1
375
+
376
+ # If no branch specified, show current branch for this repo
377
+ if args.target_branch is None:
378
+ display = repo_display_name(target_repo)
379
+ branch = current_branch(target_repo) or "(detached)"
380
+ is_clean = repo_is_clean(target_repo)
381
+ status = Colors.green("[clean]") if is_clean else Colors.red("[DIRTY]")
382
+ print(f"Repo: {target_repo}")
383
+ print(f"Display name: {display}")
384
+ print(f"Branch: {Colors.bold(branch)} {status}")
385
+ return 0
386
+
387
+ # Switch to the specified branch
388
+ display = repo_display_name(target_repo)
389
+ success, msg = checkout_branch(target_repo, args.target_branch)
390
+ if success:
391
+ print(f"→ {Colors.green(display)}: {msg}")
392
+ return 0
393
+ else:
394
+ print(f"→ {display}: {Colors.red(msg)}")
395
+ return 1
396
+
397
+
398
+ # ------------------------ Prepare Export ------------------------
399
+
400
+
401
+ def get_local_commits(repo: str, branch: str) -> Tuple[List[Tuple[str, str]], Optional[str]]:
402
+ """
403
+ Get local commits between origin/<branch> and HEAD.
404
+ Returns (list of (hash, subject) tuples in chronological order oldest-first, base_ref or None).
405
+ """
406
+ remote_ref = f"origin/{branch}"
407
+ remote_exists = subprocess.run(
408
+ ["git", "-C", repo, "rev-parse", "--verify", remote_ref],
409
+ stdout=subprocess.DEVNULL,
410
+ stderr=subprocess.DEVNULL,
411
+ ).returncode == 0
412
+
413
+ if not remote_exists:
414
+ return [], None
415
+
416
+ try:
417
+ # Get commits oldest-first (--reverse)
418
+ output = subprocess.check_output(
419
+ ["git", "-C", repo, "log", "--reverse", "--format=%H %s", f"{remote_ref}..HEAD"],
420
+ text=True,
421
+ )
422
+ except subprocess.CalledProcessError:
423
+ return [], remote_ref
424
+
425
+ commits = []
426
+ for line in output.strip().splitlines():
427
+ if line:
428
+ parts = line.split(" ", 1)
429
+ if len(parts) == 2:
430
+ commits.append((parts[0], parts[1]))
431
+ elif len(parts) == 1:
432
+ commits.append((parts[0], ""))
433
+
434
+ return commits, remote_ref
435
+
436
+
437
+
438
+ def prompt_branch_name(prompt: str = "Branch name for PR (Enter to skip): ") -> Optional[str]:
439
+ """Prompt user for branch name. Returns None if skipped."""
440
+ try:
441
+ name = input(prompt).strip()
442
+ return name if name else None
443
+ except (EOFError, KeyboardInterrupt):
444
+ print() # Newline after ^C
445
+ return None
446
+
447
+
448
+
449
+ def fzf_multiselect_commits(
450
+ repo: str,
451
+ branch: str,
452
+ commits: List[Tuple[str, str]],
453
+ base_ref: str = "",
454
+ header_text: str = "",
455
+ skip_branch_prompt: bool = False,
456
+ ) -> Optional[Tuple[List[str], str, bool]]:
457
+ """
458
+ Use fzf to multi-select commits for upstream grouping.
459
+ Supports:
460
+ - Tab: range selection (first Tab = start, second Tab = end, selects all between)
461
+ - Space: toggle individual commits
462
+ - b: request branch name prompt after selection
463
+ Returns (selected_hashes in oldest-first order, action, branch_mode, want_backup) where action is:
464
+ - "selected": User made selections
465
+ - "skip": User chose to skip this repo
466
+ - "skip_rest": User chose to skip all remaining repos
467
+ - None if cancelled (Escape pressed)
468
+ branch_mode is None, "create" (b key), or "replace" (B key)
469
+ want_backup is True if user pressed '!' for backup
470
+ """
471
+ if not commits:
472
+ return ([], "skip", None, False)
473
+
474
+ display_name = repo_display_name(repo)
475
+
476
+ # Get upstream commits for context (dimmed)
477
+ upstream_commits = []
478
+ if base_ref:
479
+ upstream_commits = get_upstream_context_commits(repo, base_ref, count=3)
480
+
481
+ # Build menu: upstream context -> separator -> local commits
482
+ # fzf with --height reverses display, so input order = bottom-to-top display
483
+ menu_lines = []
484
+
485
+ # Upstream commits for context (will appear at bottom, dimmed)
486
+ if upstream_commits:
487
+ for hash_val, subject in reversed(upstream_commits):
488
+ dimmed = Colors.dim(f" {hash_val[:12]} {subject[:70]}")
489
+ menu_lines.append(dimmed)
490
+
491
+ # Separator
492
+ menu_lines.append("────────────────────────────────────────")
493
+
494
+ # Local commits in green (will appear at top after fzf reversal)
495
+ for hash_val, subject in commits:
496
+ green_line = Colors.green(f" {hash_val[:12]} {subject[:70]}")
497
+ menu_lines.append(green_line)
498
+
499
+ # Menu options at very top
500
+ menu_options = [
501
+ f"►► Select ALL {len(commits)} commits for upstream",
502
+ "►► Select NONE (skip this repo)",
503
+ "── [S] Skip all remaining repos ──",
504
+ "────────────────────────────────────────",
505
+ ]
506
+
507
+ menu_input = "\n".join(menu_options) + "\n" + "\n".join(menu_lines)
508
+
509
+ header = f"Repo: {Colors.bold(display_name)} Branch: {Colors.bold(branch)}\n"
510
+ header += f"Local commits: {len(commits)}\n"
511
+ if header_text:
512
+ header += header_text + "\n"
513
+ if skip_branch_prompt:
514
+ header += "Space=range | Tab=single | !=backup | ?=preview | Enter=confirm"
515
+ else:
516
+ header += "Space=range | Tab=single | b/B=branch | !=backup | ?=preview | Enter"
517
+
518
+ # Temp files for tracking fzf interactions
519
+ range_file = f"/tmp/fzf_range_{os.getpid()}"
520
+ branch_file = f"/tmp/fzf_branch_{os.getpid()}"
521
+ backup_file = f"/tmp/fzf_backup_{os.getpid()}"
522
+ if os.path.exists(range_file):
523
+ os.remove(range_file)
524
+ if os.path.exists(branch_file):
525
+ os.remove(branch_file)
526
+ if os.path.exists(backup_file):
527
+ os.remove(backup_file)
528
+
529
+ # Preview command to show commit details
530
+ # {1} is the first field (hash), git show fails gracefully for non-commits
531
+ preview_cmd = f'git -C {repo} show --stat --color=always {{1}} 2>/dev/null || echo "Select a commit to preview"'
532
+
533
+ # Shell script to build combined prompt showing branch, backup, and range marker state
534
+ prompt_script = (
535
+ f'br=; bk=; rng=; '
536
+ f'[ -f {branch_file} ] && {{ c=$(cat {branch_file}); [ "$c" = B ] && br="[+BRANCH]" || br="[+branch]"; }}; '
537
+ f'[ -f {backup_file} ] && bk="[!backup]"; '
538
+ f'[ -f {range_file} ] && {{ n=$(wc -l < {range_file}); [ "$n" -gt 0 ] && rng="[range:$n]"; }}; '
539
+ f'echo "Select$br$bk$rng: "'
540
+ )
541
+
542
+ # Build fzf command arguments
543
+ fzf_args = [
544
+ "fzf",
545
+ "--multi",
546
+ "--no-sort",
547
+ "--ansi",
548
+ "--height", "~60%",
549
+ "--header", header,
550
+ "--prompt", "Select for upstream: ",
551
+ "--marker", "> ",
552
+ "--info", "inline",
553
+ "--preview", preview_cmd,
554
+ "--preview-window", "down,50%,hidden,wrap",
555
+ "--bind", "?:toggle-preview",
556
+ "--bind", "ctrl-d:preview-half-page-down",
557
+ "--bind", "ctrl-u:preview-half-page-up",
558
+ "--bind", "page-down:preview-page-down",
559
+ "--bind", "page-up:preview-page-up",
560
+ "--bind", "tab:toggle",
561
+ "--bind", f"space:toggle+execute-silent(echo {{}} >> {range_file})+transform-prompt({prompt_script})+down",
562
+ "--bind", "s:become(echo SKIP_THIS)",
563
+ "--bind", "S:become(echo SKIP_REST)",
564
+ ]
565
+
566
+ # Add 'b'/'B' bindings to flag branch creation request (doesn't exit fzf)
567
+ # 'b' = create branch (skip if exists), 'B' = replace existing branch
568
+ if not skip_branch_prompt:
569
+ fzf_args.extend([
570
+ "--bind", f"b:execute-silent(echo b > {branch_file})+transform-prompt({prompt_script})",
571
+ "--bind", f"B:execute-silent(echo B > {branch_file})+transform-prompt({prompt_script})",
572
+ ])
573
+
574
+ # Add '!' binding to toggle backup mode
575
+ fzf_args.extend([
576
+ "--bind", f"!:execute-silent(touch {backup_file})+transform-prompt({prompt_script})",
577
+ ])
578
+
579
+ # Add preview window resize bindings and theme colors
580
+ fzf_args.extend(get_fzf_preview_resize_bindings())
581
+ fzf_args.extend(get_fzf_color_args())
582
+
583
+ try:
584
+ result = subprocess.run(
585
+ fzf_args,
586
+ input=menu_input,
587
+ stdout=subprocess.PIPE,
588
+ text=True,
589
+ )
590
+ except FileNotFoundError:
591
+ if os.path.exists(range_file):
592
+ os.remove(range_file)
593
+ if os.path.exists(branch_file):
594
+ os.remove(branch_file)
595
+ if os.path.exists(backup_file):
596
+ os.remove(backup_file)
597
+ return None # fzf not found
598
+
599
+ # Read range markers if any
600
+ range_markers = []
601
+ if os.path.exists(range_file):
602
+ with open(range_file) as f:
603
+ range_markers = [line.strip() for line in f.readlines() if line.strip()]
604
+ os.remove(range_file)
605
+
606
+ # Check if user pressed 'b' or 'B' for branch creation
607
+ # 'b' = create (skip if exists), 'B' = replace (delete if exists)
608
+ branch_mode = None # None, "create", or "replace"
609
+ if os.path.exists(branch_file):
610
+ with open(branch_file) as f:
611
+ content = f.read().strip()
612
+ os.remove(branch_file)
613
+ if content == "B":
614
+ branch_mode = "replace"
615
+ elif content == "b":
616
+ branch_mode = "create"
617
+
618
+ # Check if user pressed '!' for backup
619
+ want_backup = os.path.exists(backup_file)
620
+ if os.path.exists(backup_file):
621
+ os.remove(backup_file)
622
+
623
+ if result.returncode != 0 or not result.stdout.strip():
624
+ return None # Cancelled with Escape
625
+
626
+ selected_lines = result.stdout.strip().splitlines()
627
+
628
+ # Check for special outputs from keybindings
629
+ if len(selected_lines) == 1:
630
+ line = selected_lines[0].strip()
631
+ if line == "SKIP_THIS":
632
+ return ([], "skip", branch_mode, want_backup)
633
+ if line == "SKIP_REST":
634
+ return ([], "skip_rest", branch_mode, want_backup)
635
+
636
+ # Check for menu options
637
+ for line in selected_lines:
638
+ if "Select ALL" in line:
639
+ return ([h for h, _ in commits], "selected", branch_mode, want_backup)
640
+ if "Select NONE" in line or "skip this repo" in line.lower():
641
+ return ([], "skip", branch_mode, want_backup)
642
+ if "Skip all remaining" in line:
643
+ return ([], "skip_rest", branch_mode, want_backup)
644
+
645
+ # Extract selected commit hashes
646
+ hash_set = {h[:12]: h for h, _ in commits} # Map short hash to full hash
647
+ # Also create index mapping for range calculation
648
+ hash_to_idx = {h[:12]: i for i, (h, _) in enumerate(commits)}
649
+
650
+ selected_hashes = set()
651
+
652
+ # Process range markers (Tab presses) - fill in all commits between first and last
653
+ if len(range_markers) >= 2:
654
+ # Extract hashes from range markers
655
+ range_hashes = []
656
+ for marker in range_markers:
657
+ parts = marker.strip().split(maxsplit=1)
658
+ if parts and parts[0] in hash_set:
659
+ range_hashes.append(parts[0])
660
+
661
+ if len(range_hashes) >= 2:
662
+ # Get indices of first and last range marker
663
+ first_hash = range_hashes[0]
664
+ last_hash = range_hashes[-1]
665
+ if first_hash in hash_to_idx and last_hash in hash_to_idx:
666
+ start_idx = hash_to_idx[first_hash]
667
+ end_idx = hash_to_idx[last_hash]
668
+ # Ensure start <= end
669
+ if start_idx > end_idx:
670
+ start_idx, end_idx = end_idx, start_idx
671
+ # Add all commits in range
672
+ for i in range(start_idx, end_idx + 1):
673
+ selected_hashes.add(commits[i][0])
674
+
675
+ # Process space-selected commits (from fzf output)
676
+ for line in selected_lines:
677
+ stripped = line.strip()
678
+ if stripped.startswith("►") or stripped.startswith("─"):
679
+ continue # Skip menu items
680
+ parts = stripped.split(maxsplit=1)
681
+ if parts and parts[0] in hash_set:
682
+ selected_hashes.add(hash_set[parts[0]])
683
+
684
+ # Return in original (oldest-first) order
685
+ selected_ordered = [h for h, _ in commits if h in selected_hashes]
686
+ return (selected_ordered, "selected", branch_mode, want_backup)
687
+
688
+
689
+
690
+ def get_upstream_context_commits(repo: str, base_ref: str, count: int = 5) -> List[Tuple[str, str]]:
691
+ """Get recent commits from upstream for context display."""
692
+ try:
693
+ output = subprocess.check_output(
694
+ ["git", "-C", repo, "log", "--format=%H %s", f"-n{count}", base_ref],
695
+ text=True,
696
+ stderr=subprocess.DEVNULL,
697
+ )
698
+ except subprocess.CalledProcessError:
699
+ return []
700
+
701
+ commits = []
702
+ for line in output.strip().splitlines():
703
+ if line:
704
+ parts = line.split(" ", 1)
705
+ if len(parts) == 2:
706
+ commits.append((parts[0], parts[1]))
707
+ elif len(parts) == 1:
708
+ commits.append((parts[0], ""))
709
+ return commits
710
+
711
+
712
+
713
+ def get_upstream_to_pull(repo: str, branch: str, count: int = 50) -> List[Tuple[str, str]]:
714
+ """Get commits in origin/<branch> that are not in HEAD (commits to pull)."""
715
+ remote_ref = f"origin/{branch}"
716
+ try:
717
+ output = subprocess.check_output(
718
+ ["git", "-C", repo, "log", "--format=%H %s", f"-n{count}", f"HEAD..{remote_ref}"],
719
+ text=True,
720
+ stderr=subprocess.DEVNULL,
721
+ )
722
+ except subprocess.CalledProcessError:
723
+ return []
724
+
725
+ commits = []
726
+ for line in output.strip().splitlines():
727
+ if line:
728
+ parts = line.split(" ", 1)
729
+ if len(parts) == 2:
730
+ commits.append((parts[0], parts[1]))
731
+ elif len(parts) == 1:
732
+ commits.append((parts[0], ""))
733
+ return commits
734
+
735
+
736
+
737
+ def fzf_select_insertion_point(
738
+ repo: str,
739
+ branch: str,
740
+ base_ref: str,
741
+ remaining_commits: List[Tuple[str, str]],
742
+ selected_commits: List[Tuple[str, str]] = None,
743
+ current_branch_mode: Optional[str] = None,
744
+ ) -> Tuple[Optional[str], Optional[str]]:
745
+ """
746
+ Use fzf to select insertion point for upstream commits.
747
+ Shows a unified view: local commits (newest first), separator, upstream context.
748
+ Selected commits are marked with ">>" prefix.
749
+ Returns (insertion_point, branch_mode):
750
+ - insertion_point: base_ref, a commit hash, or None if cancelled
751
+ - branch_mode: None, "create", or "replace" (can be set/changed here with b/B)
752
+ """
753
+ display_name = repo_display_name(repo)
754
+ selected_commits = selected_commits or []
755
+ selected_set = {h for h, _ in selected_commits}
756
+ num_selected = len(selected_commits)
757
+
758
+ # Get upstream commits for context
759
+ upstream_commits = get_upstream_context_commits(repo, base_ref)
760
+
761
+ # Temp file for branch mode (may already have value from selection screen)
762
+ branch_file = f"/tmp/fzf_branch_{os.getpid()}"
763
+ if current_branch_mode:
764
+ with open(branch_file, "w") as f:
765
+ f.write("B" if current_branch_mode == "replace" else "b")
766
+
767
+ # Build display for fzf
768
+ # Show commits in the order they'll be AFTER reordering:
769
+ # upstream context -> selected commits (>>) -> default/separator -> remaining local
770
+ menu_lines = []
771
+
772
+ # Upstream commits for context (dimmed)
773
+ if upstream_commits:
774
+ for hash_val, subject in reversed(upstream_commits):
775
+ dimmed = Colors.dim(f" {hash_val[:12]} {subject[:70]}")
776
+ menu_lines.append(dimmed)
777
+
778
+ # Selected commits (will be moved here after reorder) - in green
779
+ for hash_val, subject in selected_commits:
780
+ green_line = Colors.green(f">> {hash_val[:12]} {subject[:70]}")
781
+ menu_lines.append(green_line)
782
+
783
+ # Default insertion point and separator (the cut line)
784
+ menu_lines.append(f"►► {base_ref} (default - insert here)")
785
+ menu_lines.append("────────────────────────────────────────")
786
+
787
+ # Remaining local commits (not selected)
788
+ for hash_val, subject in remaining_commits:
789
+ menu_lines.append(f" {hash_val[:12]} {subject[:70]}")
790
+
791
+ menu_input = "\n".join(menu_lines)
792
+
793
+ # Position of default line: upstream + selected + 1 (1-indexed for fzf)
794
+ num_upstream = len(upstream_commits) if upstream_commits else 0
795
+ num_selected = len(selected_commits)
796
+ default_line_pos = num_upstream + num_selected + 1
797
+
798
+ # Shell script for dynamic prompt showing branch mode
799
+ prompt_script = (
800
+ f'br=; '
801
+ f'[ -f {branch_file} ] && {{ c=$(cat {branch_file}); [ "$c" = B ] && br="[+BRANCH]" || br="[+branch]"; }}; '
802
+ f'echo "Insert {num_selected}$br after: "'
803
+ )
804
+
805
+ # Build initial prompt based on current branch mode
806
+ if current_branch_mode == "replace":
807
+ initial_prompt = f"Insert {num_selected}[+BRANCH] after: "
808
+ elif current_branch_mode == "create":
809
+ initial_prompt = f"Insert {num_selected}[+branch] after: "
810
+ else:
811
+ initial_prompt = f"Insert {num_selected} after: "
812
+
813
+ header = f"Repo: {Colors.bold(display_name)} Branch: {Colors.bold(branch)}\n"
814
+ header += f"Selected: {num_selected} commits (marked with >>)\n"
815
+ header += "b=branch | B=replace branch | Select insertion point"
816
+
817
+ try:
818
+ result = subprocess.run(
819
+ [
820
+ "fzf",
821
+ "--no-multi",
822
+ "--no-sort",
823
+ "--ansi",
824
+ "--layout=reverse-list",
825
+ "--height", "~50%",
826
+ "--header", header,
827
+ "--prompt", initial_prompt,
828
+ "--sync",
829
+ "--bind", f"load:pos({default_line_pos})",
830
+ "--bind", f"b:execute-silent(echo b > {branch_file})+transform-prompt({prompt_script})",
831
+ "--bind", f"B:execute-silent(echo B > {branch_file})+transform-prompt({prompt_script})",
832
+ ] + get_fzf_color_args(),
833
+ input=menu_input,
834
+ stdout=subprocess.PIPE,
835
+ text=True,
836
+ )
837
+ except FileNotFoundError:
838
+ return base_ref, current_branch_mode # Default if fzf not available
839
+
840
+ # Read branch mode from file
841
+ branch_mode = current_branch_mode
842
+ if os.path.exists(branch_file):
843
+ with open(branch_file) as f:
844
+ content = f.read().strip()
845
+ if content == "B":
846
+ branch_mode = "replace"
847
+ elif content == "b":
848
+ branch_mode = "create"
849
+ os.remove(branch_file)
850
+
851
+ if result.returncode != 0 or not result.stdout.strip():
852
+ return None, branch_mode # Cancelled
853
+
854
+ selected = result.stdout.strip()
855
+
856
+ # Check if default was selected
857
+ if base_ref in selected:
858
+ return base_ref, branch_mode
859
+
860
+ # Strip whitespace and ignore separator line
861
+ selected = selected.strip()
862
+ if selected.startswith("───"):
863
+ return base_ref, branch_mode
864
+
865
+ # Extract commit hash
866
+ parts = selected.split(maxsplit=1)
867
+ if parts:
868
+ # Handle ">>" prefix for selected commits
869
+ hash_part = parts[0].lstrip(">").strip()
870
+ # Find full hash in remaining commits (local)
871
+ for h, _ in remaining_commits:
872
+ if h[:12] == hash_part:
873
+ return h, branch_mode
874
+ # Check selected commits too
875
+ for h, _ in selected_commits:
876
+ if h[:12] == hash_part:
877
+ return h, branch_mode
878
+ # If selected an upstream commit, treat as default
879
+ for h, _ in upstream_commits:
880
+ if h[:12] == hash_part:
881
+ return base_ref, branch_mode
882
+
883
+ return base_ref, branch_mode # Fallback to default
884
+
885
+
886
+ # ------------------------ Browse Command ------------------------
887
+
888
+
889
+