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/__init__.py +3 -0
- commitrocket/__main__.py +3 -0
- commitrocket/batching.py +184 -0
- commitrocket/cli.py +431 -0
- commitrocket/config.py +125 -0
- commitrocket/doctor.py +202 -0
- commitrocket/errors.py +14 -0
- commitrocket/eta.py +40 -0
- commitrocket/filters.py +72 -0
- commitrocket/git.py +228 -0
- commitrocket/journal.py +31 -0
- commitrocket/lfs.py +212 -0
- commitrocket/logs.py +67 -0
- commitrocket/notify.py +162 -0
- commitrocket/options.py +38 -0
- commitrocket/push.py +246 -0
- commitrocket/report.py +130 -0
- commitrocket/runner.py +680 -0
- commitrocket/security.py +50 -0
- commitrocket/template.py +37 -0
- commitrocket/ui.py +216 -0
- commitrocket/verify.py +129 -0
- commitrocket-0.1.1.dist-info/METADATA +520 -0
- commitrocket-0.1.1.dist-info/RECORD +27 -0
- commitrocket-0.1.1.dist-info/WHEEL +4 -0
- commitrocket-0.1.1.dist-info/entry_points.txt +2 -0
- commitrocket-0.1.1.dist-info/licenses/LICENSE +21 -0
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
|