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/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()