commitrocket 0.1.1__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.
commitrocket/runner.py ADDED
@@ -0,0 +1,680 @@
1
+ import logging
2
+ import time
3
+ from pathlib import Path
4
+
5
+ from rich.markup import escape
6
+ from rich.panel import Panel
7
+ from rich.text import Text
8
+
9
+ from commitrocket.batching import (
10
+ MIB,
11
+ estimate_batch_count,
12
+ plan_batches,
13
+ select_batch,
14
+ )
15
+ from commitrocket.errors import CommitRocketError
16
+ from commitrocket.eta import EtaEstimator
17
+ from commitrocket.filters import FileFilter
18
+ from commitrocket.git import (
19
+ LocalConfig,
20
+ add_files,
21
+ find_large_unpushed_blobs,
22
+ get_file_size,
23
+ get_pending_files,
24
+ get_remote_url,
25
+ get_staged_files,
26
+ get_unpushed_commits,
27
+ resolve_target,
28
+ run_git,
29
+ )
30
+ from commitrocket.journal import Journal
31
+ from commitrocket.lfs import (
32
+ HOST_LIMIT_MIB,
33
+ LfsSession,
34
+ committed_rule_lines,
35
+ ensure_git_lfs,
36
+ find_lfs_candidates,
37
+ is_lfs_available,
38
+ )
39
+ from commitrocket.push import push_with_retries
40
+ from commitrocket.report import (
41
+ ABORTED,
42
+ CANCELLED,
43
+ DEFERRED,
44
+ DRY_RUN,
45
+ FAILED,
46
+ INTERRUPTED,
47
+ SUCCESS,
48
+ RunReport,
49
+ )
50
+ from commitrocket.security import redact
51
+ from commitrocket.template import render_message
52
+ from commitrocket.ui import (
53
+ make_progress,
54
+ print_dry_run,
55
+ print_final_summary,
56
+ print_lfs_required,
57
+ print_resume,
58
+ print_run_info,
59
+ print_stuck_commits,
60
+ )
61
+ from commitrocket.verify import (
62
+ expected_lfs_paths,
63
+ find_non_pointers,
64
+ unpushed_has_lfs_pointers,
65
+ verify_remote_head,
66
+ )
67
+
68
+ logger = logging.getLogger(__name__)
69
+
70
+
71
+ def own_output_files(repo, options):
72
+ """Paths (relative to the repo) of the log and report files this run writes."""
73
+ found = set()
74
+ for raw in (options.log_file, options.report):
75
+ if not raw:
76
+ continue
77
+ path = Path(raw).expanduser().resolve()
78
+ try:
79
+ found.add(path.relative_to(repo).as_posix())
80
+ except ValueError:
81
+ pass # outside the repository
82
+ return found
83
+
84
+
85
+ def push_target(repo, progress, push_task, console, options, target, **kwargs):
86
+ """Push HEAD to the run's remote branch, retrying transient failures."""
87
+ push_with_retries(
88
+ repo,
89
+ progress,
90
+ push_task,
91
+ console,
92
+ retries=options.retries,
93
+ remote=target.remote,
94
+ refspec=target.refspec,
95
+ **kwargs,
96
+ )
97
+
98
+
99
+ def process_batches(
100
+ console,
101
+ repo,
102
+ progress,
103
+ task,
104
+ push_task,
105
+ options,
106
+ target,
107
+ number,
108
+ limit_bytes,
109
+ max_bytes,
110
+ list_pending,
111
+ lfs_files=frozenset(),
112
+ lfs_limit_bytes=0,
113
+ estimator=None,
114
+ journal=None,
115
+ check_lfs=None,
116
+ verify=None,
117
+ report=None,
118
+ ):
119
+ """Commit (and push) batches until only deferred files remain."""
120
+ batch_count = 0
121
+ commit_number = number
122
+
123
+ # Progress is measured in bytes so the ETA reflects the real amount of work.
124
+ total_work = sum(
125
+ size
126
+ for size in (get_file_size(repo, path) for path in list_pending())
127
+ if size <= max_bytes
128
+ )
129
+ done_bytes = 0
130
+ progress.update(task, total=max(total_work, 1), completed=0)
131
+ if estimator:
132
+ estimator.start()
133
+
134
+ selection = (
135
+ limit_bytes,
136
+ max_bytes,
137
+ lfs_files,
138
+ lfs_limit_bytes,
139
+ options.batch_type,
140
+ )
141
+
142
+ while True:
143
+ pending = list_pending()
144
+ if not pending:
145
+ break
146
+
147
+ selected, _, total_bytes = select_batch(repo, pending, *selection)
148
+
149
+ if not selected:
150
+ # Only oversized files remain; report them at the end.
151
+ break
152
+
153
+ remaining_estimate = estimate_batch_count(repo, pending, *selection)
154
+ estimated_total = max(batch_count + remaining_estimate, batch_count + 1)
155
+
156
+ progress.update(
157
+ task,
158
+ description=(
159
+ f"Batch {batch_count + 1}/{estimated_total}: "
160
+ f"commit #{commit_number}"
161
+ ),
162
+ )
163
+
164
+ console.rule(f"[bold cyan]Batch {batch_count + 1}[/bold cyan]")
165
+ console.print(
166
+ f"Selected: {len(selected)} file(s), {total_bytes / MIB:.2f} MiB"
167
+ )
168
+ logger.info(
169
+ "Batch %d/%d: %d file(s), %.2f MiB, commit #%d",
170
+ batch_count + 1,
171
+ estimated_total,
172
+ len(selected),
173
+ total_bytes / MIB,
174
+ commit_number,
175
+ )
176
+ logger.debug("Batch %d files: %s", batch_count + 1, selected)
177
+
178
+ commit_message = render_message(
179
+ options.message_template,
180
+ options.message,
181
+ commit_number,
182
+ batch_count + 1,
183
+ len(selected),
184
+ total_bytes / MIB,
185
+ selected[0],
186
+ )
187
+
188
+ # Lets an interrupted run recognise the files it left staged.
189
+ if journal:
190
+ journal.write(selected, commit_message, commit_number)
191
+
192
+ add_files(repo, selected)
193
+
194
+ if not get_staged_files(repo):
195
+ raise CommitRocketError("No staged changes to commit.")
196
+
197
+ run_git(repo, "commit", "-m", commit_message)
198
+ if journal:
199
+ journal.clear()
200
+
201
+ entry = None
202
+ if report:
203
+ entry = report.add_batch(
204
+ commit_number,
205
+ commit_message,
206
+ selected,
207
+ total_bytes,
208
+ lfs_files=len(lfs_files.intersection(selected)),
209
+ commit=run_git(repo, "rev-parse", "HEAD").stdout.strip(),
210
+ )
211
+
212
+ # Catch files that did not become LFS pointers before uploading them.
213
+ if check_lfs:
214
+ check_lfs(selected)
215
+
216
+ if not options.no_push:
217
+ push_target(
218
+ repo,
219
+ progress,
220
+ push_task,
221
+ console,
222
+ options,
223
+ target,
224
+ on_upload=lambda percent, base=done_bytes, size=total_bytes: (
225
+ progress.update(task, completed=base + size * percent / 100)
226
+ ),
227
+ batch_bytes=total_bytes,
228
+ )
229
+
230
+ if entry:
231
+ entry["pushed"] = True
232
+
233
+ if verify:
234
+ verify()
235
+ if entry:
236
+ entry["verified"] = True
237
+
238
+ batch_count += 1
239
+ commit_number += 1
240
+ done_bytes += total_bytes
241
+ progress.update(task, completed=done_bytes)
242
+
243
+ outcome = "committed" if options.no_push else "committed and pushed"
244
+ console.print(f"[green]Batch {batch_count} {outcome}.[/green]")
245
+ logger.info("Batch %d %s", batch_count, outcome)
246
+
247
+ return batch_count
248
+
249
+
250
+ def run(console, options, report=None):
251
+ """Batch, commit and push pending changes. Returns a process exit code.
252
+
253
+ The outcome is recorded in report (a RunReport) for --report and notifications.
254
+ """
255
+ o = options
256
+ report = report or RunReport(options)
257
+ repo = Path(o.repo).expanduser().resolve()
258
+
259
+ if not repo.is_dir():
260
+ console.print(f"[red]Repository directory not found: {repo}[/red]")
261
+ report.abort(f"Repository directory not found: {repo}")
262
+ return 2
263
+
264
+ started_at = time.monotonic()
265
+ lfs = None
266
+ config = None
267
+ acting = False
268
+ completed = False
269
+
270
+ try:
271
+ root = run_git(repo, "rev-parse", "--show-toplevel")
272
+ repo = Path(root.stdout.strip()).resolve()
273
+
274
+ branch = run_git(repo, "branch", "--show-current").stdout.strip()
275
+ if not branch:
276
+ raise CommitRocketError(
277
+ "Detached HEAD. Check out a branch before continuing."
278
+ )
279
+
280
+ target = resolve_target(repo, branch, o.remote, o.branch)
281
+ report.repo = str(repo)
282
+ report.branch = branch
283
+ report.target = target.label
284
+ file_filter = FileFilter.from_options(
285
+ repo, o.include, o.exclude, o.include_from, o.exclude_from
286
+ )
287
+
288
+ # The run's own log and report must never be committed, or writing them
289
+ # would create a new batch forever.
290
+ own_files = own_output_files(repo, o)
291
+
292
+ def raw_pending():
293
+ return [p for p in get_pending_files(repo) if p not in own_files]
294
+
295
+ def list_pending():
296
+ return file_filter.apply(raw_pending())
297
+
298
+ logger.info(
299
+ "Run started: repo=%s branch=%s target=%s limit=%d MiB max=%d MiB "
300
+ "batch_type=%s use_lfs=%s dry_run=%s no_push=%s retries=%d",
301
+ repo,
302
+ branch,
303
+ target.label,
304
+ o.limit_mib,
305
+ o.max_mib,
306
+ o.batch_type,
307
+ o.use_lfs,
308
+ o.dry_run,
309
+ o.no_push,
310
+ o.retries,
311
+ )
312
+
313
+ # Files left staged by an interrupted run are ours; anything else is not.
314
+ journal = Journal(repo)
315
+ entry = journal.read()
316
+ staged = get_staged_files(repo)
317
+ resume_staged = []
318
+ if staged:
319
+ if entry and set(staged) <= set(entry["files"]):
320
+ resume_staged = staged
321
+ else:
322
+ raise CommitRocketError(
323
+ f"{len(staged)} file(s) already staged. Commit or unstage "
324
+ "them before running this tool."
325
+ )
326
+ elif entry and not o.dry_run:
327
+ journal.clear()
328
+
329
+ # Earlier failed runs can leave commits that the host will reject.
330
+ stuck = find_large_unpushed_blobs(repo, HOST_LIMIT_MIB * MIB, target.revs)
331
+ if stuck:
332
+ logger.warning("Unpushed commits contain large files: %s", stuck)
333
+ print_stuck_commits(
334
+ console,
335
+ get_unpushed_commits(repo, target.revs),
336
+ stuck,
337
+ target.label,
338
+ HOST_LIMIT_MIB,
339
+ )
340
+ report.abort(
341
+ f"Unpushed commits contain files over {HOST_LIMIT_MIB} MiB: "
342
+ + ", ".join(path for path, _ in stuck[:5])
343
+ )
344
+ return 2
345
+
346
+ unpushed = get_unpushed_commits(repo, target.revs)
347
+ start_number = (
348
+ max(o.number, entry["number"] + 1) if resume_staged else o.number
349
+ )
350
+
351
+ print_run_info(
352
+ console,
353
+ repo,
354
+ branch,
355
+ target.label,
356
+ o.limit_mib,
357
+ o.max_mib,
358
+ start_number,
359
+ )
360
+
361
+ status = run_git(repo, "status", "--short").stdout.strip()
362
+ console.print(
363
+ Panel(Text(status or "(no pending changes)"), title="Pending Changes")
364
+ )
365
+
366
+ if resume_staged or unpushed:
367
+ logger.info(
368
+ "Resuming: %d staged file(s), %d unpushed commit(s)",
369
+ len(resume_staged),
370
+ len(unpushed),
371
+ )
372
+ print_resume(console, resume_staged, entry, unpushed)
373
+ report.resumed = {
374
+ "staged_files": len(resume_staged),
375
+ "unpushed_commits": len(unpushed),
376
+ }
377
+
378
+ limit_bytes = o.limit_mib * MIB
379
+ max_bytes = o.max_mib * MIB
380
+ lfs_limit_bytes = (o.lfs_batch_mib or o.limit_mib) * MIB
381
+
382
+ all_pending = raw_pending()
383
+ pending_files = list_pending()
384
+ if file_filter.active:
385
+ left_out = len(all_pending) - len(pending_files)
386
+ console.print(
387
+ f"[dim]Filters: {len(pending_files)} file(s) selected, "
388
+ f"{left_out} left out.[/dim]"
389
+ )
390
+ logger.info("Filters kept %d, left out %d", len(pending_files), left_out)
391
+
392
+ if not o.use_lfs:
393
+ over_host_limit = find_lfs_candidates(
394
+ repo, pending_files, HOST_LIMIT_MIB * MIB, max_bytes
395
+ )
396
+ if over_host_limit:
397
+ # The host would reject the push, so stop before committing anything.
398
+ logger.warning(
399
+ "Files over the limit need --use-lfs: %s", over_host_limit
400
+ )
401
+ print_lfs_required(console, repo, over_host_limit, HOST_LIMIT_MIB)
402
+ report.abort(
403
+ f"{len(over_host_limit)} file(s) over {HOST_LIMIT_MIB} MiB "
404
+ "need --use-lfs"
405
+ )
406
+ return 2
407
+
408
+ lfs_paths = []
409
+ lfs_scope = ""
410
+ if o.use_lfs:
411
+ lfs_paths = find_lfs_candidates(
412
+ repo, pending_files, o.lfs_threshold_mib * MIB, max_bytes
413
+ )
414
+ report.lfs["files"] = len(lfs_paths)
415
+ if not lfs_paths:
416
+ lfs_scope = (
417
+ f"--use-lfs: no files above {o.lfs_threshold_mib} MiB; "
418
+ "LFS not needed."
419
+ )
420
+ else:
421
+ which = (
422
+ f"All {len(lfs_paths)} file(s)"
423
+ if o.lfs_threshold_mib == 0
424
+ else f"{len(lfs_paths)} file(s) above {o.lfs_threshold_mib} MiB"
425
+ )
426
+ if o.lfs_commit_rules:
427
+ rule_count = len(committed_rule_lines(lfs_paths))
428
+ lfs_scope = (
429
+ f"{which} will be stored with Git LFS. {rule_count} rule(s) "
430
+ "will be committed to .gitattributes first, so they also "
431
+ "cover files of the same type added later."
432
+ )
433
+ else:
434
+ lfs_scope = (
435
+ f"{which} will be stored with Git LFS for this run only; "
436
+ "no LFS config is committed."
437
+ )
438
+ if o.dry_run and not is_lfs_available():
439
+ lfs_scope += " git-lfs is not installed and would be installed."
440
+
441
+ if o.dry_run:
442
+ batches, deferred = plan_batches(
443
+ repo,
444
+ pending_files,
445
+ limit_bytes,
446
+ max_bytes,
447
+ frozenset(lfs_paths),
448
+ lfs_limit_bytes,
449
+ o.batch_type,
450
+ )
451
+ messages = [
452
+ render_message(
453
+ o.message_template,
454
+ o.message,
455
+ start_number + index,
456
+ index + 1,
457
+ len(paths),
458
+ size / MIB,
459
+ paths[0],
460
+ )
461
+ for index, (paths, size) in enumerate(batches)
462
+ ]
463
+ logger.info("Dry run: %d batch(es) planned", len(batches))
464
+ for index, (paths, size) in enumerate(batches):
465
+ report.add_batch(
466
+ start_number + index,
467
+ messages[index],
468
+ paths,
469
+ size,
470
+ lfs_files=len(set(paths).intersection(lfs_paths)),
471
+ )
472
+ report.set_deferred(deferred)
473
+ report.status = DRY_RUN
474
+ print_dry_run(
475
+ console, batches, deferred, messages, lfs_paths, lfs_scope, o.no_push
476
+ )
477
+ return 0
478
+
479
+ if lfs_scope:
480
+ console.print(f"[cyan]{lfs_scope}[/cyan]")
481
+
482
+ if not o.force:
483
+ answer = (
484
+ console.input(
485
+ "[yellow]Proceed with staging, committing, and "
486
+ f"{'finishing' if o.no_push else 'pushing'}? [y/N][/yellow] "
487
+ )
488
+ .strip()
489
+ .lower()
490
+ )
491
+ if answer not in {"y", "yes"}:
492
+ console.print("[yellow]Cancelled.[/yellow]")
493
+ logger.info("Cancelled by user at the confirmation prompt")
494
+ report.status = CANCELLED
495
+ return 1
496
+
497
+ # Unpushed LFS pointers need the LFS hooks, or a push would publish
498
+ # pointers whose files were never uploaded.
499
+ resume_needs_lfs = (
500
+ not o.no_push
501
+ and bool(unpushed)
502
+ and unpushed_has_lfs_pointers(repo, target.revs)
503
+ )
504
+ if lfs_paths or resume_needs_lfs:
505
+ ensure_git_lfs(console, o.force)
506
+
507
+ def verify():
508
+ verify_remote_head(repo, target.remote, target.ref, o.retries)
509
+
510
+ def check_lfs(selected):
511
+ bad = find_non_pointers(repo, expected_lfs_paths(selected, lfs_paths))
512
+ if bad:
513
+ raise CommitRocketError(
514
+ f"{len(bad)} file(s) were committed as ordinary files instead of "
515
+ f"LFS pointers (first: {bad[0]}). Nothing was pushed. Drop the "
516
+ f"commit with 'git reset --mixed {target.label}' and report this."
517
+ )
518
+
519
+ acting = True
520
+ try:
521
+ # Repository-local settings only; the previous values are restored below.
522
+ config = LocalConfig(repo)
523
+ if o.compression_threads is not None:
524
+ config.set("pack.threads", o.compression_threads)
525
+ logger.info("Set local pack.threads=%d", o.compression_threads)
526
+ if o.lfs_threads is not None:
527
+ config.set("lfs.concurrenttransfers", o.lfs_threads)
528
+ logger.info("Set local lfs.concurrenttransfers=%d", o.lfs_threads)
529
+ if o.post_buffer_mib is not None:
530
+ url = get_remote_url(repo, target.remote).lower()
531
+ if url.startswith(("http://", "https://")):
532
+ config.set("http.postBuffer", o.post_buffer_mib * MIB)
533
+ logger.info("Set local http.postBuffer=%d MiB", o.post_buffer_mib)
534
+ else:
535
+ console.print(
536
+ "[dim]--post-buffer-mib ignored: remote is not HTTP(S).[/dim]"
537
+ )
538
+ logger.info("--post-buffer-mib ignored for non-HTTP remote")
539
+
540
+ if lfs_paths or resume_needs_lfs:
541
+ lfs = LfsSession(repo)
542
+ if lfs_paths and not o.lfs_commit_rules:
543
+ lfs.enable(lfs_paths, all_files=o.lfs_threshold_mib == 0)
544
+ logger.info(
545
+ "LFS enabled locally for %d file(s) (threshold %d MiB)",
546
+ len(lfs_paths),
547
+ o.lfs_threshold_mib,
548
+ )
549
+ logger.debug("LFS files: %s", lfs_paths)
550
+ else:
551
+ lfs.install_hooks()
552
+
553
+ with make_progress(console) as progress:
554
+ estimator = EtaEstimator()
555
+ task = progress.add_task(
556
+ "Preparing batches", total=1, estimator=estimator
557
+ )
558
+ push_task = progress.add_task("Git push", total=100, visible=False)
559
+
560
+ if resume_staged:
561
+ run_git(repo, "commit", "-m", entry["message"])
562
+ journal.clear()
563
+ logger.info("Committed resumed batch: %s", entry["message"])
564
+ if find_large_unpushed_blobs(
565
+ repo, HOST_LIMIT_MIB * MIB, target.revs
566
+ ):
567
+ raise CommitRocketError(
568
+ "The resumed commit contains files over "
569
+ f"{HOST_LIMIT_MIB} MiB. Drop it with "
570
+ f"'git reset --mixed {target.label}' and run again."
571
+ )
572
+
573
+ if (resume_staged or unpushed) and not o.no_push:
574
+ push_target(repo, progress, push_task, console, o, target)
575
+ verify()
576
+ console.print("[green]Resumed commits pushed.[/green]")
577
+
578
+ if o.lfs_commit_rules and lfs_paths:
579
+ if lfs.write_committed_rules(lfs_paths):
580
+ run_git(repo, "add", "--", ".gitattributes")
581
+ rules_message = f"{o.message.rstrip()} (LFS rules)"
582
+ run_git(repo, "commit", "-m", rules_message)
583
+ logger.info("Committed LFS rules: %s", rules_message)
584
+ report.lfs["rules_commit"] = run_git(
585
+ repo, "rev-parse", "HEAD"
586
+ ).stdout.strip()
587
+ console.print("[cyan]Committed LFS rules.[/cyan]")
588
+ push_target(repo, progress, push_task, console, o, target)
589
+ verify()
590
+
591
+ batch_count = process_batches(
592
+ console,
593
+ repo,
594
+ progress,
595
+ task,
596
+ push_task,
597
+ o,
598
+ target,
599
+ start_number,
600
+ limit_bytes,
601
+ max_bytes,
602
+ list_pending,
603
+ frozenset(lfs_paths),
604
+ lfs_limit_bytes,
605
+ estimator,
606
+ journal,
607
+ check_lfs if lfs_paths else None,
608
+ verify,
609
+ report,
610
+ )
611
+ completed = True
612
+ finally:
613
+ if lfs:
614
+ keep_hooks = False
615
+ if not completed:
616
+ try:
617
+ keep_hooks = unpushed_has_lfs_pointers(repo, target.revs)
618
+ except (RuntimeError, OSError):
619
+ keep_hooks = True
620
+ lfs.close(uninstall_hooks=not keep_hooks)
621
+ if keep_hooks:
622
+ console.print(
623
+ "[yellow]Git LFS hooks were left installed because unpushed "
624
+ "commits contain LFS files. Run CommitRocket again to push "
625
+ "them; a plain 'git push' only works while the hooks "
626
+ "stay.[/yellow]"
627
+ )
628
+ logger.warning("LFS hooks kept: unpushed LFS commits remain")
629
+ else:
630
+ logger.info("Local LFS setup removed")
631
+ if config:
632
+ config.restore()
633
+ logger.info("Local git config restored")
634
+
635
+ code = print_final_summary(
636
+ console,
637
+ repo,
638
+ batch_count,
639
+ started_at,
640
+ o.max_mib,
641
+ pending_files=list_pending,
642
+ pushed=not o.no_push,
643
+ )
644
+ report.set_deferred(
645
+ (path, size)
646
+ for path, size in ((p, get_file_size(repo, p)) for p in list_pending())
647
+ if size > max_bytes
648
+ )
649
+ report.status = SUCCESS if code == 0 else DEFERRED
650
+ logger.info(
651
+ "Finished: %d batch(es) in %.1f s, exit code %d",
652
+ batch_count,
653
+ time.monotonic() - started_at,
654
+ code,
655
+ )
656
+ return code
657
+
658
+ except KeyboardInterrupt:
659
+ console.print("\n[yellow]Interrupted by user.[/yellow]")
660
+ report.status = INTERRUPTED
661
+ report.error = "Interrupted by user"
662
+ if acting:
663
+ console.print(
664
+ "[dim]Run the same command again to resume from where it "
665
+ "stopped.[/dim]"
666
+ )
667
+ logger.warning("Interrupted by user")
668
+ return 130
669
+ except (RuntimeError, OSError) as exc:
670
+ message = redact(str(exc))
671
+ console.print(f"\n[bold red]Error:[/bold red] {escape(message)}")
672
+ report.status = ABORTED if not acting else FAILED
673
+ report.error = message
674
+ if acting:
675
+ console.print(
676
+ "[dim]Run the same command again to resume from where it "
677
+ "stopped.[/dim]"
678
+ )
679
+ logger.error("Run failed: %s", exc, exc_info=True)
680
+ return 1