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.
@@ -0,0 +1,50 @@
1
+ """Small helpers that keep untrusted text from doing harm."""
2
+
3
+ import os
4
+ import re
5
+
6
+ from commitrocket.errors import CommitRocketError
7
+
8
+ # user:password@ inside a URL, e.g. https://name:token@host/path
9
+ _CREDENTIALS = re.compile(r"(?i)\b([a-z][a-z0-9+.\-]*://)[^/\s@]+@")
10
+ _CONTROL = re.compile(r"[\x00-\x1f\x7f]")
11
+
12
+
13
+ def redact(text):
14
+ """Hide credentials embedded in URLs before text is logged or shown."""
15
+ return _CREDENTIALS.sub(r"\1***@", text)
16
+
17
+
18
+ def redact_value(value):
19
+ """redact() applied to every string inside nested dicts and lists."""
20
+ if isinstance(value, str):
21
+ return redact(value)
22
+ if isinstance(value, dict):
23
+ return {key: redact_value(item) for key, item in value.items()}
24
+ if isinstance(value, (list, tuple)):
25
+ return [redact_value(item) for item in value]
26
+ return value
27
+
28
+
29
+ def has_control_chars(text):
30
+ return bool(_CONTROL.search(text))
31
+
32
+
33
+ def validate_ref_part(value, label):
34
+ """Reject remote and branch names that Git could read as options."""
35
+ if not value or value.startswith("-") or has_control_chars(value) or any(
36
+ char.isspace() for char in value
37
+ ):
38
+ raise CommitRocketError(
39
+ f"Invalid {label} {value!r}: it must not be empty, start with '-', "
40
+ "or contain spaces or control characters."
41
+ )
42
+ return value
43
+
44
+
45
+ def restrict_permissions(path):
46
+ """Make a file readable and writable by its owner only (best effort)."""
47
+ try:
48
+ os.chmod(path, 0o600)
49
+ except OSError:
50
+ pass
@@ -0,0 +1,37 @@
1
+ from datetime import date
2
+
3
+ DEFAULT_TEMPLATE = "{message} #{n}"
4
+
5
+ PLACEHOLDERS = ("message", "n", "batch", "files", "size", "first", "date")
6
+
7
+
8
+ def render_message(template, message, n, batch, files, size_mib, first):
9
+ """Fill in a commit message template for one batch.
10
+
11
+ {message} the --message text, {n} the commit number, {batch} the position
12
+ in this run, {files} the file count, {size} the size in MiB, {first} the
13
+ first file's path and {date} today's date (YYYY-MM-DD).
14
+ """
15
+ return template.format(
16
+ message=message.rstrip(),
17
+ n=n,
18
+ batch=batch,
19
+ files=files,
20
+ size=size_mib,
21
+ first=first,
22
+ date=date.today().isoformat(),
23
+ ).strip()
24
+
25
+
26
+ def validate_template(template):
27
+ """Return an error string if the template can't be rendered, else None."""
28
+ try:
29
+ text = render_message(template, "msg", 1, 1, 1, 1.0, "a.txt")
30
+ except (KeyError, IndexError, ValueError, AttributeError) as exc:
31
+ return (
32
+ f"invalid --message-template ({exc!r}); "
33
+ f"available placeholders: {', '.join(PLACEHOLDERS)}"
34
+ )
35
+ if not text:
36
+ return "--message-template renders an empty commit message"
37
+ return None
commitrocket/ui.py ADDED
@@ -0,0 +1,216 @@
1
+ import time
2
+
3
+ from rich.markup import escape
4
+ from rich.panel import Panel
5
+ from rich.progress import (
6
+ BarColumn,
7
+ Progress,
8
+ ProgressColumn,
9
+ SpinnerColumn,
10
+ TaskProgressColumn,
11
+ TextColumn,
12
+ TimeElapsedColumn,
13
+ )
14
+ from rich.table import Table
15
+ from rich.text import Text
16
+
17
+ from commitrocket.batching import MIB
18
+ from commitrocket.eta import format_duration
19
+ from commitrocket.git import get_file_size, get_pending_files
20
+
21
+
22
+ class EtaColumn(ProgressColumn):
23
+ """Shows the run's own ETA for tasks created with an `estimator` field."""
24
+
25
+ def render(self, task):
26
+ estimator = task.fields.get("estimator")
27
+ if estimator is None:
28
+ return Text("")
29
+
30
+ remaining = estimator.estimate(task.completed, task.total or 0)
31
+ if remaining is None:
32
+ return Text("ETA estimating…", style="dim")
33
+ return Text(f"ETA {format_duration(remaining)}", style="progress.remaining")
34
+
35
+
36
+ def make_progress(console):
37
+ return Progress(
38
+ SpinnerColumn(),
39
+ TextColumn("[bold cyan]{task.description}"),
40
+ BarColumn(),
41
+ TaskProgressColumn(),
42
+ TimeElapsedColumn(),
43
+ EtaColumn(),
44
+ console=console,
45
+ )
46
+
47
+
48
+ def print_run_info(console, repo, branch, upstream, limit_mib, max_mib, number):
49
+ info = Table.grid(padding=(0, 2))
50
+ info.add_column(style="bold cyan")
51
+ info.add_column()
52
+ info.add_row("Repository", Text(str(repo)))
53
+ info.add_row("Branch", Text(branch))
54
+ info.add_row("Push target", Text(upstream))
55
+ info.add_row("Normal batch limit", f"{limit_mib} MiB")
56
+ info.add_row("Maximum file size", f"{max_mib} MiB")
57
+ info.add_row("Starting commit #", str(number))
58
+
59
+ console.print(Panel(info, title="Git Batch Push", border_style="cyan"))
60
+
61
+
62
+ def print_lfs_required(console, repo, paths, threshold_mib):
63
+ table = Table(title=f"Files over {threshold_mib} MiB", show_lines=True)
64
+ table.add_column("File", overflow="fold")
65
+ table.add_column("Size (MiB)", justify="right")
66
+
67
+ for path in sorted(paths):
68
+ table.add_row(Text(path), f"{get_file_size(repo, path) / MIB:.2f}")
69
+
70
+ console.print(table)
71
+ console.print(
72
+ f"\n[bold red]These files exceed the {threshold_mib} MiB limit of most "
73
+ "Git hosts and the push would be rejected.[/bold red]\n"
74
+ "[yellow]Nothing was committed. Run again with [bold]--use-lfs[/bold] "
75
+ "to store them with Git LFS.[/yellow]"
76
+ )
77
+
78
+
79
+ def print_resume(console, staged, entry, unpushed):
80
+ lines = []
81
+ if staged:
82
+ lines.append(
83
+ f"{len(staged)} file(s) were staged by an interrupted run and will be "
84
+ f"committed as: {entry['message']}"
85
+ )
86
+ if unpushed:
87
+ lines.append(f"{len(unpushed)} unpushed commit(s) will be pushed first:")
88
+ lines.extend(f" {commit}" for commit in unpushed[:10])
89
+ if len(unpushed) > 10:
90
+ lines.append(f" ... and {len(unpushed) - 10} more")
91
+
92
+ panel = Panel(Text("\n".join(lines)), title="Resuming", border_style="yellow")
93
+ console.print(panel)
94
+
95
+
96
+ def print_dry_run(
97
+ console, batches, deferred, messages, lfs_files, lfs_scope, no_push=False
98
+ ):
99
+ table = Table(title="Dry run: planned batches", show_lines=True)
100
+ table.add_column("Batch", justify="right")
101
+ table.add_column("Commit message")
102
+ table.add_column("Files", justify="right")
103
+ table.add_column("Size (MiB)", justify="right")
104
+ table.add_column("Via LFS", justify="right")
105
+
106
+ total_files = 0
107
+ total_bytes = 0
108
+ for index, (paths, size) in enumerate(batches):
109
+ lfs_count = len(set(paths) & set(lfs_files))
110
+ table.add_row(
111
+ str(index + 1),
112
+ Text(messages[index]),
113
+ str(len(paths)),
114
+ f"{size / MIB:.2f}",
115
+ str(lfs_count) if lfs_count else "-",
116
+ )
117
+ total_files += len(paths)
118
+ total_bytes += size
119
+
120
+ console.print(table)
121
+ console.print(
122
+ f"{len(batches)} batch(es), {total_files} file(s), "
123
+ f"{total_bytes / MIB:.2f} MiB in total."
124
+ )
125
+ if lfs_scope:
126
+ console.print(f"[cyan]{lfs_scope}[/cyan]")
127
+
128
+ if deferred:
129
+ details = Table(title="Would be deferred (over --max-mib)", show_lines=True)
130
+ details.add_column("File", overflow="fold")
131
+ details.add_column("Size (MiB)", justify="right")
132
+ for path, size in sorted(deferred):
133
+ details.add_row(Text(path), f"{size / MIB:.2f}")
134
+ console.print(details)
135
+
136
+ if no_push:
137
+ console.print("[dim]--no-push: batches would be committed, not pushed.[/dim]")
138
+
139
+ console.print(
140
+ "\n[bold green]Dry run only: nothing was staged, committed, pushed or "
141
+ "changed.[/bold green]"
142
+ )
143
+
144
+
145
+ def print_stuck_commits(console, commits, blobs, upstream, threshold_mib):
146
+ table = Table(title=f"Unpushed files over {threshold_mib} MiB", show_lines=True)
147
+ table.add_column("File", overflow="fold")
148
+ table.add_column("Size (MiB)", justify="right")
149
+
150
+ for path, size in blobs:
151
+ table.add_row(Text(path), f"{size / MIB:.2f}")
152
+
153
+ console.print(table)
154
+ console.print(f"\n[bold]{len(commits)} unpushed local commit(s):[/bold]")
155
+ for commit in commits:
156
+ console.print(Text(f" {commit}"))
157
+
158
+ console.print(
159
+ f"\n[bold red]These commits already contain files over {threshold_mib} "
160
+ "MiB, so pushing them would be rejected.[/bold red]\n"
161
+ "[yellow]Nothing was changed. To drop those commits and keep your files "
162
+ "as untracked changes, run:[/yellow]\n\n"
163
+ f" git reset --mixed {escape(upstream)}\n\n"
164
+ "[yellow]then run CommitRocket again (the commits stay recoverable via "
165
+ "'git reflog').[/yellow]"
166
+ )
167
+
168
+
169
+ def print_final_summary(
170
+ console, repo, batch_count, started_at, max_mib, pending_files=None, pushed=True
171
+ ):
172
+ """Report files still deferred after all eligible batches finish."""
173
+ elapsed = time.monotonic() - started_at
174
+ pending = pending_files() if pending_files else get_pending_files(repo)
175
+
176
+ deferred = []
177
+ for path in pending:
178
+ size = get_file_size(repo, path)
179
+ if size > max_mib * MIB:
180
+ deferred.append((path, size))
181
+
182
+ table = Table(title="Execution Summary", show_lines=True)
183
+ table.add_column("Metric", style="cyan")
184
+ table.add_column("Value", justify="right")
185
+
186
+ label = "Batches pushed" if pushed else "Batches committed"
187
+ table.add_row(label, str(batch_count))
188
+ table.add_row("Elapsed time", f"{elapsed:.1f} seconds")
189
+ table.add_row("Deferred files", str(len(deferred)))
190
+
191
+ console.print()
192
+ console.print(Panel(table, border_style="green" if not deferred else "yellow"))
193
+
194
+ if not deferred:
195
+ console.print("[bold green]All eligible files processed.[/bold green]")
196
+ return 0
197
+
198
+ details = Table(title="Deferred Files — Final Report", show_lines=True)
199
+ details.add_column("File", overflow="fold")
200
+ details.add_column("Size (MiB)", justify="right")
201
+ details.add_column("Reason", overflow="fold")
202
+
203
+ for path, size in sorted(deferred):
204
+ details.add_row(
205
+ Text(path),
206
+ f"{size / MIB:.2f}",
207
+ f"Exceeds --max-mib ({max_mib} MiB)",
208
+ )
209
+
210
+ console.print(details)
211
+ console.print(
212
+ "\n[yellow]Increase --max-mib to include these files. "
213
+ "Deferred files were not committed by this run.[/yellow]"
214
+ )
215
+
216
+ return 2
commitrocket/verify.py ADDED
@@ -0,0 +1,129 @@
1
+ import logging
2
+ import subprocess
3
+ import time
4
+ from pathlib import PurePosixPath
5
+
6
+ from commitrocket.errors import CommitRocketError, GitError
7
+ from commitrocket.git import get_remote_head, run_git
8
+ from commitrocket.lfs import LFS_EXCLUDED
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1"
13
+ MAX_POINTER_BYTES = 1024
14
+
15
+
16
+ def verify_remote_head(repo, remote, ref, retries=0, sleep=time.sleep):
17
+ """Raise unless the remote's branch points at the local HEAD."""
18
+ local = run_git(repo, "rev-parse", "HEAD").stdout.strip()
19
+
20
+ attempt = 0
21
+ while True:
22
+ try:
23
+ remote_head = get_remote_head(repo, remote, ref)
24
+ break
25
+ except GitError:
26
+ if attempt >= retries:
27
+ raise
28
+ attempt += 1
29
+ sleep(2**attempt)
30
+
31
+ if remote_head != local:
32
+ raise CommitRocketError(
33
+ f"Push verification failed: {remote} {ref} is at "
34
+ f"{(remote_head or 'nothing')[:10]} but local HEAD is {local[:10]}."
35
+ )
36
+ logger.info("Verified %s %s is at %s", remote, ref, local[:10])
37
+
38
+
39
+ def expected_lfs_paths(selected, lfs_paths):
40
+ """Files in a batch that should have been stored as LFS pointers."""
41
+ wanted = set(lfs_paths)
42
+ return [
43
+ path
44
+ for path in selected
45
+ if path in wanted and PurePosixPath(path).name not in LFS_EXCLUDED
46
+ ]
47
+
48
+
49
+ def _git_cat_file(repo, mode, specs):
50
+ result = subprocess.run(
51
+ ["git", "-C", str(repo), "cat-file", mode],
52
+ input="".join(f"{spec}\n" for spec in specs).encode("utf-8"),
53
+ capture_output=True,
54
+ )
55
+ if result.returncode != 0:
56
+ error = result.stderr.decode(errors="replace")
57
+ raise GitError(f"git cat-file failed:\n{error}")
58
+ return result.stdout
59
+
60
+
61
+ def _is_pointer(content):
62
+ return content.startswith(POINTER_PREFIX)
63
+
64
+
65
+ def unpushed_has_lfs_pointers(repo, revs):
66
+ """True if commits not yet pushed contain LFS pointer files.
67
+
68
+ Pushing those without the LFS hooks installed would publish pointers whose
69
+ data was never uploaded.
70
+ """
71
+ objects = run_git(repo, "rev-list", "--objects", *revs).stdout
72
+ if not objects.strip():
73
+ return False
74
+
75
+ info = _git_cat_file(
76
+ repo,
77
+ "--batch-check=%(objecttype) %(objectname) %(objectsize) %(rest)",
78
+ objects.splitlines(),
79
+ )
80
+ small = []
81
+ for line in info.decode("utf-8", errors="replace").splitlines():
82
+ kind, name, size = (line.split(" ", 3) + ["", "", ""])[:3]
83
+ if kind == "blob" and size.isdigit() and int(size) <= MAX_POINTER_BYTES:
84
+ small.append(name)
85
+
86
+ if not small:
87
+ return False
88
+
89
+ data = _git_cat_file(repo, "--batch", small)
90
+ position = 0
91
+ for _ in small:
92
+ newline = data.index(b"\n", position)
93
+ size = int(data[position:newline].split()[2])
94
+ content = data[newline + 1 : newline + 1 + size]
95
+ position = newline + 1 + size + 1
96
+ if _is_pointer(content):
97
+ return True
98
+ return False
99
+
100
+
101
+ def find_non_pointers(repo, paths):
102
+ """Paths whose committed content in HEAD is not an LFS pointer."""
103
+ if not paths:
104
+ return []
105
+
106
+ specs = [f"HEAD:{path}" for path in paths]
107
+ sizes = _git_cat_file(repo, "--batch-check=%(objectsize)", specs)
108
+ sizes = sizes.decode("utf-8", errors="replace").splitlines()
109
+
110
+ bad = []
111
+ small = []
112
+ for path, size in zip(paths, sizes, strict=True):
113
+ if size.endswith("missing") or int(size) > MAX_POINTER_BYTES:
114
+ bad.append(path)
115
+ else:
116
+ small.append(path)
117
+
118
+ if small:
119
+ data = _git_cat_file(repo, "--batch", [f"HEAD:{path}" for path in small])
120
+ position = 0
121
+ for path in small:
122
+ newline = data.index(b"\n", position)
123
+ size = int(data[position:newline].split()[2])
124
+ content = data[newline + 1 : newline + 1 + size]
125
+ position = newline + 1 + size + 1
126
+ if not content.startswith(POINTER_PREFIX):
127
+ bad.append(path)
128
+
129
+ return bad