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.
- 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.6.dist-info/METADATA +401 -0
- bitp-1.0.6.dist-info/RECORD +24 -0
- bitp-1.0.6.dist-info/WHEEL +5 -0
- bitp-1.0.6.dist-info/entry_points.txt +3 -0
- bitp-1.0.6.dist-info/licenses/COPYING +338 -0
- bitp-1.0.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (C) 2025 Bruce Ashfield <bruce.ashfield@gmail.com>
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: GPL-2.0-only
|
|
5
|
+
#
|
|
6
|
+
"""Export command - export patches from layer repos."""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
15
|
+
|
|
16
|
+
from ..core import (
|
|
17
|
+
Colors,
|
|
18
|
+
current_branch,
|
|
19
|
+
current_head,
|
|
20
|
+
fzf_available,
|
|
21
|
+
load_defaults,
|
|
22
|
+
load_export_state,
|
|
23
|
+
load_prep_state,
|
|
24
|
+
repo_is_clean,
|
|
25
|
+
save_export_state,
|
|
26
|
+
save_prep_state,
|
|
27
|
+
)
|
|
28
|
+
from .common import (
|
|
29
|
+
resolve_bblayers_path,
|
|
30
|
+
resolve_base_and_layers,
|
|
31
|
+
collect_repos,
|
|
32
|
+
repo_display_name,
|
|
33
|
+
prepare_target_dir,
|
|
34
|
+
get_repo_commit_info,
|
|
35
|
+
fzf_pick_range,
|
|
36
|
+
show_log_for_pick,
|
|
37
|
+
prompt_export,
|
|
38
|
+
group_commits_by_layer,
|
|
39
|
+
commit_files,
|
|
40
|
+
layer_display_name,
|
|
41
|
+
create_pull_branch,
|
|
42
|
+
repo_origin_url,
|
|
43
|
+
author_ident,
|
|
44
|
+
patch_subject,
|
|
45
|
+
clean_title,
|
|
46
|
+
git_version,
|
|
47
|
+
git_request_pull,
|
|
48
|
+
push_branch_to_target,
|
|
49
|
+
get_push_target,
|
|
50
|
+
copy_to_clipboard,
|
|
51
|
+
)
|
|
52
|
+
from .branch import (
|
|
53
|
+
get_local_commits,
|
|
54
|
+
fzf_multiselect_commits,
|
|
55
|
+
fzf_select_insertion_point,
|
|
56
|
+
prompt_branch_name,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Lazy imports to avoid circular dependency with explore.py
|
|
60
|
+
def _get_explore_functions():
|
|
61
|
+
from .explore import (
|
|
62
|
+
text_multiselect_commits,
|
|
63
|
+
text_select_insertion_point,
|
|
64
|
+
reorder_commits_via_cherrypick,
|
|
65
|
+
)
|
|
66
|
+
return text_multiselect_commits, text_select_insertion_point, reorder_commits_via_cherrypick
|
|
67
|
+
|
|
68
|
+
def diffstat_for_range(repo: str, range_spec: str) -> str:
|
|
69
|
+
try:
|
|
70
|
+
if range_spec == "--root":
|
|
71
|
+
empty = subprocess.check_output(
|
|
72
|
+
["git", "-C", repo, "hash-object", "-t", "tree", "/dev/null"], text=True
|
|
73
|
+
).strip()
|
|
74
|
+
return subprocess.check_output(
|
|
75
|
+
["git", "-C", repo, "diff", "--stat", f"{empty}..HEAD"], text=True
|
|
76
|
+
).strip()
|
|
77
|
+
return subprocess.check_output(["git", "-C", repo, "diff", "--stat", range_spec], text=True).strip()
|
|
78
|
+
except subprocess.CalledProcessError:
|
|
79
|
+
return ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run_export(args) -> int:
|
|
84
|
+
# Validate incompatible options
|
|
85
|
+
if getattr(args, 'from_branch', None) and args.branch:
|
|
86
|
+
print("Error: Cannot use --from-branch with --branch", file=sys.stderr)
|
|
87
|
+
return 1
|
|
88
|
+
|
|
89
|
+
defaults = load_defaults(args.defaults_file)
|
|
90
|
+
pairs, _repo_sets = resolve_base_and_layers(args.bblayers, defaults)
|
|
91
|
+
export_state = load_export_state(args.export_state_file)
|
|
92
|
+
|
|
93
|
+
# Load prep state if available
|
|
94
|
+
PREP_STATE_FILE = ".bit.prep-state.json"
|
|
95
|
+
prep_state = load_prep_state(PREP_STATE_FILE)
|
|
96
|
+
used_prep_state = False
|
|
97
|
+
|
|
98
|
+
if prep_state and not getattr(args, 'from_branch', None):
|
|
99
|
+
# Show what prep found
|
|
100
|
+
print("\nFound prep state from previous 'export prep' run:")
|
|
101
|
+
for repo, info in prep_state.get("repos", {}).items():
|
|
102
|
+
display = repo_display_name(repo)
|
|
103
|
+
branch = info.get("prep_branch") or (info.get("cut_point", "?")[:8] + "...")
|
|
104
|
+
print(f" {display}: {branch}")
|
|
105
|
+
|
|
106
|
+
# Prompt user
|
|
107
|
+
print()
|
|
108
|
+
try:
|
|
109
|
+
choice = input("Use prep results? [Y]es / [n]o / [a]bort: ").strip().lower()
|
|
110
|
+
except (EOFError, KeyboardInterrupt):
|
|
111
|
+
print()
|
|
112
|
+
return 0
|
|
113
|
+
if choice in ("a", "abort"):
|
|
114
|
+
return 0
|
|
115
|
+
elif choice in ("n", "no"):
|
|
116
|
+
prep_state = None # Ignore prep state
|
|
117
|
+
else:
|
|
118
|
+
used_prep_state = True
|
|
119
|
+
|
|
120
|
+
prepare_target_dir(args.target_dir, args.force)
|
|
121
|
+
if os.listdir(args.target_dir) and not args.force:
|
|
122
|
+
sys.exit(f"Target directory '{args.target_dir}' is not empty; use --force to proceed.")
|
|
123
|
+
|
|
124
|
+
repo_layers: Dict[str, List[str]] = {}
|
|
125
|
+
for layer, repo in pairs:
|
|
126
|
+
repo_layers.setdefault(repo, []).append(layer)
|
|
127
|
+
|
|
128
|
+
repo_cache: Dict[str, Tuple[Optional[str], bool, str, int, str, str]] = {}
|
|
129
|
+
selections: List[Tuple[str, List[str], str, Tuple[Optional[str], bool, str, int, str, str]]] = []
|
|
130
|
+
|
|
131
|
+
skip_rest = False
|
|
132
|
+
|
|
133
|
+
for repo, layers in repo_layers.items():
|
|
134
|
+
if skip_rest:
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
# When using prep state, skip repos that weren't prepped
|
|
138
|
+
if used_prep_state and repo not in prep_state.get("repos", {}):
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if repo not in repo_cache:
|
|
142
|
+
repo_cache[repo] = get_repo_commit_info(repo)
|
|
143
|
+
info = repo_cache[repo]
|
|
144
|
+
branch, remote_exists, remote_ref, count, range_spec, desc = info
|
|
145
|
+
head = current_head(repo)
|
|
146
|
+
display_name = repo_display_name(repo)
|
|
147
|
+
|
|
148
|
+
# Determine export reference (--from-branch or prep_state)
|
|
149
|
+
export_ref = None
|
|
150
|
+
from_branch_arg = getattr(args, 'from_branch', None)
|
|
151
|
+
if from_branch_arg:
|
|
152
|
+
# Verify branch exists in this repo
|
|
153
|
+
result = subprocess.run(
|
|
154
|
+
["git", "-C", repo, "rev-parse", "--verify", from_branch_arg],
|
|
155
|
+
capture_output=True, text=True
|
|
156
|
+
)
|
|
157
|
+
if result.returncode == 0:
|
|
158
|
+
export_ref = from_branch_arg
|
|
159
|
+
else:
|
|
160
|
+
print(f" {display_name}: branch '{from_branch_arg}' not found, using HEAD")
|
|
161
|
+
elif prep_state and repo in prep_state.get("repos", {}):
|
|
162
|
+
repo_prep = prep_state["repos"][repo]
|
|
163
|
+
if repo_prep.get("prep_branch"):
|
|
164
|
+
# Verify prep branch still exists
|
|
165
|
+
result = subprocess.run(
|
|
166
|
+
["git", "-C", repo, "rev-parse", "--verify", repo_prep["prep_branch"]],
|
|
167
|
+
capture_output=True, text=True
|
|
168
|
+
)
|
|
169
|
+
if result.returncode == 0:
|
|
170
|
+
export_ref = repo_prep["prep_branch"]
|
|
171
|
+
elif repo_prep.get("cut_point"):
|
|
172
|
+
export_ref = repo_prep["cut_point"]
|
|
173
|
+
|
|
174
|
+
# If we have a custom export ref, recalculate range_spec and count
|
|
175
|
+
if export_ref and branch:
|
|
176
|
+
new_range = f"origin/{branch}..{export_ref}"
|
|
177
|
+
try:
|
|
178
|
+
new_count = int(subprocess.check_output(
|
|
179
|
+
["git", "-C", repo, "rev-list", "--count", new_range],
|
|
180
|
+
text=True
|
|
181
|
+
).strip())
|
|
182
|
+
range_spec = new_range
|
|
183
|
+
count = new_count
|
|
184
|
+
desc = f"from {export_ref}"
|
|
185
|
+
info = (branch, remote_exists, remote_ref, count, range_spec, desc)
|
|
186
|
+
except subprocess.CalledProcessError:
|
|
187
|
+
print(f" {display_name}: invalid range '{new_range}', using default")
|
|
188
|
+
|
|
189
|
+
default_include = defaults.get(repo, "rebase") != "skip"
|
|
190
|
+
prev = export_state.get(repo)
|
|
191
|
+
prev_range = None
|
|
192
|
+
default_from_state = False
|
|
193
|
+
if prev and prev.get("head") == head:
|
|
194
|
+
if "include" in prev:
|
|
195
|
+
default_include = bool(prev["include"])
|
|
196
|
+
default_from_state = True
|
|
197
|
+
prev_range = prev.get("range") or None
|
|
198
|
+
layer_list = ", ".join(layers)
|
|
199
|
+
|
|
200
|
+
if args.pick:
|
|
201
|
+
if not branch:
|
|
202
|
+
print(f"{display_name}: detached HEAD; skipping.")
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
user_range = None
|
|
206
|
+
default_range = range_spec if range_spec != "--root" else None
|
|
207
|
+
|
|
208
|
+
# Try fzf for interactive selection
|
|
209
|
+
if fzf_available():
|
|
210
|
+
prev_was_skip = default_from_state and not default_include
|
|
211
|
+
fzf_result = fzf_pick_range(repo, branch, default_range=default_range, prev_range=prev_range, prev_was_skip=prev_was_skip)
|
|
212
|
+
if fzf_result == "SKIP_REST":
|
|
213
|
+
export_state[repo] = {"head": head or "", "include": False, "range": prev_range or default_range or ""}
|
|
214
|
+
skip_rest = True
|
|
215
|
+
break
|
|
216
|
+
elif fzf_result == "SKIP":
|
|
217
|
+
export_state[repo] = {"head": head or "", "include": False, "range": prev_range or default_range or ""}
|
|
218
|
+
continue
|
|
219
|
+
elif fzf_result is None:
|
|
220
|
+
# Escape pressed - treat as skip
|
|
221
|
+
print(f"Skipping {display_name} (cancelled).")
|
|
222
|
+
export_state[repo] = {"head": head or "", "include": False, "range": prev_range or default_range or ""}
|
|
223
|
+
continue
|
|
224
|
+
elif fzf_result == "USE_DEFAULT":
|
|
225
|
+
user_range = default_range or range_spec
|
|
226
|
+
elif fzf_result == "USE_PREVIOUS":
|
|
227
|
+
user_range = prev_range
|
|
228
|
+
else:
|
|
229
|
+
user_range = fzf_result
|
|
230
|
+
else:
|
|
231
|
+
# Fallback to manual input
|
|
232
|
+
suggested_range = prev_range or default_range or range_spec
|
|
233
|
+
print(f"\n{display_name} ({repo}) on {branch}")
|
|
234
|
+
show_log_for_pick(repo)
|
|
235
|
+
prompt = f"Range to export (git range, e.g. <start>^..<end>; empty to "
|
|
236
|
+
prompt += "use default" if suggested_range else "skip"
|
|
237
|
+
if default_from_state:
|
|
238
|
+
prompt += " (prev choice: "
|
|
239
|
+
prompt += "include" if default_include else "skip"
|
|
240
|
+
prompt += ")"
|
|
241
|
+
prompt += f"; default {suggested_range}; S=skip rest, s=skip this): "
|
|
242
|
+
user_range = input(prompt).strip()
|
|
243
|
+
if not user_range:
|
|
244
|
+
if default_include and suggested_range:
|
|
245
|
+
user_range = suggested_range
|
|
246
|
+
else:
|
|
247
|
+
export_state[repo] = {"head": head or "", "include": False, "range": suggested_range or ""}
|
|
248
|
+
continue
|
|
249
|
+
if user_range == "S":
|
|
250
|
+
export_state[repo] = {"head": head or "", "include": False, "range": suggested_range or ""}
|
|
251
|
+
skip_rest = True
|
|
252
|
+
break
|
|
253
|
+
if user_range.lower() == "s":
|
|
254
|
+
export_state[repo] = {"head": head or "", "include": False, "range": suggested_range or ""}
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
cnt = int(subprocess.check_output(["git", "-C", repo, "rev-list", "--count", user_range], text=True).strip())
|
|
259
|
+
except subprocess.CalledProcessError:
|
|
260
|
+
print(f"{display_name}: invalid range '{user_range}', skipping.")
|
|
261
|
+
continue
|
|
262
|
+
if cnt == 0:
|
|
263
|
+
print(f"{display_name}: range '{user_range}' has no commits, skipping.")
|
|
264
|
+
continue
|
|
265
|
+
remote_ref = f"origin/{branch}"
|
|
266
|
+
info = (branch, True, remote_ref, cnt, user_range, f"user range {user_range}")
|
|
267
|
+
selections.append((layer_list.split(", "), repo, display_name, info))
|
|
268
|
+
export_state[repo] = {"head": head or "", "include": True, "range": user_range}
|
|
269
|
+
continue
|
|
270
|
+
include, skip_rest = prompt_export(repo, layer_list, info, default_include, display_name)
|
|
271
|
+
if include:
|
|
272
|
+
selections.append((layer_list.split(", "), repo, display_name, info))
|
|
273
|
+
else:
|
|
274
|
+
if count > 0:
|
|
275
|
+
print(f"Skipping {display_name} ({repo}).")
|
|
276
|
+
export_state[repo] = {"head": head or "", "include": include, "range": range_spec or prev_range or ""}
|
|
277
|
+
|
|
278
|
+
if not selections:
|
|
279
|
+
print("No patches selected for export.")
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
global_counter = 1
|
|
283
|
+
summary_entries = []
|
|
284
|
+
all_patches: List[Tuple[str, str]] = [] # (display_name, patch_path)
|
|
285
|
+
diffstats: List[str] = []
|
|
286
|
+
pull_urls: List[Tuple[str, str, str]] = [] # (repo_name, url, branch_name)
|
|
287
|
+
request_pull_msgs: Dict[str, str] = {} # repo_path -> git request-pull output
|
|
288
|
+
|
|
289
|
+
for layers, repo, repo_name, info in selections:
|
|
290
|
+
branch, remote_exists, remote_ref, count, range_spec, desc = info
|
|
291
|
+
|
|
292
|
+
out_dir = args.target_dir
|
|
293
|
+
if args.layout == "per-repo":
|
|
294
|
+
out_dir = os.path.join(args.target_dir, repo_name)
|
|
295
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
296
|
+
|
|
297
|
+
# For multi-layer repos, we need to generate patches per-layer
|
|
298
|
+
# For single-layer repos, use repo_name as prefix
|
|
299
|
+
if len(layers) > 1:
|
|
300
|
+
# Get commits in range
|
|
301
|
+
if range_spec == "--root":
|
|
302
|
+
commits = subprocess.check_output(
|
|
303
|
+
["git", "-C", repo, "rev-list", "--reverse", "HEAD"],
|
|
304
|
+
text=True,
|
|
305
|
+
).strip().splitlines()
|
|
306
|
+
else:
|
|
307
|
+
commits = subprocess.check_output(
|
|
308
|
+
["git", "-C", repo, "rev-list", "--reverse", range_spec],
|
|
309
|
+
text=True,
|
|
310
|
+
).strip().splitlines()
|
|
311
|
+
|
|
312
|
+
# Group commits by layer
|
|
313
|
+
layer_commits, cross_layer, no_layer = group_commits_by_layer(repo, commits, layers)
|
|
314
|
+
|
|
315
|
+
if cross_layer:
|
|
316
|
+
# Get short hash and subject for error message
|
|
317
|
+
for c in cross_layer:
|
|
318
|
+
short = c[:12]
|
|
319
|
+
subj = subprocess.check_output(
|
|
320
|
+
["git", "-C", repo, "log", "-1", "--format=%s", c],
|
|
321
|
+
text=True,
|
|
322
|
+
).strip()[:60]
|
|
323
|
+
touched_layers = []
|
|
324
|
+
files = commit_files(repo, c)
|
|
325
|
+
for layer in layers:
|
|
326
|
+
relpath = os.path.relpath(layer, repo)
|
|
327
|
+
for f in files:
|
|
328
|
+
if f.startswith(relpath + "/") or f == relpath:
|
|
329
|
+
touched_layers.append(layer_display_name(layer))
|
|
330
|
+
break
|
|
331
|
+
print(f"Error: commit {short} touches multiple layers ({', '.join(touched_layers)}): {subj}")
|
|
332
|
+
return 1
|
|
333
|
+
|
|
334
|
+
if no_layer:
|
|
335
|
+
# Commits touching files outside known layers
|
|
336
|
+
for c in no_layer:
|
|
337
|
+
short = c[:12]
|
|
338
|
+
subj = subprocess.check_output(
|
|
339
|
+
["git", "-C", repo, "log", "-1", "--format=%s", c],
|
|
340
|
+
text=True,
|
|
341
|
+
).strip()[:60]
|
|
342
|
+
print(f"Error: commit {short} touches no known layer: {subj}")
|
|
343
|
+
return 1
|
|
344
|
+
|
|
345
|
+
# Generate patches per-layer
|
|
346
|
+
print(f"{repo_name}:")
|
|
347
|
+
repo_patch_count = 0
|
|
348
|
+
for layer in layers:
|
|
349
|
+
if layer not in layer_commits:
|
|
350
|
+
continue
|
|
351
|
+
layer_name = layer_display_name(layer)
|
|
352
|
+
layer_commit_list = layer_commits[layer]
|
|
353
|
+
|
|
354
|
+
# Track existing patches
|
|
355
|
+
existing_patches = set(os.listdir(out_dir)) if os.path.exists(out_dir) else set()
|
|
356
|
+
|
|
357
|
+
# Generate patches for this layer's commits
|
|
358
|
+
for commit in layer_commit_list:
|
|
359
|
+
cmd = ["git", "-C", repo, "format-patch", "-1", "--start-number", str(global_counter),
|
|
360
|
+
"--output-directory", out_dir, "--subject-prefix", layer_name, commit]
|
|
361
|
+
try:
|
|
362
|
+
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL)
|
|
363
|
+
except subprocess.CalledProcessError as exc:
|
|
364
|
+
print(f"Failed to export patch for {commit[:12]}: {exc}")
|
|
365
|
+
return exc.returncode or 1
|
|
366
|
+
global_counter += 1
|
|
367
|
+
|
|
368
|
+
# Collect newly generated patches
|
|
369
|
+
layer_patch_count = 0
|
|
370
|
+
for fname in sorted(os.listdir(out_dir)):
|
|
371
|
+
if fname.endswith(".patch") and fname not in existing_patches:
|
|
372
|
+
patch_path = os.path.join(out_dir, fname)
|
|
373
|
+
all_patches.append((layer_name, patch_path))
|
|
374
|
+
layer_patch_count += 1
|
|
375
|
+
repo_patch_count += 1
|
|
376
|
+
|
|
377
|
+
print(f" {layer_name}: {layer_patch_count} patch(es)")
|
|
378
|
+
|
|
379
|
+
print(f" -> {out_dir}")
|
|
380
|
+
|
|
381
|
+
# Diffstat for whole repo
|
|
382
|
+
diffstat = diffstat_for_range(repo, range_spec)
|
|
383
|
+
if diffstat:
|
|
384
|
+
diffstats.append(f"{repo_name}:\n{diffstat}")
|
|
385
|
+
|
|
386
|
+
# Generate cover letter for per-repo layout (since we're not using --cover-letter)
|
|
387
|
+
if args.layout == "per-repo":
|
|
388
|
+
cover_path = os.path.join(out_dir, "0000-cover-letter.patch")
|
|
389
|
+
author_name, author_email = author_ident(repo)
|
|
390
|
+
date_str = datetime.now().astimezone().strftime("%a, %d %b %Y %H:%M:%S %z")
|
|
391
|
+
version_str = f"v{args.series_version} " if args.series_version else ""
|
|
392
|
+
|
|
393
|
+
# Get commit subjects for shortlog
|
|
394
|
+
all_commits = []
|
|
395
|
+
for layer in layers:
|
|
396
|
+
if layer in layer_commits:
|
|
397
|
+
all_commits.extend(layer_commits[layer])
|
|
398
|
+
|
|
399
|
+
with open(cover_path, "w", encoding="utf-8") as f:
|
|
400
|
+
f.write("From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n")
|
|
401
|
+
f.write(f"From: {author_name} <{author_email}>\n")
|
|
402
|
+
f.write(f"Date: {date_str}\n")
|
|
403
|
+
f.write(f"Subject: [PATCH {version_str}0/{repo_patch_count}] *** SUBJECT HERE ***\n")
|
|
404
|
+
f.write("\n")
|
|
405
|
+
f.write("*** BLURB HERE ***\n")
|
|
406
|
+
f.write("\n")
|
|
407
|
+
# Shortlog by layer
|
|
408
|
+
for layer in layers:
|
|
409
|
+
if layer in layer_commits:
|
|
410
|
+
layer_name = layer_display_name(layer)
|
|
411
|
+
f.write(f"{layer_name} ({len(layer_commits[layer])}):\n")
|
|
412
|
+
for commit in layer_commits[layer]:
|
|
413
|
+
subj = subprocess.check_output(
|
|
414
|
+
["git", "-C", repo, "log", "-1", "--format=%s", commit],
|
|
415
|
+
text=True,
|
|
416
|
+
).strip()
|
|
417
|
+
f.write(f" {subj}\n")
|
|
418
|
+
f.write("\n")
|
|
419
|
+
if diffstat:
|
|
420
|
+
for line in diffstat.splitlines():
|
|
421
|
+
f.write(f" {line}\n")
|
|
422
|
+
f.write("\n")
|
|
423
|
+
f.write("-- \n")
|
|
424
|
+
f.write(f"{git_version()}\n")
|
|
425
|
+
|
|
426
|
+
else:
|
|
427
|
+
# Single layer - use repo display name
|
|
428
|
+
cmd = ["git", "-C", repo, "format-patch", "--start-number", str(global_counter), "--output-directory", out_dir, "--subject-prefix", repo_name]
|
|
429
|
+
if args.layout == "per-repo":
|
|
430
|
+
cmd.append("--cover-letter")
|
|
431
|
+
|
|
432
|
+
if range_spec == "--root":
|
|
433
|
+
cmd.append("--root")
|
|
434
|
+
else:
|
|
435
|
+
cmd.append(range_spec)
|
|
436
|
+
|
|
437
|
+
# Track existing patches before format-patch to only collect new ones
|
|
438
|
+
existing_patches = set(os.listdir(out_dir)) if os.path.exists(out_dir) else set()
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
subprocess.run(cmd, check=True)
|
|
442
|
+
except subprocess.CalledProcessError as exc:
|
|
443
|
+
print(f"Failed to export patches for {repo}: {exc}")
|
|
444
|
+
return exc.returncode or 1
|
|
445
|
+
|
|
446
|
+
# collect only newly generated patches for this repo
|
|
447
|
+
patch_count = 0
|
|
448
|
+
for fname in sorted(os.listdir(out_dir)):
|
|
449
|
+
if fname.endswith(".patch") and fname not in existing_patches:
|
|
450
|
+
patch_path = os.path.join(out_dir, fname)
|
|
451
|
+
all_patches.append((repo_name, patch_path))
|
|
452
|
+
patch_count += 1
|
|
453
|
+
|
|
454
|
+
print(f"{repo_name}:")
|
|
455
|
+
print(f" {patch_count} patch(es) -> {out_dir}")
|
|
456
|
+
|
|
457
|
+
diffstat = diffstat_for_range(repo, range_spec)
|
|
458
|
+
if diffstat:
|
|
459
|
+
diffstats.append(f"{repo_name}:\n{diffstat}")
|
|
460
|
+
|
|
461
|
+
global_counter += count
|
|
462
|
+
|
|
463
|
+
# Create pull branch if requested (per-repo, not per-layer)
|
|
464
|
+
if args.branch:
|
|
465
|
+
if not repo_is_clean(repo):
|
|
466
|
+
print(f" skipping branch creation (repo is dirty)")
|
|
467
|
+
else:
|
|
468
|
+
base_ref = remote_ref if remote_exists else "HEAD"
|
|
469
|
+
success, msg = create_pull_branch(repo, args.branch, base_ref, range_spec, args.force)
|
|
470
|
+
if success:
|
|
471
|
+
print(f" {msg}")
|
|
472
|
+
origin_url = repo_origin_url(repo)
|
|
473
|
+
if origin_url:
|
|
474
|
+
pull_urls.append((repo_name, origin_url, args.branch))
|
|
475
|
+
else:
|
|
476
|
+
print(f" {msg}")
|
|
477
|
+
|
|
478
|
+
summary_entries.append((repo_name, layers, branch or "(detached)", desc, count, out_dir, repo))
|
|
479
|
+
|
|
480
|
+
# Rewrite subjects with per-display-name numbering
|
|
481
|
+
patches_by_name: Dict[str, List[str]] = {}
|
|
482
|
+
for display_name, patch_path in all_patches:
|
|
483
|
+
patches_by_name.setdefault(display_name, []).append(patch_path)
|
|
484
|
+
|
|
485
|
+
for display_name, plist in patches_by_name.items():
|
|
486
|
+
total = len(plist)
|
|
487
|
+
for idx, patch_path in enumerate(plist, start=1):
|
|
488
|
+
title = clean_title(patch_subject(patch_path))
|
|
489
|
+
try:
|
|
490
|
+
with open(patch_path, encoding="utf-8") as f:
|
|
491
|
+
lines = f.readlines()
|
|
492
|
+
except Exception:
|
|
493
|
+
continue
|
|
494
|
+
new_lines = []
|
|
495
|
+
changed = False
|
|
496
|
+
version_str = f"v{args.series_version} " if args.series_version else ""
|
|
497
|
+
for line in lines:
|
|
498
|
+
if not changed and line.lower().startswith("subject:"):
|
|
499
|
+
new_lines.append(f"Subject: [{display_name}][PATCH {version_str}{idx:02d}/{total:02d}] {title}\n")
|
|
500
|
+
changed = True
|
|
501
|
+
continue
|
|
502
|
+
new_lines.append(line)
|
|
503
|
+
if changed:
|
|
504
|
+
try:
|
|
505
|
+
with open(patch_path, "w", encoding="utf-8") as f:
|
|
506
|
+
f.writelines(new_lines)
|
|
507
|
+
except Exception:
|
|
508
|
+
pass
|
|
509
|
+
|
|
510
|
+
# Push step - check for configured push targets and offer to push
|
|
511
|
+
repos_with_push_targets = []
|
|
512
|
+
for layers, repo, repo_name, info in selections:
|
|
513
|
+
push_target = get_push_target(defaults, repo)
|
|
514
|
+
if push_target and push_target.get("push_url"):
|
|
515
|
+
branch = info[0] # branch from info tuple
|
|
516
|
+
if branch:
|
|
517
|
+
repos_with_push_targets.append((repo, repo_name, branch, push_target))
|
|
518
|
+
|
|
519
|
+
if repos_with_push_targets:
|
|
520
|
+
print("\nPush targets configured for:")
|
|
521
|
+
for repo, repo_name, branch, target in repos_with_push_targets:
|
|
522
|
+
# Determine default remote branch - prefer prep branch name
|
|
523
|
+
prefix = target.get("branch_prefix", "")
|
|
524
|
+
if prep_state and repo in prep_state.get("repos", {}):
|
|
525
|
+
repo_prep = prep_state["repos"][repo]
|
|
526
|
+
if repo_prep.get("prep_branch"):
|
|
527
|
+
default_remote = f"{prefix}{repo_prep['prep_branch']}" if prefix else repo_prep["prep_branch"]
|
|
528
|
+
else:
|
|
529
|
+
default_remote = f"{prefix}{branch}" if prefix else branch
|
|
530
|
+
else:
|
|
531
|
+
default_remote = f"{prefix}{branch}" if prefix else branch
|
|
532
|
+
print(f" {repo_name}: {target['push_url']} -> {default_remote}")
|
|
533
|
+
|
|
534
|
+
try:
|
|
535
|
+
push_choice = input("\nPush branches? [y]es / [N]o: ").strip().lower()
|
|
536
|
+
except (EOFError, KeyboardInterrupt):
|
|
537
|
+
print()
|
|
538
|
+
push_choice = ""
|
|
539
|
+
|
|
540
|
+
if push_choice in ("y", "yes"):
|
|
541
|
+
print()
|
|
542
|
+
for repo, repo_name, branch, target in repos_with_push_targets:
|
|
543
|
+
push_url = target["push_url"]
|
|
544
|
+
prefix = target.get("branch_prefix", "")
|
|
545
|
+
|
|
546
|
+
# Determine what to push - use prep branch or current branch
|
|
547
|
+
local_ref = branch
|
|
548
|
+
if prep_state and repo in prep_state.get("repos", {}):
|
|
549
|
+
repo_prep = prep_state["repos"][repo]
|
|
550
|
+
if repo_prep.get("prep_branch"):
|
|
551
|
+
local_ref = repo_prep["prep_branch"]
|
|
552
|
+
|
|
553
|
+
# Default remote branch name - use prep branch if available
|
|
554
|
+
default_remote = f"{prefix}{local_ref}" if prefix else local_ref
|
|
555
|
+
|
|
556
|
+
# Prompt for remote branch name
|
|
557
|
+
try:
|
|
558
|
+
prompt = f"{repo_name}: push {local_ref} to [{default_remote}]: "
|
|
559
|
+
user_input = input(prompt).strip()
|
|
560
|
+
except (EOFError, KeyboardInterrupt):
|
|
561
|
+
print("\nSkipping remaining pushes.")
|
|
562
|
+
break
|
|
563
|
+
|
|
564
|
+
if user_input.lower() == "s":
|
|
565
|
+
print(f" Skipped {repo_name}")
|
|
566
|
+
continue
|
|
567
|
+
elif user_input.lower() == "f":
|
|
568
|
+
# Force push with default name
|
|
569
|
+
remote_branch = default_remote
|
|
570
|
+
force_push = True
|
|
571
|
+
elif user_input.startswith("f "):
|
|
572
|
+
# Force push with custom name
|
|
573
|
+
remote_branch = user_input[2:].strip() or default_remote
|
|
574
|
+
force_push = True
|
|
575
|
+
elif user_input:
|
|
576
|
+
remote_branch = user_input
|
|
577
|
+
force_push = False
|
|
578
|
+
else:
|
|
579
|
+
remote_branch = default_remote
|
|
580
|
+
force_push = False
|
|
581
|
+
|
|
582
|
+
# Push the branch
|
|
583
|
+
success, msg = push_branch_to_target(repo, push_url, local_ref, remote_branch, force=force_push)
|
|
584
|
+
if success:
|
|
585
|
+
force_str = " (forced)" if force_push else ""
|
|
586
|
+
print(f" {repo_name}: pushed to {remote_branch}{force_str}")
|
|
587
|
+
# Generate request-pull message
|
|
588
|
+
# Find the base ref (remote tracking branch)
|
|
589
|
+
base_ref = f"origin/{branch}"
|
|
590
|
+
rp_msg = git_request_pull(repo, base_ref, push_url, local_ref, remote_branch)
|
|
591
|
+
if rp_msg:
|
|
592
|
+
request_pull_msgs[repo] = rp_msg
|
|
593
|
+
else:
|
|
594
|
+
print(f" {repo_name}: {msg}")
|
|
595
|
+
|
|
596
|
+
if args.layout == "flat":
|
|
597
|
+
cover_path = os.path.join(args.target_dir, "0000-cover-letter.patch")
|
|
598
|
+
author_name, author_email = author_ident(selections[0][1] if selections else ".")
|
|
599
|
+
date_str = datetime.now().astimezone().strftime("%a, %d %b %Y %H:%M:%S %z")
|
|
600
|
+
version_str = f"v{args.series_version} " if args.series_version else ""
|
|
601
|
+
with open(cover_path, "w", encoding="utf-8") as f:
|
|
602
|
+
f.write("From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n")
|
|
603
|
+
f.write(f"From: {author_name} <{author_email}>\n")
|
|
604
|
+
f.write(f"Date: {date_str}\n")
|
|
605
|
+
f.write(f"Subject: [PATCH {version_str}0/{len(all_patches)}] *** SUBJECT HERE ***\n")
|
|
606
|
+
f.write("\n")
|
|
607
|
+
f.write("*** BLURB HERE ***\n")
|
|
608
|
+
f.write("\n")
|
|
609
|
+
# Shortlog by repo/layer
|
|
610
|
+
for repo_name, layers, branch, desc, count, _out_dir, _repo_path in summary_entries:
|
|
611
|
+
f.write(f"{repo_name} ({count}):\n")
|
|
612
|
+
for display_name, patch_path in all_patches:
|
|
613
|
+
if display_name == repo_name or display_name in [layer_display_name(l) for l in layers]:
|
|
614
|
+
subj = patch_subject(patch_path)
|
|
615
|
+
# Clean up the subject
|
|
616
|
+
clean = clean_title(subj)
|
|
617
|
+
f.write(f" {clean}\n")
|
|
618
|
+
f.write("\n")
|
|
619
|
+
if pull_urls:
|
|
620
|
+
f.write("Pull requests:\n")
|
|
621
|
+
for repo_name, url, branch_name in pull_urls:
|
|
622
|
+
f.write(f" {repo_name}: git pull {url} {branch_name}\n")
|
|
623
|
+
f.write("\n")
|
|
624
|
+
if request_pull_msgs:
|
|
625
|
+
for repo_name, layers, branch, desc, count, _out_dir, repo_path in summary_entries:
|
|
626
|
+
if repo_path in request_pull_msgs:
|
|
627
|
+
f.write(f"{repo_name}:\n")
|
|
628
|
+
for line in request_pull_msgs[repo_path].splitlines():
|
|
629
|
+
f.write(f" {line}\n")
|
|
630
|
+
f.write("\n")
|
|
631
|
+
if diffstats:
|
|
632
|
+
for stat in diffstats:
|
|
633
|
+
lines = stat.splitlines()
|
|
634
|
+
for i, line in enumerate(lines):
|
|
635
|
+
clean = line.lstrip()
|
|
636
|
+
f.write(f" {clean}\n")
|
|
637
|
+
f.write("\n")
|
|
638
|
+
f.write("-- \n")
|
|
639
|
+
f.write(f"{git_version()}\n")
|
|
640
|
+
print(f"Wrote cover letter: {cover_path}")
|
|
641
|
+
else:
|
|
642
|
+
# For per-repo layout, update cover letters with request-pull info if available
|
|
643
|
+
for repo_name, _layer, _branch, _desc, _count, out_dir, repo_path in summary_entries:
|
|
644
|
+
cover = os.path.join(out_dir, "0000-cover-letter.patch")
|
|
645
|
+
if os.path.exists(cover):
|
|
646
|
+
if repo_path in request_pull_msgs:
|
|
647
|
+
# Insert request-pull info before diffstat in cover letter
|
|
648
|
+
try:
|
|
649
|
+
with open(cover, "r", encoding="utf-8") as f:
|
|
650
|
+
content = f.read()
|
|
651
|
+
# Find where to insert (before diffstat or at end of body)
|
|
652
|
+
rp_text = "\nThe following changes since commit "
|
|
653
|
+
if "*** BLURB HERE ***" in content:
|
|
654
|
+
# Insert after BLURB HERE marker
|
|
655
|
+
rp_info = request_pull_msgs[repo_path]
|
|
656
|
+
content = content.replace(
|
|
657
|
+
"*** BLURB HERE ***",
|
|
658
|
+
f"*** BLURB HERE ***\n\n{rp_info}"
|
|
659
|
+
)
|
|
660
|
+
with open(cover, "w", encoding="utf-8") as f:
|
|
661
|
+
f.write(content)
|
|
662
|
+
except Exception:
|
|
663
|
+
pass
|
|
664
|
+
print(f"Summary for {repo_name}: {cover}")
|
|
665
|
+
|
|
666
|
+
print(f"Export complete. Patches written under {args.target_dir}")
|
|
667
|
+
save_export_state(args.export_state_file, export_state)
|
|
668
|
+
|
|
669
|
+
# Prompt to delete prep state if we used it
|
|
670
|
+
if used_prep_state and os.path.exists(PREP_STATE_FILE):
|
|
671
|
+
try:
|
|
672
|
+
choice = input("\nDelete prep state file? [Y]es / [n]o: ").strip().lower()
|
|
673
|
+
except (EOFError, KeyboardInterrupt):
|
|
674
|
+
print()
|
|
675
|
+
choice = ""
|
|
676
|
+
if choice not in ("n", "no"):
|
|
677
|
+
os.remove(PREP_STATE_FILE)
|
|
678
|
+
print("Prep state deleted.")
|
|
679
|
+
|
|
680
|
+
return 0
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def export_single_patch(repo: str, commit: str, target_dir: str = ".") -> Optional[str]:
|
|
685
|
+
"""Export a single commit as a .patch file using git format-patch. Returns full path or None on error."""
|
|
686
|
+
try:
|
|
687
|
+
# Use git format-patch to create file with standard naming (0001-subject.patch)
|
|
688
|
+
output = subprocess.check_output(
|
|
689
|
+
["git", "-C", repo, "format-patch", "-1", commit, "-o", target_dir],
|
|
690
|
+
text=True,
|
|
691
|
+
).strip()
|
|
692
|
+
# git format-patch outputs the created filename
|
|
693
|
+
return output
|
|
694
|
+
except subprocess.CalledProcessError:
|
|
695
|
+
return None
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def export_commits_from_explore(repo: str, commits: List[str]) -> None:
|
|
699
|
+
"""Export one or more commits as patch files. Prompts for directory if multiple."""
|
|
700
|
+
if not commits:
|
|
701
|
+
return
|
|
702
|
+
|
|
703
|
+
# Get current directory for display
|
|
704
|
+
cwd = os.getcwd()
|
|
705
|
+
|
|
706
|
+
if len(commits) == 1:
|
|
707
|
+
# Single commit - export to current directory
|
|
708
|
+
print(f"\nExporting to {cwd}...")
|
|
709
|
+
filepath = export_single_patch(repo, commits[0], cwd)
|
|
710
|
+
if filepath:
|
|
711
|
+
print(f" {os.path.basename(filepath)}")
|
|
712
|
+
else:
|
|
713
|
+
print(f" Failed to export {commits[0][:8]}")
|
|
714
|
+
input("Press Enter to continue...")
|
|
715
|
+
return
|
|
716
|
+
|
|
717
|
+
# Multiple commits - prompt for target directory
|
|
718
|
+
print(f"\nExporting {len(commits)} commits...")
|
|
719
|
+
try:
|
|
720
|
+
default_target = os.path.expanduser("~/patches")
|
|
721
|
+
target_dir = input(f"Target directory [{default_target}]: ").strip()
|
|
722
|
+
if not target_dir:
|
|
723
|
+
target_dir = default_target
|
|
724
|
+
target_dir = os.path.expanduser(target_dir)
|
|
725
|
+
except (EOFError, KeyboardInterrupt):
|
|
726
|
+
print("\nCancelled.")
|
|
727
|
+
return
|
|
728
|
+
|
|
729
|
+
# Create directory if needed
|
|
730
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
731
|
+
|
|
732
|
+
print(f"Exporting to {target_dir}...")
|
|
733
|
+
|
|
734
|
+
# Export each commit using git format-patch (standard naming)
|
|
735
|
+
exported = []
|
|
736
|
+
for i, commit in enumerate(commits, 1):
|
|
737
|
+
try:
|
|
738
|
+
# Use git format-patch with start-number for proper sequencing
|
|
739
|
+
output = subprocess.check_output(
|
|
740
|
+
["git", "-C", repo, "format-patch", "-1", commit, "-o", target_dir,
|
|
741
|
+
f"--start-number={i}"],
|
|
742
|
+
text=True,
|
|
743
|
+
).strip()
|
|
744
|
+
exported.append(os.path.basename(output))
|
|
745
|
+
print(f" {os.path.basename(output)}")
|
|
746
|
+
except subprocess.CalledProcessError as e:
|
|
747
|
+
print(f" Failed: {commit[:8]}")
|
|
748
|
+
|
|
749
|
+
print(f"Exported {len(exported)} patch(es)")
|
|
750
|
+
input("Press Enter to continue...")
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def run_prepare_export(args) -> int:
|
|
755
|
+
"""Main entry point for prepare-export subcommand."""
|
|
756
|
+
# Lazy import to avoid circular dependency
|
|
757
|
+
text_multiselect_commits, text_select_insertion_point, reorder_commits_via_cherrypick = _get_explore_functions()
|
|
758
|
+
|
|
759
|
+
bblayers_path = resolve_bblayers_path(args.bblayers)
|
|
760
|
+
defaults = load_defaults(args.defaults_file)
|
|
761
|
+
repos, _repo_sets = collect_repos(bblayers_path, defaults)
|
|
762
|
+
|
|
763
|
+
if not repos:
|
|
764
|
+
print("No repos found.")
|
|
765
|
+
return 1
|
|
766
|
+
|
|
767
|
+
processed_repos = []
|
|
768
|
+
skip_rest = False
|
|
769
|
+
|
|
770
|
+
for repo in repos:
|
|
771
|
+
if skip_rest:
|
|
772
|
+
break
|
|
773
|
+
|
|
774
|
+
display_name = repo_display_name(repo)
|
|
775
|
+
|
|
776
|
+
# Check if repo should be skipped by default
|
|
777
|
+
if defaults.get(repo, "rebase") == "skip":
|
|
778
|
+
print(f"→ {display_name}: default=skip")
|
|
779
|
+
continue
|
|
780
|
+
|
|
781
|
+
# Check for dirty repo
|
|
782
|
+
if not repo_is_clean(repo):
|
|
783
|
+
print(f"→ {Colors.yellow(display_name)}: uncommitted changes, skipping")
|
|
784
|
+
continue
|
|
785
|
+
|
|
786
|
+
# Get branch info
|
|
787
|
+
branch = current_branch(repo)
|
|
788
|
+
if not branch:
|
|
789
|
+
print(f"→ {display_name}: detached HEAD, skipping")
|
|
790
|
+
continue
|
|
791
|
+
|
|
792
|
+
# Get local commits
|
|
793
|
+
commits, base_ref = get_local_commits(repo, branch)
|
|
794
|
+
if not base_ref:
|
|
795
|
+
print(f"→ {display_name}: no origin/{branch}, skipping")
|
|
796
|
+
continue
|
|
797
|
+
|
|
798
|
+
if not commits:
|
|
799
|
+
print(f"→ {display_name}: no local commits")
|
|
800
|
+
continue
|
|
801
|
+
|
|
802
|
+
# Prompt for selection
|
|
803
|
+
# Skip branch prompt in fzf if --branch is already specified
|
|
804
|
+
skip_branch_prompt = bool(args.branch)
|
|
805
|
+
if args.plain or not fzf_available():
|
|
806
|
+
result = text_multiselect_commits(repo, branch, commits)
|
|
807
|
+
branch_mode = None # Text mode doesn't have 'b' key
|
|
808
|
+
want_backup = False # Text mode doesn't have '!' key
|
|
809
|
+
else:
|
|
810
|
+
result = fzf_multiselect_commits(repo, branch, commits, base_ref=base_ref, skip_branch_prompt=skip_branch_prompt)
|
|
811
|
+
|
|
812
|
+
if result is None:
|
|
813
|
+
print(f"→ {display_name}: cancelled")
|
|
814
|
+
continue
|
|
815
|
+
|
|
816
|
+
selected, action, branch_mode, want_backup = result
|
|
817
|
+
|
|
818
|
+
if action == "skip_rest":
|
|
819
|
+
print(f"→ {display_name}: skipping remaining repos")
|
|
820
|
+
skip_rest = True
|
|
821
|
+
break
|
|
822
|
+
if action == "skip" or not selected:
|
|
823
|
+
print(f"→ {display_name}: skipped")
|
|
824
|
+
continue
|
|
825
|
+
|
|
826
|
+
# Compute remaining commits (not selected) - keep as (hash, subject) tuples
|
|
827
|
+
selected_set = set(selected)
|
|
828
|
+
selected_tuples = [(h, s) for h, s in commits if h in selected_set]
|
|
829
|
+
remaining_tuples = [(h, s) for h, s in commits if h not in selected_set]
|
|
830
|
+
remaining = [h for h, _ in remaining_tuples]
|
|
831
|
+
|
|
832
|
+
# Prompt for insertion point if there are remaining commits
|
|
833
|
+
insertion_point = base_ref
|
|
834
|
+
if remaining_tuples:
|
|
835
|
+
if args.plain or not fzf_available():
|
|
836
|
+
insertion_point = text_select_insertion_point(repo, branch, base_ref, remaining_tuples)
|
|
837
|
+
else:
|
|
838
|
+
insertion_point, branch_mode = fzf_select_insertion_point(
|
|
839
|
+
repo, branch, base_ref, remaining_tuples, selected_tuples, branch_mode
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
if insertion_point is None:
|
|
843
|
+
print(f"→ {display_name}: cancelled")
|
|
844
|
+
continue
|
|
845
|
+
|
|
846
|
+
# Determine the actual commit order based on insertion point
|
|
847
|
+
if insertion_point == base_ref:
|
|
848
|
+
# Default: base_ref -> selected -> remaining
|
|
849
|
+
final_order = selected + remaining
|
|
850
|
+
else:
|
|
851
|
+
# Custom insertion point: commits before insertion -> selected -> commits after
|
|
852
|
+
insertion_idx = None
|
|
853
|
+
for i, h in enumerate(remaining):
|
|
854
|
+
if h == insertion_point:
|
|
855
|
+
insertion_idx = i
|
|
856
|
+
break
|
|
857
|
+
if insertion_idx is not None:
|
|
858
|
+
before = remaining[:insertion_idx + 1] # Include the insertion point commit
|
|
859
|
+
after = remaining[insertion_idx + 1:]
|
|
860
|
+
final_order = before + selected + after
|
|
861
|
+
else:
|
|
862
|
+
# Fallback if not found
|
|
863
|
+
final_order = selected + remaining
|
|
864
|
+
|
|
865
|
+
# Check if reorder is actually needed
|
|
866
|
+
actual_order = [h for h, _ in commits]
|
|
867
|
+
if final_order == actual_order:
|
|
868
|
+
print(f"→ {Colors.green(display_name)}: commits already in correct order")
|
|
869
|
+
# Get cut point for branch creation (last selected commit)
|
|
870
|
+
cut_point = selected[-1] if selected else None
|
|
871
|
+
processed_repos.append((repo, cut_point, branch_mode))
|
|
872
|
+
continue
|
|
873
|
+
|
|
874
|
+
# Perform reorder
|
|
875
|
+
backup_branch = None
|
|
876
|
+
if args.backup or want_backup:
|
|
877
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
878
|
+
backup_branch = f"{branch}-backup-{timestamp}"
|
|
879
|
+
|
|
880
|
+
success, msg, cut_point = reorder_commits_via_cherrypick(
|
|
881
|
+
repo=repo,
|
|
882
|
+
branch=branch,
|
|
883
|
+
base_ref=base_ref,
|
|
884
|
+
selected_commits=selected,
|
|
885
|
+
remaining_commits=remaining,
|
|
886
|
+
commits_info=commits,
|
|
887
|
+
insertion_point=insertion_point,
|
|
888
|
+
backup_branch=backup_branch,
|
|
889
|
+
dry_run=args.dry_run,
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
if success:
|
|
893
|
+
print(f"→ {Colors.green(display_name)}: {msg}")
|
|
894
|
+
processed_repos.append((repo, cut_point, branch_mode))
|
|
895
|
+
else:
|
|
896
|
+
print(f"→ {Colors.red(display_name)}: {msg}")
|
|
897
|
+
|
|
898
|
+
# Summary
|
|
899
|
+
print()
|
|
900
|
+
if not processed_repos:
|
|
901
|
+
if args.dry_run:
|
|
902
|
+
print("Dry run complete. No changes made.")
|
|
903
|
+
else:
|
|
904
|
+
print("No repos were prepared.")
|
|
905
|
+
return 0
|
|
906
|
+
|
|
907
|
+
print(f"Prepared {len(processed_repos)} repo(s) for export.")
|
|
908
|
+
|
|
909
|
+
# Branch creation logic
|
|
910
|
+
branch_name = args.branch # From CLI
|
|
911
|
+
# Check if any repo requested branch creation (b or B key)
|
|
912
|
+
any_branch_mode = any(mode for _, _, mode in processed_repos)
|
|
913
|
+
# Use replace mode if any repo pressed 'B'
|
|
914
|
+
force_replace = any(mode == "replace" for _, _, mode in processed_repos)
|
|
915
|
+
|
|
916
|
+
if args.dry_run:
|
|
917
|
+
if branch_name:
|
|
918
|
+
print(f"Would create branch '{branch_name}' at cut points")
|
|
919
|
+
elif any_branch_mode:
|
|
920
|
+
mode_str = "replace existing" if force_replace else "skip existing"
|
|
921
|
+
print(f"Would prompt for branch name ({mode_str})")
|
|
922
|
+
print("\nDry run complete. No changes made.")
|
|
923
|
+
return 0
|
|
924
|
+
|
|
925
|
+
if not branch_name and any_branch_mode:
|
|
926
|
+
# User pressed 'b' or 'B' in fzf - prompt for branch name
|
|
927
|
+
branch_name = prompt_branch_name()
|
|
928
|
+
elif not branch_name:
|
|
929
|
+
# Fallback prompt if no --branch specified
|
|
930
|
+
branch_name = prompt_branch_name()
|
|
931
|
+
|
|
932
|
+
# Create branches if a name was provided
|
|
933
|
+
if branch_name:
|
|
934
|
+
print()
|
|
935
|
+
for repo, cut_point, _ in processed_repos:
|
|
936
|
+
if not cut_point:
|
|
937
|
+
continue # Skip if no cut point (shouldn't happen in non-dry-run)
|
|
938
|
+
|
|
939
|
+
display_name = repo_display_name(repo)
|
|
940
|
+
|
|
941
|
+
# Check if branch already exists
|
|
942
|
+
branch_exists = subprocess.run(
|
|
943
|
+
["git", "-C", repo, "rev-parse", "--verify", branch_name],
|
|
944
|
+
stdout=subprocess.DEVNULL,
|
|
945
|
+
stderr=subprocess.DEVNULL,
|
|
946
|
+
).returncode == 0
|
|
947
|
+
|
|
948
|
+
if branch_exists:
|
|
949
|
+
if force_replace:
|
|
950
|
+
# Delete existing branch
|
|
951
|
+
subprocess.run(
|
|
952
|
+
["git", "-C", repo, "branch", "-D", branch_name],
|
|
953
|
+
stdout=subprocess.DEVNULL,
|
|
954
|
+
stderr=subprocess.DEVNULL,
|
|
955
|
+
)
|
|
956
|
+
else:
|
|
957
|
+
print(f"→ {Colors.yellow(display_name)}: branch '{branch_name}' already exists, skipping")
|
|
958
|
+
continue
|
|
959
|
+
|
|
960
|
+
# Create branch at cut point
|
|
961
|
+
result = subprocess.run(
|
|
962
|
+
["git", "-C", repo, "branch", branch_name, cut_point],
|
|
963
|
+
stdout=subprocess.DEVNULL,
|
|
964
|
+
stderr=subprocess.DEVNULL,
|
|
965
|
+
)
|
|
966
|
+
if result.returncode == 0:
|
|
967
|
+
action = "replaced" if branch_exists else "created"
|
|
968
|
+
print(f"→ {Colors.green(display_name)}: {action} branch '{branch_name}' at {cut_point[:12]}")
|
|
969
|
+
else:
|
|
970
|
+
print(f"→ {Colors.red(display_name)}: failed to create branch '{branch_name}'")
|
|
971
|
+
|
|
972
|
+
# Save prep state for use by export command
|
|
973
|
+
PREP_STATE_FILE = ".bit.prep-state.json"
|
|
974
|
+
prep_state_data = {}
|
|
975
|
+
for repo, cut_point, _ in processed_repos:
|
|
976
|
+
if cut_point:
|
|
977
|
+
# Get working branch
|
|
978
|
+
working_branch = subprocess.run(
|
|
979
|
+
["git", "-C", repo, "rev-parse", "--abbrev-ref", "HEAD"],
|
|
980
|
+
capture_output=True, text=True
|
|
981
|
+
).stdout.strip()
|
|
982
|
+
prep_state_data[repo] = {
|
|
983
|
+
"working_branch": working_branch,
|
|
984
|
+
"prep_branch": branch_name if branch_name else None,
|
|
985
|
+
"cut_point": cut_point,
|
|
986
|
+
}
|
|
987
|
+
if prep_state_data:
|
|
988
|
+
save_prep_state(PREP_STATE_FILE, prep_state_data)
|
|
989
|
+
print(f"\nPrep state saved to {PREP_STATE_FILE}")
|
|
990
|
+
|
|
991
|
+
# Prompt for export
|
|
992
|
+
print()
|
|
993
|
+
try:
|
|
994
|
+
response = input("Proceed to export? [Y/n] ").strip().lower()
|
|
995
|
+
except (EOFError, KeyboardInterrupt):
|
|
996
|
+
print()
|
|
997
|
+
return 0
|
|
998
|
+
|
|
999
|
+
if response not in ('', 'y', 'yes'):
|
|
1000
|
+
return 0
|
|
1001
|
+
|
|
1002
|
+
# Prompt for target directory
|
|
1003
|
+
print()
|
|
1004
|
+
try:
|
|
1005
|
+
default_target = os.path.expanduser("~/patches")
|
|
1006
|
+
target_dir = input(f"Target directory [{default_target}]: ").strip()
|
|
1007
|
+
if not target_dir:
|
|
1008
|
+
target_dir = default_target
|
|
1009
|
+
target_dir = os.path.expanduser(target_dir)
|
|
1010
|
+
except (EOFError, KeyboardInterrupt):
|
|
1011
|
+
print()
|
|
1012
|
+
return 0
|
|
1013
|
+
|
|
1014
|
+
# Build export args
|
|
1015
|
+
export_args = argparse.Namespace(
|
|
1016
|
+
bblayers=args.bblayers,
|
|
1017
|
+
defaults_file=args.defaults_file,
|
|
1018
|
+
target_dir=target_dir,
|
|
1019
|
+
layout="flat",
|
|
1020
|
+
force=False,
|
|
1021
|
+
pick=False, # Not needed - prep state provides the range
|
|
1022
|
+
series_version=None,
|
|
1023
|
+
branch=None, # Branch already created by prep
|
|
1024
|
+
from_branch=None, # Will use prep state
|
|
1025
|
+
export_state_file=".bit.export-state.json",
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
print()
|
|
1029
|
+
return run_export(export_args)
|
|
1030
|
+
|