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.
@@ -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
+