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,31 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from commitrocket.git import run_git
5
+
6
+
7
+ class Journal:
8
+ """Remembers the batch being staged so an interrupted run can be resumed.
9
+
10
+ It lives inside .git, is written before `git add` and removed once the commit
11
+ exists. Staged files that match it are ours; any other staged file is not.
12
+ """
13
+
14
+ def __init__(self, repo):
15
+ relative = run_git(
16
+ repo, "rev-parse", "--git-path", "commitrocket-journal.json"
17
+ ).stdout.strip()
18
+ self.path = Path(repo) / relative
19
+
20
+ def read(self):
21
+ try:
22
+ return json.loads(self.path.read_text(encoding="utf-8"))
23
+ except (OSError, ValueError):
24
+ return None
25
+
26
+ def write(self, files, message, number):
27
+ entry = {"files": list(files), "message": message, "number": number}
28
+ self.path.write_text(json.dumps(entry), encoding="utf-8")
29
+
30
+ def clear(self):
31
+ self.path.unlink(missing_ok=True)
commitrocket/lfs.py ADDED
@@ -0,0 +1,212 @@
1
+ import os
2
+ import re
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path, PurePosixPath
7
+
8
+ from commitrocket.errors import CommitRocketError
9
+ from commitrocket.git import get_file_size, run_git
10
+ from commitrocket.security import has_control_chars
11
+
12
+ # Git hosts reject pushes containing files larger than this.
13
+ HOST_LIMIT_MIB = 100
14
+
15
+ # Files above this size go through LFS by default (--lfs-threshold-mib; 0 = all).
16
+ LFS_THRESHOLD_MIB = HOST_LIMIT_MIB
17
+
18
+ # Files that must stay ordinary blobs even when every file goes through LFS.
19
+ LFS_EXCLUDED = (".gitattributes", ".gitignore", ".lfsconfig")
20
+
21
+
22
+ def find_lfs_candidates(repo, files, threshold_bytes, max_bytes):
23
+ """Files above the LFS threshold that are still within the max file size."""
24
+ return [
25
+ path
26
+ for path in files
27
+ if threshold_bytes < get_file_size(repo, path) <= max_bytes
28
+ ]
29
+
30
+
31
+ def attribute_line(path):
32
+ """A .gitattributes-style rule that sends exactly this file through LFS."""
33
+ if has_control_chars(path):
34
+ # A newline here would let a file name add rules of its own.
35
+ raise CommitRocketError(
36
+ f"Cannot create an LFS rule for {path!r}: the file name contains "
37
+ "control characters. Rename the file and try again."
38
+ )
39
+ glob_escaped = re.sub(r"([*?\[\]\\])", r"\\\1", path)
40
+ quoted = glob_escaped.replace("\\", "\\\\").replace('"', '\\"')
41
+ return f'"/{quoted}" filter=lfs diff=lfs merge=lfs -text'
42
+
43
+
44
+ def committed_rule_lines(paths):
45
+ """Rules for .gitattributes: one per file extension, exact path if it has none.
46
+
47
+ Extension rules also cover future files of the same type.
48
+ """
49
+ lines = set()
50
+ for path in paths:
51
+ pure = PurePosixPath(path)
52
+ if pure.name in LFS_EXCLUDED:
53
+ continue
54
+ suffix = pure.suffix
55
+ if suffix and not re.search(r"[\s*?\[\]\\\"]", suffix):
56
+ lines.add(f"*{suffix} filter=lfs diff=lfs merge=lfs -text")
57
+ else:
58
+ lines.add(attribute_line(path))
59
+ return sorted(lines)
60
+
61
+
62
+ def all_files_rules():
63
+ """Rules sending every file through LFS, except the Git config files."""
64
+ rules = ["* filter=lfs diff=lfs merge=lfs -text"]
65
+ rules += [f"{name} !filter !diff !merge !text" for name in LFS_EXCLUDED]
66
+ return rules
67
+
68
+
69
+ def is_lfs_available():
70
+ result = subprocess.run(["git", "lfs", "version"], capture_output=True)
71
+ return result.returncode == 0
72
+
73
+
74
+ def install_command():
75
+ """Package-manager command that installs git-lfs, or None if unsupported."""
76
+ if sys.platform == "win32" and shutil.which("winget"):
77
+ return [
78
+ "winget",
79
+ "install",
80
+ "--id",
81
+ "GitHub.GitLFS",
82
+ "-e",
83
+ "--accept-source-agreements",
84
+ "--accept-package-agreements",
85
+ ]
86
+ if sys.platform == "darwin" and shutil.which("brew"):
87
+ return ["brew", "install", "git-lfs"]
88
+ if sys.platform.startswith("linux") and shutil.which("apt-get"):
89
+ command = ["apt-get", "install", "-y", "git-lfs"]
90
+ if os.geteuid() != 0 and shutil.which("sudo"):
91
+ command.insert(0, "sudo")
92
+ return command
93
+ return None
94
+
95
+
96
+ def ensure_git_lfs(console, force):
97
+ """Make sure the git-lfs binary is available, installing it if needed."""
98
+ if is_lfs_available():
99
+ return
100
+
101
+ command = install_command()
102
+ if command is None:
103
+ raise CommitRocketError(
104
+ "git-lfs is not installed and no supported package manager was "
105
+ "found. Install it from https://git-lfs.com and try again."
106
+ )
107
+
108
+ if not force:
109
+ answer = (
110
+ console.input(
111
+ "[yellow]git-lfs is not installed. Run "
112
+ f"'{' '.join(command)}'? [y/N][/yellow] "
113
+ )
114
+ .strip()
115
+ .lower()
116
+ )
117
+ if answer not in {"y", "yes"}:
118
+ raise CommitRocketError("git-lfs is required for --use-lfs.")
119
+
120
+ console.print("[bold cyan]Installing git-lfs...[/bold cyan]")
121
+ if subprocess.run(command).returncode != 0:
122
+ raise CommitRocketError("Installing git-lfs failed.")
123
+
124
+ if not is_lfs_available():
125
+ raise CommitRocketError(
126
+ "git-lfs was installed but is not on PATH yet. "
127
+ "Restart your terminal and run the command again."
128
+ )
129
+
130
+
131
+ class LfsSession:
132
+ """Route large files through LFS for one run, with nothing committed.
133
+
134
+ The rules go into .git/info/attributes, which Git honours but never
135
+ commits, and are taken back out by close().
136
+ """
137
+
138
+ def __init__(self, repo):
139
+ self.repo = repo
140
+ self.attributes_path = None
141
+ self.original = None
142
+ self.installed = False
143
+
144
+ def enable(self, paths, all_files=False):
145
+ """Route paths through LFS, or every file when all_files is set."""
146
+ info_path = run_git(
147
+ self.repo, "rev-parse", "--git-path", "info/attributes"
148
+ ).stdout.strip()
149
+ self.attributes_path = Path(self.repo) / info_path
150
+ self.original = (
151
+ self.attributes_path.read_bytes() if self.attributes_path.exists() else None
152
+ )
153
+
154
+ self.install_hooks()
155
+
156
+ lines = all_files_rules() if all_files else map(attribute_line, paths)
157
+ rules = "".join(f"{line}\n" for line in lines)
158
+ existing = (self.original or b"").decode("utf-8", errors="replace")
159
+ if existing and not existing.endswith("\n"):
160
+ existing += "\n"
161
+
162
+ self.attributes_path.parent.mkdir(parents=True, exist_ok=True)
163
+ self.attributes_path.write_text(existing + rules, encoding="utf-8")
164
+
165
+ def install_hooks(self):
166
+ """Install LFS in this repository only, so pushes upload LFS files."""
167
+ run_git(self.repo, "lfs", "install", "--local")
168
+ self.installed = True
169
+
170
+ def write_committed_rules(self, paths):
171
+ """Append extension rules for paths to .gitattributes, to be committed.
172
+
173
+ Returns True if the file changed.
174
+ """
175
+ dirty = run_git(
176
+ self.repo, "status", "--porcelain", "--", ".gitattributes"
177
+ ).stdout.strip()
178
+ if dirty:
179
+ raise CommitRocketError(
180
+ ".gitattributes has uncommitted changes. Commit or discard "
181
+ "them before using --lfs-commit-rules."
182
+ )
183
+
184
+ target = Path(self.repo) / ".gitattributes"
185
+ existing = target.read_text(encoding="utf-8") if target.exists() else ""
186
+ known = {line.strip() for line in existing.splitlines()}
187
+ missing = [line for line in committed_rule_lines(paths) if line not in known]
188
+ if not missing:
189
+ return False
190
+
191
+ if existing and not existing.endswith("\n"):
192
+ existing += "\n"
193
+ rules = "".join(f"{line}\n" for line in missing)
194
+ target.write_text(existing + rules, encoding="utf-8")
195
+ return True
196
+
197
+ def close(self, uninstall_hooks=True):
198
+ """Restore the attributes file and remove the local LFS setup.
199
+
200
+ Pass uninstall_hooks=False to keep the hooks, which unpushed commits
201
+ containing LFS files still need to upload their data.
202
+ """
203
+ if self.attributes_path is not None:
204
+ if self.original is None:
205
+ self.attributes_path.unlink(missing_ok=True)
206
+ else:
207
+ self.attributes_path.write_bytes(self.original)
208
+ self.attributes_path = None
209
+
210
+ if self.installed and uninstall_hooks:
211
+ run_git(self.repo, "lfs", "uninstall", "--local", check=False)
212
+ self.installed = False
commitrocket/logs.py ADDED
@@ -0,0 +1,67 @@
1
+ import logging
2
+ import os
3
+ import sys
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ from commitrocket.security import redact, restrict_permissions
8
+
9
+ LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR")
10
+ DEFAULT_LEVEL = "INFO"
11
+ LOGGER_NAME = "commitrocket"
12
+
13
+
14
+ class RedactingFormatter(logging.Formatter):
15
+ """Hides credentials embedded in URLs, including in tracebacks."""
16
+
17
+ def format(self, record):
18
+ return redact(super().format(record))
19
+
20
+
21
+ def default_log_dir():
22
+ """The conventional per-user log folder for the current OS."""
23
+ home = Path.home()
24
+
25
+ if sys.platform == "win32":
26
+ base = Path(os.environ.get("LOCALAPPDATA") or home / "AppData" / "Local")
27
+ return base / "CommitRocket" / "Logs"
28
+ if sys.platform == "darwin":
29
+ return home / "Library" / "Logs" / "CommitRocket"
30
+
31
+ state = os.environ.get("XDG_STATE_HOME")
32
+ return (Path(state) if state else home / ".local" / "state") / "commitrocket"
33
+
34
+
35
+ def setup_logging(level=DEFAULT_LEVEL, log_file=None):
36
+ """Log to a file for this run. Returns its path, or None if it can't be opened."""
37
+ logger = logging.getLogger(LOGGER_NAME)
38
+ logger.setLevel(getattr(logging, level.upper()))
39
+ logger.propagate = False
40
+ close_logging()
41
+
42
+ if log_file:
43
+ path = Path(log_file).expanduser()
44
+ else:
45
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
46
+ path = default_log_dir() / f"commitrocket-{stamp}.log"
47
+
48
+ try:
49
+ path.parent.mkdir(parents=True, exist_ok=True)
50
+ handler = logging.FileHandler(path, encoding="utf-8")
51
+ except OSError:
52
+ logger.addHandler(logging.NullHandler())
53
+ return None
54
+
55
+ restrict_permissions(path)
56
+ handler.setFormatter(
57
+ RedactingFormatter("%(asctime)s %(levelname)-7s %(name)s: %(message)s")
58
+ )
59
+ logger.addHandler(handler)
60
+ return path.resolve()
61
+
62
+
63
+ def close_logging():
64
+ logger = logging.getLogger(LOGGER_NAME)
65
+ for handler in list(logger.handlers):
66
+ handler.close()
67
+ logger.removeHandler(handler)
commitrocket/notify.py ADDED
@@ -0,0 +1,162 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import urllib.error
8
+ import urllib.request
9
+ from urllib.parse import urlparse
10
+
11
+ from commitrocket.eta import format_duration
12
+ from commitrocket.report import (
13
+ ABORTED,
14
+ CANCELLED,
15
+ DEFERRED,
16
+ FAILED,
17
+ INTERRUPTED,
18
+ SUCCESS,
19
+ )
20
+ from commitrocket.security import redact
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Windows PowerShell's own AppUserModelID, which Windows already trusts to show toasts.
25
+ WINDOWS_APP_ID = (
26
+ "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe"
27
+ )
28
+ WINDOWS_SCRIPT = f"""
29
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
30
+ $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
31
+ $text = $xml.GetElementsByTagName('text')
32
+ $text.Item(0).AppendChild($xml.CreateTextNode($env:CR_TITLE)) | Out-Null
33
+ $text.Item(1).AppendChild($xml.CreateTextNode($env:CR_BODY)) | Out-Null
34
+ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
35
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{WINDOWS_APP_ID}').Show($toast)
36
+ """ # noqa: E501
37
+ MACOS_SCRIPT = (
38
+ 'display notification (system attribute "CR_BODY") '
39
+ 'with title (system attribute "CR_TITLE")'
40
+ )
41
+
42
+ DESKTOP_TIMEOUT = 15
43
+ WEBHOOK_TIMEOUT = 10
44
+
45
+ HEADLINES = {
46
+ SUCCESS: "finished",
47
+ DEFERRED: "finished, some files were skipped",
48
+ CANCELLED: "cancelled",
49
+ ABORTED: "stopped before starting",
50
+ INTERRUPTED: "interrupted",
51
+ FAILED: "failed",
52
+ }
53
+
54
+
55
+ def build_message(report):
56
+ """(title, body) describing how the run ended."""
57
+ title = f"CommitRocket {HEADLINES.get(report.status, report.status)}"
58
+
59
+ parts = []
60
+ if report.batches:
61
+ mib = report.total_bytes / (1024 * 1024)
62
+ verb = "committed" if report.settings.get("no_push") else "pushed"
63
+ parts.append(
64
+ f"{len(report.batches)} batch(es) {verb}, {report.total_files} file(s), "
65
+ f"{mib:.1f} MiB"
66
+ )
67
+ if report.deferred:
68
+ parts.append(f"{len(report.deferred)} file(s) skipped for size")
69
+ if report.error:
70
+ parts.append(report.error.strip().splitlines()[0])
71
+ if report.target:
72
+ parts.append(f"to {report.target}")
73
+ if report.duration_seconds is not None:
74
+ parts.append(f"in {format_duration(report.duration_seconds)}")
75
+
76
+ return title, redact("; ".join(parts) or "No changes were made.")
77
+
78
+
79
+ def _run_quietly(command, env):
80
+ result = subprocess.run(
81
+ command,
82
+ env={**os.environ, **env},
83
+ stdout=subprocess.DEVNULL,
84
+ stderr=subprocess.DEVNULL,
85
+ timeout=DESKTOP_TIMEOUT,
86
+ )
87
+ return result.returncode == 0
88
+
89
+
90
+ def notify_desktop(title, body):
91
+ """Show a native notification. Returns the method used; 'bell' if none worked."""
92
+ env = {"CR_TITLE": title, "CR_BODY": body}
93
+ try:
94
+ if sys.platform == "win32" and shutil.which("powershell"):
95
+ command = ["powershell", "-NoProfile", "-NonInteractive", "-Command"]
96
+ if _run_quietly(command + [WINDOWS_SCRIPT], env):
97
+ return "windows-toast"
98
+ elif sys.platform == "darwin" and shutil.which("osascript"):
99
+ if _run_quietly(["osascript", "-e", MACOS_SCRIPT], env):
100
+ return "macos-notification"
101
+ elif shutil.which("notify-send"):
102
+ command = ["notify-send", "--app-name=CommitRocket", title, body]
103
+ if _run_quietly(command, env):
104
+ return "notify-send"
105
+ except (OSError, subprocess.SubprocessError) as exc:
106
+ logger.warning("Desktop notification failed: %s", exc)
107
+
108
+ sys.stdout.write("\a")
109
+ sys.stdout.flush()
110
+ return "bell"
111
+
112
+
113
+ def is_valid_webhook(url):
114
+ parsed = urlparse(url)
115
+ return parsed.scheme in ("http", "https") and bool(parsed.netloc)
116
+
117
+
118
+ def notify_webhook(url, title, body, report):
119
+ """POST a JSON summary. 'text' and 'content' suit Slack/Teams and Discord."""
120
+ payload = {
121
+ "text": f"{title}: {body}",
122
+ "content": f"{title}: {body}",
123
+ "title": title,
124
+ "message": body,
125
+ "status": report.status,
126
+ "exit_code": report.exit_code,
127
+ "repo": report.repo,
128
+ "target": redact(report.target) if report.target else None,
129
+ "batches": len(report.batches),
130
+ "files": report.total_files,
131
+ "size_bytes": report.total_bytes,
132
+ "duration_seconds": report.duration_seconds,
133
+ }
134
+ request = urllib.request.Request(
135
+ url,
136
+ data=json.dumps(payload).encode("utf-8"),
137
+ headers={
138
+ "Content-Type": "application/json",
139
+ "Title": title.encode("ascii", "replace").decode("ascii"),
140
+ "User-Agent": "commitrocket",
141
+ },
142
+ method="POST",
143
+ )
144
+ with urllib.request.urlopen(request, timeout=WEBHOOK_TIMEOUT) as response:
145
+ return response.status
146
+
147
+
148
+ def send_notifications(console, report, desktop=False, webhook=None):
149
+ """Notify about the finished run. Failures are reported but never raised."""
150
+ title, body = build_message(report)
151
+
152
+ if desktop:
153
+ method = notify_desktop(title, body)
154
+ logger.info("Desktop notification sent via %s", method)
155
+
156
+ if webhook:
157
+ try:
158
+ status = notify_webhook(webhook, title, body, report)
159
+ logger.info("Webhook notified (HTTP %s)", status)
160
+ except (urllib.error.URLError, OSError, ValueError) as exc:
161
+ logger.warning("Webhook notification failed: %s", exc)
162
+ console.print(f"[yellow]Could not notify the webhook: {exc}[/yellow]")
@@ -0,0 +1,38 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ from commitrocket.batching import DETERMINISTIC
4
+ from commitrocket.lfs import LFS_THRESHOLD_MIB
5
+ from commitrocket.template import DEFAULT_TEMPLATE
6
+
7
+
8
+ @dataclass
9
+ class RunOptions:
10
+ """Everything a run needs, as parsed from the command line and config file."""
11
+
12
+ repo: str = "."
13
+ message: str = ""
14
+ number: int = 1
15
+ force: bool = False
16
+ limit_mib: int = 100
17
+ max_mib: int = 300
18
+ batch_type: str = DETERMINISTIC
19
+ message_template: str = DEFAULT_TEMPLATE
20
+ remote: str | None = None
21
+ branch: str | None = None
22
+ no_push: bool = False
23
+ dry_run: bool = False
24
+ retries: int = 3
25
+ post_buffer_mib: int | None = None
26
+ compression_threads: int | None = None
27
+ use_lfs: bool = False
28
+ lfs_threshold_mib: int = LFS_THRESHOLD_MIB
29
+ lfs_threads: int | None = None
30
+ lfs_batch_mib: int | None = None
31
+ lfs_commit_rules: bool = False
32
+ include: list = field(default_factory=list)
33
+ exclude: list = field(default_factory=list)
34
+ include_from: list = field(default_factory=list)
35
+ exclude_from: list = field(default_factory=list)
36
+ # Files the run itself writes; they are never committed, even inside the repo.
37
+ log_file: str | None = None
38
+ report: str | None = None