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/push.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from rich.markup import escape
|
|
8
|
+
|
|
9
|
+
from commitrocket.errors import PushError
|
|
10
|
+
from commitrocket.security import redact
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
PERCENT_RE = re.compile(r"(\d{1,3})%")
|
|
15
|
+
|
|
16
|
+
UPLOAD_PHASE = "Uploading objects"
|
|
17
|
+
LFS_PHASE = "Uploading LFS objects"
|
|
18
|
+
|
|
19
|
+
# git-lfs reports e.g. "Uploading LFS objects: 50% (1/2), 52 MB | 3.2 MB/s".
|
|
20
|
+
# The percentage counts files, so the transferred byte count is used instead.
|
|
21
|
+
LFS_BYTES_RE = re.compile(
|
|
22
|
+
r"Uploading LFS objects:.*?\)\s*,\s*([\d.]+)\s*([KMGT]?B)\b", re.IGNORECASE
|
|
23
|
+
)
|
|
24
|
+
LFS_UNITS = {"B": 1, "KB": 1000, "MB": 1000**2, "GB": 1000**3, "TB": 1000**4}
|
|
25
|
+
|
|
26
|
+
# Output that means the host refused the push, so a retry would fail the same way.
|
|
27
|
+
NON_RETRYABLE_MARKERS = (
|
|
28
|
+
"[rejected]",
|
|
29
|
+
"[remote rejected]",
|
|
30
|
+
"remote rejected",
|
|
31
|
+
"pre-receive hook declined",
|
|
32
|
+
"non-fast-forward",
|
|
33
|
+
"fetch first",
|
|
34
|
+
"gh001",
|
|
35
|
+
"large files detected",
|
|
36
|
+
"protected branch",
|
|
37
|
+
"authentication failed",
|
|
38
|
+
"permission denied",
|
|
39
|
+
"access denied",
|
|
40
|
+
"repository not found",
|
|
41
|
+
"could not read username",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
RETRY_BASE_SECONDS = 5
|
|
45
|
+
|
|
46
|
+
PHASES = (
|
|
47
|
+
("enumerating objects", "Enumerating objects"),
|
|
48
|
+
("counting objects", "Counting objects"),
|
|
49
|
+
("compressing objects", "Compressing objects"),
|
|
50
|
+
("writing objects", UPLOAD_PHASE),
|
|
51
|
+
("resolving deltas", "Resolving deltas"),
|
|
52
|
+
("receiving objects", "Receiving objects"),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_progress_line(line):
|
|
57
|
+
"""Return (phase, percent) for a git progress line; percent may be None."""
|
|
58
|
+
match = PERCENT_RE.search(line)
|
|
59
|
+
percent = min(100, int(match.group(1))) if match else None
|
|
60
|
+
|
|
61
|
+
lower = line.lower()
|
|
62
|
+
phase = next((label for key, label in PHASES if key in lower), "Git push")
|
|
63
|
+
return phase, percent
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_retryable(output):
|
|
67
|
+
"""False for failures that trying again cannot fix (rejections, auth)."""
|
|
68
|
+
lower = output.lower()
|
|
69
|
+
return not any(marker in lower for marker in NON_RETRYABLE_MARKERS)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_lfs_bytes(line):
|
|
73
|
+
"""Bytes transferred so far according to a git-lfs upload line, else None."""
|
|
74
|
+
match = LFS_BYTES_RE.search(line)
|
|
75
|
+
if not match:
|
|
76
|
+
return None
|
|
77
|
+
return int(float(match.group(1)) * LFS_UNITS[match.group(2).upper()])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def push_with_retries(
|
|
81
|
+
repo, progress, push_task, console, retries=0, sleep=time.sleep, **kwargs
|
|
82
|
+
):
|
|
83
|
+
"""push_with_progress, retried with growing waits on transient errors."""
|
|
84
|
+
attempt = 0
|
|
85
|
+
while True:
|
|
86
|
+
try:
|
|
87
|
+
return push_with_progress(repo, progress, push_task, console, **kwargs)
|
|
88
|
+
except PushError as exc:
|
|
89
|
+
if not exc.retryable or attempt >= retries:
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
delay = RETRY_BASE_SECONDS * 3**attempt
|
|
93
|
+
attempt += 1
|
|
94
|
+
logger.warning(
|
|
95
|
+
"Push failed (attempt %d of %d), retrying in %d s: %s",
|
|
96
|
+
attempt,
|
|
97
|
+
retries,
|
|
98
|
+
delay,
|
|
99
|
+
exc,
|
|
100
|
+
)
|
|
101
|
+
console.print(
|
|
102
|
+
f"[yellow]Push failed. Retrying in {delay} s "
|
|
103
|
+
f"(retry {attempt} of {retries})...[/yellow]"
|
|
104
|
+
)
|
|
105
|
+
sleep(delay)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def push_with_progress(
|
|
109
|
+
repo,
|
|
110
|
+
progress,
|
|
111
|
+
push_task,
|
|
112
|
+
console,
|
|
113
|
+
on_upload=None,
|
|
114
|
+
batch_bytes=None,
|
|
115
|
+
remote=None,
|
|
116
|
+
refspec=None,
|
|
117
|
+
):
|
|
118
|
+
"""Stream Git push progress into a live Rich progress task.
|
|
119
|
+
|
|
120
|
+
on_upload, if given, is called with a rising percent (0-100) of the upload,
|
|
121
|
+
covering both the git-lfs upload and the pack transfer. batch_bytes is the
|
|
122
|
+
size of the batch being pushed and lets LFS byte counts become a percent.
|
|
123
|
+
"""
|
|
124
|
+
console.print("\n[bold cyan]Pushing commit...[/bold cyan]")
|
|
125
|
+
logger.info("Pushing %s", repo)
|
|
126
|
+
|
|
127
|
+
progress.update(
|
|
128
|
+
push_task,
|
|
129
|
+
description="Git push: Starting",
|
|
130
|
+
total=100,
|
|
131
|
+
completed=0,
|
|
132
|
+
visible=True,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
process = subprocess.Popen(
|
|
136
|
+
["git", "-C", str(repo), "push", "--progress"]
|
|
137
|
+
+ (["--", remote, refspec] if remote else []),
|
|
138
|
+
stdout=subprocess.PIPE,
|
|
139
|
+
stderr=subprocess.STDOUT,
|
|
140
|
+
bufsize=0,
|
|
141
|
+
# Without a terminal git-lfs would otherwise print no progress at all.
|
|
142
|
+
env={**os.environ, "GIT_LFS_FORCE_PROGRESS": "1"},
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
pending = b""
|
|
146
|
+
messages = []
|
|
147
|
+
best_percent = 0
|
|
148
|
+
logged_phase = None
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
while True:
|
|
152
|
+
chunk = os.read(process.stdout.fileno(), 4096)
|
|
153
|
+
|
|
154
|
+
if not chunk:
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
pending += chunk
|
|
158
|
+
|
|
159
|
+
# Git progress commonly uses carriage returns instead
|
|
160
|
+
# of newlines to refresh terminal output.
|
|
161
|
+
parts = re.split(rb"[\r\n]+", pending)
|
|
162
|
+
pending = parts.pop()
|
|
163
|
+
|
|
164
|
+
for raw_line in parts:
|
|
165
|
+
line = raw_line.decode("utf-8", errors="replace").strip()
|
|
166
|
+
|
|
167
|
+
if not line:
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
messages.append(line)
|
|
171
|
+
|
|
172
|
+
phase, percent = parse_progress_line(line)
|
|
173
|
+
|
|
174
|
+
uploaded = parse_lfs_bytes(line)
|
|
175
|
+
if uploaded is not None:
|
|
176
|
+
phase = LFS_PHASE
|
|
177
|
+
if batch_bytes:
|
|
178
|
+
percent = min(100, int(uploaded * 100 / batch_bytes))
|
|
179
|
+
|
|
180
|
+
# Progress lines repeat constantly; keep one per phase change
|
|
181
|
+
# plus the final one, and every other line.
|
|
182
|
+
if percent is None or phase != logged_phase or percent == 100:
|
|
183
|
+
logger.debug("push: %s", line)
|
|
184
|
+
logged_phase = phase
|
|
185
|
+
|
|
186
|
+
if (
|
|
187
|
+
on_upload
|
|
188
|
+
and percent is not None
|
|
189
|
+
and phase in (UPLOAD_PHASE, LFS_PHASE)
|
|
190
|
+
and percent > best_percent
|
|
191
|
+
):
|
|
192
|
+
best_percent = percent
|
|
193
|
+
on_upload(percent)
|
|
194
|
+
|
|
195
|
+
if percent is not None:
|
|
196
|
+
progress.update(
|
|
197
|
+
push_task,
|
|
198
|
+
description=phase,
|
|
199
|
+
completed=percent,
|
|
200
|
+
total=100,
|
|
201
|
+
visible=True,
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
progress.update(push_task, description=phase, visible=True)
|
|
205
|
+
|
|
206
|
+
# Process any final output without a trailing delimiter.
|
|
207
|
+
if pending.strip():
|
|
208
|
+
line = pending.decode("utf-8", errors="replace").strip()
|
|
209
|
+
if line:
|
|
210
|
+
messages.append(line)
|
|
211
|
+
|
|
212
|
+
return_code = process.wait()
|
|
213
|
+
|
|
214
|
+
except BaseException:
|
|
215
|
+
process.kill()
|
|
216
|
+
process.wait()
|
|
217
|
+
raise
|
|
218
|
+
|
|
219
|
+
finally:
|
|
220
|
+
if process.stdout:
|
|
221
|
+
process.stdout.close()
|
|
222
|
+
|
|
223
|
+
logger.info("git push finished with exit code %d", return_code)
|
|
224
|
+
|
|
225
|
+
if return_code != 0:
|
|
226
|
+
progress.update(push_task, description="Git push failed", visible=True)
|
|
227
|
+
raise PushError(
|
|
228
|
+
"git push failed with exit code "
|
|
229
|
+
f"{return_code}:\n" + redact("\n".join(messages[-15:])),
|
|
230
|
+
retryable=is_retryable("\n".join(messages)),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
progress.update(
|
|
234
|
+
push_task,
|
|
235
|
+
description="Git push complete",
|
|
236
|
+
completed=100,
|
|
237
|
+
total=100,
|
|
238
|
+
visible=True,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Display non-progress messages such as the remote branch update.
|
|
242
|
+
for line in messages:
|
|
243
|
+
if not PERCENT_RE.search(line):
|
|
244
|
+
console.print(f"[dim]{escape(redact(line))}[/dim]")
|
|
245
|
+
|
|
246
|
+
progress.update(push_task, visible=False)
|
commitrocket/report.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from commitrocket import __version__
|
|
10
|
+
from commitrocket.security import redact_value, restrict_permissions
|
|
11
|
+
|
|
12
|
+
SCHEMA_VERSION = 1
|
|
13
|
+
|
|
14
|
+
SUCCESS = "success"
|
|
15
|
+
DEFERRED = "completed_with_deferred_files"
|
|
16
|
+
DRY_RUN = "dry_run"
|
|
17
|
+
CANCELLED = "cancelled"
|
|
18
|
+
ABORTED = "aborted"
|
|
19
|
+
INTERRUPTED = "interrupted"
|
|
20
|
+
FAILED = "failed"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RunReport:
|
|
24
|
+
"""Collects what a run did, for --report and for notifications."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, options=None):
|
|
27
|
+
self.started_at = datetime.now(UTC)
|
|
28
|
+
self._started = time.monotonic()
|
|
29
|
+
self.finished_at = None
|
|
30
|
+
self.duration_seconds = None
|
|
31
|
+
self.status = FAILED
|
|
32
|
+
self.exit_code = None
|
|
33
|
+
self.error = None
|
|
34
|
+
self.repo = None
|
|
35
|
+
self.branch = None
|
|
36
|
+
self.target = None
|
|
37
|
+
self.log_file = None
|
|
38
|
+
# --remote may be a URL with embedded credentials.
|
|
39
|
+
self.settings = redact_value(asdict(options)) if options else {}
|
|
40
|
+
self.resumed = {"staged_files": 0, "unpushed_commits": 0}
|
|
41
|
+
self.lfs = {"files": 0, "rules_commit": None}
|
|
42
|
+
self.batches = []
|
|
43
|
+
self.deferred = []
|
|
44
|
+
|
|
45
|
+
def abort(self, reason):
|
|
46
|
+
self.status = ABORTED
|
|
47
|
+
self.error = reason
|
|
48
|
+
|
|
49
|
+
def add_batch(self, number, message, files, size_bytes, lfs_files=0, commit=None):
|
|
50
|
+
entry = {
|
|
51
|
+
"number": number,
|
|
52
|
+
"message": message,
|
|
53
|
+
"commit": commit,
|
|
54
|
+
"file_count": len(files),
|
|
55
|
+
"size_bytes": size_bytes,
|
|
56
|
+
"lfs_file_count": lfs_files,
|
|
57
|
+
"pushed": False,
|
|
58
|
+
"verified": False,
|
|
59
|
+
"files": list(files),
|
|
60
|
+
}
|
|
61
|
+
self.batches.append(entry)
|
|
62
|
+
return entry
|
|
63
|
+
|
|
64
|
+
def set_deferred(self, items):
|
|
65
|
+
self.deferred = [{"path": path, "size_bytes": size} for path, size in items]
|
|
66
|
+
|
|
67
|
+
def finish(self, exit_code, log_file=None):
|
|
68
|
+
self.exit_code = exit_code
|
|
69
|
+
self.log_file = str(log_file) if log_file else None
|
|
70
|
+
self.finished_at = datetime.now(UTC)
|
|
71
|
+
self.duration_seconds = round(time.monotonic() - self._started, 3)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def total_files(self):
|
|
75
|
+
return sum(batch["file_count"] for batch in self.batches)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def total_bytes(self):
|
|
79
|
+
return sum(batch["size_bytes"] for batch in self.batches)
|
|
80
|
+
|
|
81
|
+
def to_dict(self):
|
|
82
|
+
return {
|
|
83
|
+
"schema_version": SCHEMA_VERSION,
|
|
84
|
+
"tool": "commitrocket",
|
|
85
|
+
"version": __version__,
|
|
86
|
+
"status": self.status,
|
|
87
|
+
"exit_code": self.exit_code,
|
|
88
|
+
"error": self.error,
|
|
89
|
+
"started_at": self.started_at.isoformat(),
|
|
90
|
+
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
|
|
91
|
+
"duration_seconds": self.duration_seconds,
|
|
92
|
+
"repo": self.repo,
|
|
93
|
+
"branch": self.branch,
|
|
94
|
+
"target": self.target,
|
|
95
|
+
"dry_run": bool(self.settings.get("dry_run")),
|
|
96
|
+
"no_push": bool(self.settings.get("no_push")),
|
|
97
|
+
"settings": self.settings,
|
|
98
|
+
"resumed": self.resumed,
|
|
99
|
+
"lfs": self.lfs,
|
|
100
|
+
"totals": {
|
|
101
|
+
"batches": len(self.batches),
|
|
102
|
+
"files": self.total_files,
|
|
103
|
+
"size_bytes": self.total_bytes,
|
|
104
|
+
"deferred_files": len(self.deferred),
|
|
105
|
+
},
|
|
106
|
+
"batches": self.batches,
|
|
107
|
+
"deferred": self.deferred,
|
|
108
|
+
"log_file": self.log_file,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
def write(self, path):
|
|
112
|
+
"""Write the report as JSON, replacing any file there in one step."""
|
|
113
|
+
path = Path(path).expanduser()
|
|
114
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
text = json.dumps(self.to_dict(), indent=2, ensure_ascii=False) + "\n"
|
|
116
|
+
|
|
117
|
+
# A randomly named file created exclusively (and private to the owner), so
|
|
118
|
+
# nobody can pre-create a link at a predictable name to redirect the write.
|
|
119
|
+
descriptor, name = tempfile.mkstemp(
|
|
120
|
+
dir=path.parent, prefix=path.name + ".", suffix=".tmp"
|
|
121
|
+
)
|
|
122
|
+
try:
|
|
123
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
124
|
+
handle.write(text)
|
|
125
|
+
restrict_permissions(name)
|
|
126
|
+
os.replace(name, path)
|
|
127
|
+
except BaseException:
|
|
128
|
+
Path(name).unlink(missing_ok=True)
|
|
129
|
+
raise
|
|
130
|
+
return path.resolve()
|