bitp 1.0.7__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.
- bitbake_project/__init__.py +88 -0
- bitbake_project/__main__.py +14 -0
- bitbake_project/cli.py +1580 -0
- bitbake_project/commands/__init__.py +60 -0
- bitbake_project/commands/branch.py +889 -0
- bitbake_project/commands/common.py +2372 -0
- bitbake_project/commands/config.py +1515 -0
- bitbake_project/commands/deps.py +903 -0
- bitbake_project/commands/explore.py +2269 -0
- bitbake_project/commands/export.py +1030 -0
- bitbake_project/commands/fragment.py +884 -0
- bitbake_project/commands/init.py +515 -0
- bitbake_project/commands/projects.py +1505 -0
- bitbake_project/commands/recipe.py +1374 -0
- bitbake_project/commands/repos.py +154 -0
- bitbake_project/commands/search.py +313 -0
- bitbake_project/commands/update.py +181 -0
- bitbake_project/core.py +1811 -0
- bitp-1.0.7.dist-info/METADATA +401 -0
- bitp-1.0.7.dist-info/RECORD +24 -0
- bitp-1.0.7.dist-info/WHEEL +5 -0
- bitp-1.0.7.dist-info/entry_points.txt +3 -0
- bitp-1.0.7.dist-info/licenses/COPYING +338 -0
- bitp-1.0.7.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
|