Repository navigation
Expand file tree
/
Copy pathcli.py
More file actions
437 lines (408 loc) · 15.1 KB
/
Copy pathcli.py
File metadata and controls
437 lines (408 loc) · 15.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
import argparse
import logging
import sys
from rich.console import Console
from rich.markup import escape
from commitrocket import __version__
from commitrocket.batching import BATCH_TYPES, DETERMINISTIC
from commitrocket.config import ConfigError, apply_config, find_config
from commitrocket.doctor import run_doctor
from commitrocket.errors import CommitRocketError
from commitrocket.lfs import LFS_THRESHOLD_MIB
from commitrocket.logs import (
DEFAULT_LEVEL,
LOG_LEVELS,
close_logging,
default_log_dir,
setup_logging,
)
from commitrocket.notify import is_valid_webhook, send_notifications
from commitrocket.options import RunOptions
from commitrocket.report import RunReport
from commitrocket.runner import run
from commitrocket.security import validate_ref_part
from commitrocket.template import DEFAULT_TEMPLATE, PLACEHOLDERS, validate_template
DEFAULT_LIMIT_MIB = 100
DEFAULT_RETRIES = 3
def build_parser():
parser = argparse.ArgumentParser(
prog="commitrocket",
description="Batch Git changes, commit each batch, and push "
"with progress reporting. Run 'commitrocket doctor' to check your setup.",
allow_abbrev=False,
)
parser.add_argument("--version", action="version", version=__version__)
parser.add_argument(
"--repo", default=".", help="Path to the Git repository (default: .)"
)
parser.add_argument(
"--config",
default=None,
help="Read settings from this TOML file instead of commitrocket.toml / "
"[tool.commitrocket] in pyproject.toml. Command-line options win",
)
parser.add_argument(
"--message",
default=None,
help="Base commit message (required, here or in the config file)",
)
parser.add_argument(
"--message-template",
default=DEFAULT_TEMPLATE,
help=f"Commit message for each batch (default: '{DEFAULT_TEMPLATE}'). "
f"Placeholders: {', '.join(PLACEHOLDERS)}",
)
parser.add_argument(
"--number",
type=int,
default=1,
help="Starting commit suffix number (default: 1)",
)
parser.add_argument("--force", action="store_true", help="Skip confirmation")
parser.add_argument(
"--remote",
default=None,
help="Remote to push to (default: the branch's upstream remote)",
)
parser.add_argument(
"--branch",
default=None,
help="Remote branch to push to (default: the branch's upstream branch, "
"or the current branch name)",
)
parser.add_argument(
"--no-push",
action="store_true",
help="Commit every batch but don't push; a later run pushes them",
)
parser.add_argument(
"--include",
action="append",
default=None,
metavar="GLOB",
help="Only handle files matching this .gitignore-style pattern "
"(repeatable), e.g. '*.mp4' or 'videos/**'",
)
parser.add_argument(
"--exclude",
action="append",
default=None,
metavar="GLOB",
help="Leave files matching this pattern alone (repeatable); wins over "
"--include. Patterns in .commitrocketignore are always excluded",
)
parser.add_argument(
"--include-from",
action="append",
default=None,
metavar="FILE",
help="Read --include patterns from a file, one per line (# comments)",
)
parser.add_argument(
"--exclude-from",
action="append",
default=None,
metavar="FILE",
help="Read --exclude patterns from a file, one per line (# comments)",
)
parser.add_argument(
"--limit-mib",
type=int,
default=DEFAULT_LIMIT_MIB,
help=f"Normal batch limit in MiB (default: {DEFAULT_LIMIT_MIB})",
)
parser.add_argument(
"--max-mib",
type=int,
default=None,
help="Maximum individual file size in MiB (default: 3x --limit-mib)",
)
parser.add_argument(
"--use-lfs",
action="store_true",
help="Track files above 100 MiB with Git LFS for this run, "
"installing git-lfs if needed and reverting the setup afterwards",
)
parser.add_argument(
"--lfs-commit-rules",
dest="lfs_commit_rules",
action="store_true",
default=True,
help="With --use-lfs, commit LFS rules to .gitattributes (one per file "
"extension) so clones and checkouts get real files (default)",
)
parser.add_argument(
"--no-lfs-commit-rules",
dest="lfs_commit_rules",
action="store_false",
help="With --use-lfs, keep the LFS rules local to this run. Checkouts "
"and clones will then contain LFS pointer files instead of the real files",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show the batches and commit messages that would be created, "
"without staging, committing or pushing anything",
)
parser.add_argument(
"--retries",
type=int,
default=DEFAULT_RETRIES,
help="Retry a failed push this many times, waiting 5 s, 15 s, 45 s... "
f"(default: {DEFAULT_RETRIES}). Rejections such as file-size limits are "
"never retried",
)
parser.add_argument(
"--post-buffer-mib",
type=int,
default=None,
help="Raise Git's http.postBuffer to this many MiB for this run "
"(repository config only; HTTP/HTTPS remotes). Helps with 'RPC failed' "
"and HTTP 408/500 errors on large pushes",
)
parser.add_argument(
"--report",
default=None,
metavar="FILE",
help="Write a machine-readable JSON summary of the run to this file "
"(also written when the run fails, is interrupted or is a dry run)",
)
parser.add_argument(
"--notify",
action="store_true",
help="Show a desktop notification when the run ends (Windows toast, "
"macOS notification, Linux notify-send; terminal bell if none works)",
)
parser.add_argument(
"--notify-webhook",
default=None,
metavar="URL",
help="POST a JSON summary to this http(s) URL when the run ends "
"(Slack, Discord, Teams, ntfy or your own endpoint)",
)
parser.add_argument(
"--level",
type=str.upper,
choices=LOG_LEVELS,
default=DEFAULT_LEVEL,
help=f"Log level written to the log file (default: {DEFAULT_LEVEL}); "
"DEBUG also records every git command and the push output",
)
parser.add_argument(
"--log-file",
default=None,
help="Write the log here instead of the default per-user log folder "
f"({str(default_log_dir()).replace('%', '%%')})",
)
parser.add_argument(
"--batch-type",
choices=BATCH_TYPES,
default=DETERMINISTIC,
help="How files are picked for a batch: 'deterministic' packs first-fit "
"and skips files that don't fit; 'sequential' takes files strictly in "
"order; 'soft' takes files in order and may pass --limit-mib, up to "
"--max-mib (default: deterministic)",
)
parser.add_argument(
"--compression-threads",
type=int,
default=None,
help="Threads Git uses to compress objects when pushing (pack.threads; "
"0 = auto). Set in the repository's local config for this run only",
)
parser.add_argument(
"--lfs-threads",
type=int,
default=None,
help="Concurrent Git LFS uploads (lfs.concurrenttransfers). Set in the "
"repository's local config for this run only",
)
parser.add_argument(
"--lfs-threshold-mib",
type=int,
default=LFS_THRESHOLD_MIB,
help="With --use-lfs, files larger than this go through Git LFS "
f"(default: {LFS_THRESHOLD_MIB}, the most hosts accept; 0 = every file)",
)
parser.add_argument(
"--lfs-batch-mib",
type=int,
default=None,
help="Max total size of LFS files pushed together so they can upload "
"concurrently (default: same as --limit-mib)",
)
return parser
def validate(parser, args):
"""Reject invalid combinations; returns the effective --max-mib."""
if not args.message or not args.message.strip():
parser.error("--message is required (or set 'message' in the config file)")
if args.limit_mib <= 0:
parser.error("--limit-mib must be positive")
if args.number < 0:
parser.error("--number cannot be negative")
if args.compression_threads is not None and args.compression_threads < 0:
parser.error("--compression-threads cannot be negative")
if args.lfs_threads is not None and args.lfs_threads <= 0:
parser.error("--lfs-threads must be positive")
if not 0 <= args.lfs_threshold_mib <= LFS_THRESHOLD_MIB:
parser.error(
f"--lfs-threshold-mib must be between 0 and {LFS_THRESHOLD_MIB}; "
"larger files would be rejected by the host"
)
if args.retries < 0:
parser.error("--retries cannot be negative")
if args.post_buffer_mib is not None and args.post_buffer_mib <= 0:
parser.error("--post-buffer-mib must be positive")
if args.lfs_batch_mib is not None and args.lfs_batch_mib <= 0:
parser.error("--lfs-batch-mib must be positive")
if args.no_push and args.use_lfs:
parser.error(
"--no-push cannot be combined with --use-lfs: the large files would "
"stay local and a later plain 'git push' would publish LFS pointers "
"without their data"
)
for label, value in (("--remote", args.remote), ("--branch", args.branch)):
if value is not None:
try:
validate_ref_part(value, label)
except CommitRocketError as exc:
parser.error(str(exc))
if args.notify_webhook and not is_valid_webhook(args.notify_webhook):
parser.error("--notify-webhook must be an http:// or https:// URL")
template_error = validate_template(args.message_template)
if template_error:
parser.error(template_error)
max_mib = args.max_mib if args.max_mib is not None else args.limit_mib * 3
if max_mib < args.limit_mib:
parser.error("--max-mib must be greater than or equal to --limit-mib")
return max_mib
def run_main(argv):
parser = build_parser()
# Find the config file first: its values become the parser's defaults, so
# anything given on the command line still wins.
pre = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
pre.add_argument("--repo", default=".")
pre.add_argument("--config")
known, _ = pre.parse_known_args(argv)
config_path = None
ignored_settings = []
try:
config_path, values = find_config(known.repo, known.config)
if values:
# Only a file named with --config is trusted with sensitive settings.
ignored_settings = apply_config(
parser, values, config_path, trusted=bool(known.config)
)
except ConfigError as exc:
parser.error(str(exc))
args = parser.parse_args(argv)
max_mib = validate(parser, args)
options = RunOptions(
repo=args.repo,
message=args.message,
message_template=args.message_template,
number=args.number,
force=args.force,
remote=args.remote,
branch=args.branch,
no_push=args.no_push,
include=args.include or [],
exclude=args.exclude or [],
include_from=args.include_from or [],
exclude_from=args.exclude_from or [],
limit_mib=args.limit_mib,
max_mib=max_mib,
batch_type=args.batch_type,
use_lfs=args.use_lfs,
lfs_commit_rules=args.lfs_commit_rules,
lfs_threshold_mib=args.lfs_threshold_mib,
lfs_threads=args.lfs_threads,
lfs_batch_mib=args.lfs_batch_mib,
compression_threads=args.compression_threads,
dry_run=args.dry_run,
retries=args.retries,
post_buffer_mib=args.post_buffer_mib,
report=args.report,
)
console = Console()
log_path = setup_logging(args.level, args.log_file)
options.log_file = str(log_path) if log_path else None
if log_path is None:
console.print(
"[yellow]Could not open a log file; continuing without one.[/yellow]"
)
if config_path:
console.print(f"[dim]Using config: {escape(str(config_path))}[/dim]")
if ignored_settings:
names = ", ".join(ignored_settings)
console.print(
f"[yellow]Ignored from {escape(str(config_path))}: {escape(names)}. "
"Settings that skip the prompt, change where files are pushed or "
"written, or contact other servers are only used from a file you "
"pass with --config.[/yellow]"
)
logging.getLogger(__name__).warning(
"Ignored sensitive settings from untrusted config %s: %s",
config_path,
names,
)
report = RunReport(options)
try:
code = run(console, options, report)
report.finish(code, log_path)
if args.report:
try:
report_path = report.write(args.report)
console.print(f"\n[dim]Report: {report_path}[/dim]")
except OSError as exc:
console.print(f"[yellow]Could not write the report: {exc}[/yellow]")
logging.getLogger(__name__).warning("Report not written: %s", exc)
if (args.notify or args.notify_webhook) and not args.dry_run:
send_notifications(
console, report, desktop=args.notify, webhook=args.notify_webhook
)
finally:
close_logging()
if log_path:
console.print(f"\n[dim]Log file: {log_path}[/dim]")
return code
def doctor_main(argv):
parser = argparse.ArgumentParser(
prog="commitrocket doctor",
description="Check that Git, git-lfs, the repository, the remote and "
"your settings are ready for a run. Changes nothing.",
allow_abbrev=False,
)
parser.add_argument("--repo", default=".", help="Repository to check (default: .)")
parser.add_argument("--remote", default=None, help="Remote to check")
parser.add_argument("--branch", default=None, help="Remote branch to check")
parser.add_argument("--config", default=None, help="Config file to check")
parser.add_argument(
"--offline",
action="store_true",
help="Skip the checks that contact the remote",
)
args = parser.parse_args(argv)
console = Console()
log_path = setup_logging(DEFAULT_LEVEL, None)
try:
code = run_doctor(
console,
args.repo,
remote=args.remote,
branch=args.branch,
offline=args.offline,
config_path=args.config,
)
finally:
close_logging()
if log_path:
console.print(f"\n[dim]Log file: {log_path}[/dim]")
return code
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
if argv and argv[0] == "doctor":
return doctor_main(argv[1:])
return run_main(argv)
if __name__ == "__main__":
raise SystemExit(main())