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