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/config.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from commitrocket.errors import CommitRocketError
|
|
5
|
+
|
|
6
|
+
CONFIG_NAME = "commitrocket.toml"
|
|
7
|
+
PYPROJECT_NAME = "pyproject.toml"
|
|
8
|
+
|
|
9
|
+
# Options that make no sense in a config file.
|
|
10
|
+
NOT_CONFIGURABLE = {"config", "repo", "help", "version"}
|
|
11
|
+
|
|
12
|
+
# Options an untrusted (repository) config file must not set: they can skip the
|
|
13
|
+
# confirmation, push to another remote, write files, send data to a URL or read
|
|
14
|
+
# files. They work in a file passed explicitly with --config.
|
|
15
|
+
SENSITIVE_KEYS = {
|
|
16
|
+
"force",
|
|
17
|
+
"remote",
|
|
18
|
+
"branch",
|
|
19
|
+
"report",
|
|
20
|
+
"log_file",
|
|
21
|
+
"notify_webhook",
|
|
22
|
+
"include_from",
|
|
23
|
+
"exclude_from",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfigError(CommitRocketError):
|
|
28
|
+
"""The config file could not be read or holds an invalid value."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_config(repo, explicit=None):
|
|
32
|
+
"""Return (path, values) from the config file, or (None, {}) if there is none.
|
|
33
|
+
|
|
34
|
+
Looked up in the repository folder: commitrocket.toml first, then the
|
|
35
|
+
[tool.commitrocket] table of pyproject.toml. --config overrides the lookup.
|
|
36
|
+
"""
|
|
37
|
+
if explicit:
|
|
38
|
+
path = Path(explicit).expanduser()
|
|
39
|
+
if not path.is_file():
|
|
40
|
+
raise ConfigError(f"Config file not found: {path}")
|
|
41
|
+
return path, _load(path)
|
|
42
|
+
|
|
43
|
+
folder = Path(repo).expanduser()
|
|
44
|
+
plain = folder / CONFIG_NAME
|
|
45
|
+
if plain.is_file():
|
|
46
|
+
return plain, _load(plain)
|
|
47
|
+
|
|
48
|
+
pyproject = folder / PYPROJECT_NAME
|
|
49
|
+
if pyproject.is_file():
|
|
50
|
+
values = _load(pyproject)
|
|
51
|
+
if values:
|
|
52
|
+
return pyproject, values
|
|
53
|
+
|
|
54
|
+
return None, {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load(path):
|
|
58
|
+
try:
|
|
59
|
+
with open(path, "rb") as handle:
|
|
60
|
+
data = tomllib.load(handle)
|
|
61
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
62
|
+
raise ConfigError(f"Cannot read config file {path}: {exc}") from exc
|
|
63
|
+
|
|
64
|
+
if path.name == PYPROJECT_NAME:
|
|
65
|
+
data = data.get("tool", {}).get("commitrocket", {})
|
|
66
|
+
return data
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def apply_config(parser, values, source, trusted=False):
|
|
70
|
+
"""Use config values as the parser's defaults, so the command line wins.
|
|
71
|
+
|
|
72
|
+
A config file found in the repository is untrusted: anyone who can change the
|
|
73
|
+
repository can change it. Unless trusted (the user named it with --config),
|
|
74
|
+
settings that can skip the confirmation, push elsewhere, write files or contact
|
|
75
|
+
other servers are ignored. Returns the names of the ignored settings.
|
|
76
|
+
"""
|
|
77
|
+
actions = {action.dest: action for action in parser._actions}
|
|
78
|
+
defaults = {}
|
|
79
|
+
ignored = []
|
|
80
|
+
|
|
81
|
+
for key, value in values.items():
|
|
82
|
+
dest = key.replace("-", "_")
|
|
83
|
+
action = actions.get(dest)
|
|
84
|
+
if action is None or dest in NOT_CONFIGURABLE:
|
|
85
|
+
raise ConfigError(f"{source}: unknown option '{key}'")
|
|
86
|
+
coerced = _coerce(action, key, value, source)
|
|
87
|
+
if dest in SENSITIVE_KEYS and not trusted:
|
|
88
|
+
ignored.append(key)
|
|
89
|
+
else:
|
|
90
|
+
defaults[dest] = coerced
|
|
91
|
+
|
|
92
|
+
parser.set_defaults(**defaults)
|
|
93
|
+
return ignored
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _coerce(action, key, value, source):
|
|
97
|
+
where = f"{source}: '{key}'"
|
|
98
|
+
|
|
99
|
+
if action.nargs == 0: # a flag such as --force
|
|
100
|
+
if not isinstance(value, bool):
|
|
101
|
+
raise ConfigError(f"{where} must be true or false")
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
if _is_append(action):
|
|
105
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
106
|
+
raise ConfigError(f"{where} must be a list of strings")
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
if isinstance(value, (bool, list, dict)):
|
|
110
|
+
raise ConfigError(f"{where} has the wrong type")
|
|
111
|
+
|
|
112
|
+
if action.type is not None:
|
|
113
|
+
try:
|
|
114
|
+
value = action.type(value)
|
|
115
|
+
except (TypeError, ValueError) as exc:
|
|
116
|
+
raise ConfigError(f"{where} is invalid: {exc}") from exc
|
|
117
|
+
|
|
118
|
+
if action.choices and value not in action.choices:
|
|
119
|
+
choices = ", ".join(map(str, action.choices))
|
|
120
|
+
raise ConfigError(f"{where} must be one of: {choices}")
|
|
121
|
+
return value
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_append(action):
|
|
125
|
+
return type(action).__name__ == "_AppendAction"
|
commitrocket/doctor.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from commitrocket.batching import MIB
|
|
10
|
+
from commitrocket.config import ConfigError, find_config
|
|
11
|
+
from commitrocket.errors import CommitRocketError, GitError
|
|
12
|
+
from commitrocket.filters import IGNORE_FILE
|
|
13
|
+
from commitrocket.git import (
|
|
14
|
+
find_large_unpushed_blobs,
|
|
15
|
+
get_file_size,
|
|
16
|
+
get_pending_files,
|
|
17
|
+
get_staged_files,
|
|
18
|
+
get_unpushed_commits,
|
|
19
|
+
resolve_target,
|
|
20
|
+
run_git,
|
|
21
|
+
)
|
|
22
|
+
from commitrocket.journal import Journal
|
|
23
|
+
from commitrocket.lfs import HOST_LIMIT_MIB, is_lfs_available
|
|
24
|
+
from commitrocket.logs import default_log_dir
|
|
25
|
+
from commitrocket.security import redact
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
PASS, WARN, FAIL, SKIP = "pass", "warn", "fail", "skip"
|
|
30
|
+
STYLES = {PASS: "green", WARN: "yellow", FAIL: "bold red", SKIP: "dim"}
|
|
31
|
+
NETWORK_TIMEOUT = 60
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _first_line(text):
|
|
35
|
+
lines = [line for line in text.strip().splitlines() if line.strip()]
|
|
36
|
+
return redact(lines[0]) if lines else "no details"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _remote_check(repo, args, label):
|
|
40
|
+
try:
|
|
41
|
+
result = subprocess.run(
|
|
42
|
+
["git", "-C", str(repo), *args],
|
|
43
|
+
text=True,
|
|
44
|
+
capture_output=True,
|
|
45
|
+
timeout=NETWORK_TIMEOUT,
|
|
46
|
+
)
|
|
47
|
+
except subprocess.TimeoutExpired:
|
|
48
|
+
return FAIL, f"{label} timed out after {NETWORK_TIMEOUT} s"
|
|
49
|
+
if result.returncode != 0:
|
|
50
|
+
return FAIL, _first_line(result.stderr or result.stdout)
|
|
51
|
+
return PASS, label
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _check_log_dir():
|
|
55
|
+
folder = default_log_dir()
|
|
56
|
+
try:
|
|
57
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
probe = folder / ".doctor-probe"
|
|
59
|
+
probe.write_text("ok", encoding="utf-8")
|
|
60
|
+
probe.unlink()
|
|
61
|
+
except OSError as exc:
|
|
62
|
+
return WARN, f"{folder} is not writable ({exc})"
|
|
63
|
+
return PASS, str(folder)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def collect_checks(repo, remote=None, branch=None, offline=False, config_path=None):
|
|
67
|
+
"""Run every check and return [(status, name, detail), ...]."""
|
|
68
|
+
results = []
|
|
69
|
+
|
|
70
|
+
def add(status, name, detail):
|
|
71
|
+
results.append((status, name, detail))
|
|
72
|
+
logger.info("doctor: %s %s - %s", status, name, detail)
|
|
73
|
+
|
|
74
|
+
add(PASS, "Python", sys.version.split()[0])
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
git_version = subprocess.run(
|
|
78
|
+
["git", "--version"], text=True, stdout=subprocess.PIPE
|
|
79
|
+
).stdout.strip()
|
|
80
|
+
except OSError:
|
|
81
|
+
add(FAIL, "Git", "git was not found on PATH")
|
|
82
|
+
return results
|
|
83
|
+
add(PASS, "Git", git_version)
|
|
84
|
+
|
|
85
|
+
if is_lfs_available():
|
|
86
|
+
add(PASS, "git-lfs", "installed")
|
|
87
|
+
else:
|
|
88
|
+
add(WARN, "git-lfs", "not installed (needed only for --use-lfs)")
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
path, values = find_config(repo, config_path)
|
|
92
|
+
detail = f"{path} ({len(values)} setting(s))" if path else "none (defaults)"
|
|
93
|
+
add(PASS, "Config file", detail)
|
|
94
|
+
except ConfigError as exc:
|
|
95
|
+
add(FAIL, "Config file", str(exc))
|
|
96
|
+
|
|
97
|
+
status, detail = _check_log_dir()
|
|
98
|
+
add(status, "Log folder", detail)
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
root = Path(run_git(repo, "rev-parse", "--show-toplevel").stdout.strip())
|
|
102
|
+
except (GitError, OSError):
|
|
103
|
+
add(FAIL, "Repository", f"{repo} is not inside a Git repository")
|
|
104
|
+
return results
|
|
105
|
+
add(PASS, "Repository", str(root))
|
|
106
|
+
|
|
107
|
+
local_branch = run_git(root, "branch", "--show-current").stdout.strip()
|
|
108
|
+
if not local_branch:
|
|
109
|
+
add(FAIL, "Branch", "detached HEAD; check out a branch")
|
|
110
|
+
return results
|
|
111
|
+
add(PASS, "Branch", local_branch)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
target = resolve_target(root, local_branch, remote, branch)
|
|
115
|
+
except CommitRocketError as exc:
|
|
116
|
+
add(FAIL, "Push target", str(exc))
|
|
117
|
+
return results
|
|
118
|
+
add(PASS, "Push target", target.label)
|
|
119
|
+
|
|
120
|
+
if offline:
|
|
121
|
+
add(SKIP, "Remote access", "skipped (--offline)")
|
|
122
|
+
add(SKIP, "Push permission", "skipped (--offline)")
|
|
123
|
+
else:
|
|
124
|
+
status, detail = _remote_check(
|
|
125
|
+
root, ["ls-remote", "--", target.remote, "HEAD"], "remote reachable"
|
|
126
|
+
)
|
|
127
|
+
add(status, "Remote access", detail)
|
|
128
|
+
status, detail = _remote_check(
|
|
129
|
+
root,
|
|
130
|
+
["push", "--dry-run", "--", target.remote, target.refspec],
|
|
131
|
+
"push accepted (dry run)",
|
|
132
|
+
)
|
|
133
|
+
add(WARN if status == FAIL else status, "Push permission", detail)
|
|
134
|
+
|
|
135
|
+
staged = get_staged_files(root)
|
|
136
|
+
entry = Journal(root).read()
|
|
137
|
+
if staged and not (entry and set(staged) <= set(entry["files"])):
|
|
138
|
+
add(WARN, "Staged files", f"{len(staged)} file(s) staged; the run would stop")
|
|
139
|
+
elif staged:
|
|
140
|
+
add(PASS, "Staged files", f"{len(staged)} file(s) left by an interrupted run")
|
|
141
|
+
else:
|
|
142
|
+
add(PASS, "Staged files", "none")
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
unpushed = get_unpushed_commits(root, target.revs)
|
|
146
|
+
large = find_large_unpushed_blobs(root, HOST_LIMIT_MIB * MIB, target.revs)
|
|
147
|
+
except GitError:
|
|
148
|
+
add(WARN, "Unpushed commits", "could not be inspected (no commits yet?)")
|
|
149
|
+
else:
|
|
150
|
+
if large:
|
|
151
|
+
add(
|
|
152
|
+
WARN,
|
|
153
|
+
"Unpushed commits",
|
|
154
|
+
f"{len(unpushed)} commit(s) contain files over "
|
|
155
|
+
f"{HOST_LIMIT_MIB} MiB; run 'git reset --mixed {target.label}'",
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
add(PASS, "Unpushed commits", f"{len(unpushed)} (will be pushed first)")
|
|
159
|
+
|
|
160
|
+
ignore = root / IGNORE_FILE
|
|
161
|
+
add(PASS, IGNORE_FILE, "present" if ignore.is_file() else "not used")
|
|
162
|
+
|
|
163
|
+
pending = get_pending_files(root)
|
|
164
|
+
big = [p for p in pending if get_file_size(root, p) > HOST_LIMIT_MIB * MIB]
|
|
165
|
+
if big:
|
|
166
|
+
add(
|
|
167
|
+
WARN,
|
|
168
|
+
"Pending files",
|
|
169
|
+
f"{len(pending)} file(s), {len(big)} over {HOST_LIMIT_MIB} MiB "
|
|
170
|
+
"(use --use-lfs)",
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
add(PASS, "Pending files", f"{len(pending)} file(s)")
|
|
174
|
+
|
|
175
|
+
return results
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def run_doctor(
|
|
179
|
+
console, repo, remote=None, branch=None, offline=False, config_path=None
|
|
180
|
+
):
|
|
181
|
+
"""Print a health report. Returns 1 if any check failed, else 0."""
|
|
182
|
+
results = collect_checks(repo, remote, branch, offline, config_path)
|
|
183
|
+
|
|
184
|
+
table = Table(title="CommitRocket doctor", show_lines=False)
|
|
185
|
+
table.add_column("Status")
|
|
186
|
+
table.add_column("Check")
|
|
187
|
+
table.add_column("Details", overflow="fold")
|
|
188
|
+
for status, name, detail in results:
|
|
189
|
+
label = f"[{STYLES[status]}]{status.upper()}[/]"
|
|
190
|
+
table.add_row(label, Text(name), Text(detail))
|
|
191
|
+
console.print(table)
|
|
192
|
+
|
|
193
|
+
failed = [name for status, name, _ in results if status == FAIL]
|
|
194
|
+
warned = [name for status, name, _ in results if status == WARN]
|
|
195
|
+
if failed:
|
|
196
|
+
console.print(f"\n[bold red]{len(failed)} check(s) failed.[/bold red]")
|
|
197
|
+
return 1
|
|
198
|
+
if warned:
|
|
199
|
+
console.print(f"\n[yellow]OK with {len(warned)} warning(s).[/yellow]")
|
|
200
|
+
else:
|
|
201
|
+
console.print("\n[bold green]All checks passed.[/bold green]")
|
|
202
|
+
return 0
|
commitrocket/errors.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class CommitRocketError(RuntimeError):
|
|
2
|
+
"""Base class for expected, user-reportable failures."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class GitError(CommitRocketError):
|
|
6
|
+
"""A git command failed."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PushError(GitError):
|
|
10
|
+
"""A git push failed. retryable is False when trying again cannot help."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message, retryable=True):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.retryable = retryable
|
commitrocket/eta.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
# Wait for this much real upload time before trusting the measured speed.
|
|
4
|
+
MIN_SECONDS = 5.0
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def format_duration(seconds):
|
|
8
|
+
seconds = max(0, int(seconds))
|
|
9
|
+
hours, remainder = divmod(seconds, 3600)
|
|
10
|
+
minutes, secs = divmod(remainder, 60)
|
|
11
|
+
return f"{hours}:{minutes:02d}:{secs:02d}"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EtaEstimator:
|
|
15
|
+
"""ETA from the average speed of the whole run so far.
|
|
16
|
+
|
|
17
|
+
Speed is bytes completed (including the part of the current batch already
|
|
18
|
+
uploaded) divided by the time since the first batch started, so per-batch
|
|
19
|
+
overhead such as `git add`, commit and push handshakes is included.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, clock=time.monotonic):
|
|
23
|
+
self.clock = clock
|
|
24
|
+
self.started_at = None
|
|
25
|
+
|
|
26
|
+
def start(self):
|
|
27
|
+
if self.started_at is None:
|
|
28
|
+
self.started_at = self.clock()
|
|
29
|
+
|
|
30
|
+
def estimate(self, done_bytes, total_bytes):
|
|
31
|
+
"""Seconds remaining, or None while there is not enough data yet."""
|
|
32
|
+
if self.started_at is None or done_bytes <= 0:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
elapsed = self.clock() - self.started_at
|
|
36
|
+
if elapsed < MIN_SECONDS:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
remaining = max(0, total_bytes - done_bytes)
|
|
40
|
+
return remaining / (done_bytes / elapsed)
|
commitrocket/filters.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pathspec
|
|
4
|
+
|
|
5
|
+
from commitrocket.errors import CommitRocketError
|
|
6
|
+
|
|
7
|
+
IGNORE_FILE = ".commitrocketignore"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def read_patterns(path):
|
|
11
|
+
"""Pattern lines from a file; blank lines and # comments are skipped."""
|
|
12
|
+
try:
|
|
13
|
+
text = Path(path).expanduser().read_text(encoding="utf-8")
|
|
14
|
+
except OSError as exc:
|
|
15
|
+
raise CommitRocketError(f"Cannot read pattern file {path}: {exc}") from exc
|
|
16
|
+
|
|
17
|
+
return [
|
|
18
|
+
line.strip()
|
|
19
|
+
for line in text.splitlines()
|
|
20
|
+
if line.strip() and not line.lstrip().startswith("#")
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _spec(lines):
|
|
25
|
+
try:
|
|
26
|
+
return pathspec.PathSpec.from_lines("gitignore", lines)
|
|
27
|
+
except KeyError:
|
|
28
|
+
# pathspec before 1.0 only knows the older name for the same syntax.
|
|
29
|
+
return pathspec.PathSpec.from_lines("gitwildmatch", lines)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FileFilter:
|
|
33
|
+
"""Keep only the files that match --include (if any) and not --exclude.
|
|
34
|
+
|
|
35
|
+
Patterns use .gitignore syntax: '*.mp4' matches at any depth, 'raw/' a
|
|
36
|
+
folder, '/notes.txt' only at the repo root, '**' any number of folders.
|
|
37
|
+
Exclusion always wins over inclusion.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, include=(), exclude=()):
|
|
41
|
+
self.include = _spec(include) if include else None
|
|
42
|
+
self.exclude = _spec(exclude) if exclude else None
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_options(cls, repo, include, exclude, include_from, exclude_from):
|
|
46
|
+
include_lines = list(include)
|
|
47
|
+
for path in include_from:
|
|
48
|
+
include_lines += read_patterns(path)
|
|
49
|
+
|
|
50
|
+
exclude_lines = list(exclude)
|
|
51
|
+
for path in exclude_from:
|
|
52
|
+
exclude_lines += read_patterns(path)
|
|
53
|
+
|
|
54
|
+
ignore_file = Path(repo) / IGNORE_FILE
|
|
55
|
+
if ignore_file.is_file():
|
|
56
|
+
exclude_lines += read_patterns(ignore_file)
|
|
57
|
+
|
|
58
|
+
return cls(include_lines, exclude_lines)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def active(self):
|
|
62
|
+
return self.include is not None or self.exclude is not None
|
|
63
|
+
|
|
64
|
+
def keeps(self, path):
|
|
65
|
+
if self.exclude is not None and self.exclude.match_file(path):
|
|
66
|
+
return False
|
|
67
|
+
if self.include is not None and not self.include.match_file(path):
|
|
68
|
+
return False
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
def apply(self, paths):
|
|
72
|
+
return [path for path in paths if self.keeps(path)]
|
commitrocket/git.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from commitrocket.errors import CommitRocketError, GitError
|
|
8
|
+
from commitrocket.security import redact, validate_ref_part
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
MAX_LOGGED_OUTPUT = 2000
|
|
13
|
+
MAX_LOGGED_COMMAND = 500
|
|
14
|
+
MAX_COMMAND_CHARS = 20000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _logged_output(result):
|
|
18
|
+
text = "\n".join(part for part in (result.stdout, result.stderr) if part.strip())
|
|
19
|
+
text = text.strip().replace("\0", "|")
|
|
20
|
+
if len(text) > MAX_LOGGED_OUTPUT:
|
|
21
|
+
text = text[:MAX_LOGGED_OUTPUT] + "... [truncated]"
|
|
22
|
+
return f"\n{text}" if text else ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_git(repo, *args, check=True):
|
|
26
|
+
result = subprocess.run(
|
|
27
|
+
["git", "-C", str(repo), *args],
|
|
28
|
+
text=True,
|
|
29
|
+
capture_output=True,
|
|
30
|
+
# File names are literal: "[ab].txt" must not also match a.txt and b.txt.
|
|
31
|
+
env={**os.environ, "GIT_LITERAL_PATHSPECS": "1"},
|
|
32
|
+
)
|
|
33
|
+
command = _shortened(" ".join(args))
|
|
34
|
+
logger.debug(
|
|
35
|
+
"git %s -> exit %d%s", command, result.returncode, _logged_output(result)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if check and result.returncode != 0:
|
|
39
|
+
error = result.stderr.strip() or result.stdout.strip()
|
|
40
|
+
raise GitError(redact(f"git {command} failed:\n{error}"))
|
|
41
|
+
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _shortened(command):
|
|
46
|
+
"""Keep logs and errors readable when a command lists thousands of files."""
|
|
47
|
+
if len(command) <= MAX_LOGGED_COMMAND:
|
|
48
|
+
return command
|
|
49
|
+
return command[:MAX_LOGGED_COMMAND] + "... [truncated]"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def chunk_paths(paths, limit=MAX_COMMAND_CHARS):
|
|
53
|
+
"""Split paths into groups that fit on one command line.
|
|
54
|
+
|
|
55
|
+
Windows allows about 32,767 characters for a whole command, so a batch with
|
|
56
|
+
thousands of files cannot be passed to git in one go. Each path is counted at
|
|
57
|
+
twice its length, to leave room for the quoting Windows adds.
|
|
58
|
+
"""
|
|
59
|
+
chunk = []
|
|
60
|
+
size = 0
|
|
61
|
+
for path in paths:
|
|
62
|
+
cost = 2 * len(path) + 3
|
|
63
|
+
if chunk and size + cost > limit:
|
|
64
|
+
yield chunk
|
|
65
|
+
chunk = []
|
|
66
|
+
size = 0
|
|
67
|
+
chunk.append(path)
|
|
68
|
+
size += cost
|
|
69
|
+
if chunk:
|
|
70
|
+
yield chunk
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def add_files(repo, paths):
|
|
74
|
+
"""Stage the given files, however many there are."""
|
|
75
|
+
for chunk in chunk_paths(paths):
|
|
76
|
+
run_git(repo, "add", "--", *chunk)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_paths(repo, *args):
|
|
80
|
+
return [path for path in run_git(repo, *args).stdout.split("\0") if path]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_staged_files(repo):
|
|
84
|
+
return get_paths(repo, "diff", "--cached", "--name-only", "-z")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_pending_files(repo):
|
|
88
|
+
unstaged = get_paths(repo, "diff", "--name-only", "-z")
|
|
89
|
+
untracked = get_paths(repo, "ls-files", "--others", "--exclude-standard", "-z")
|
|
90
|
+
return list(dict.fromkeys(unstaged + untracked))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class LocalConfig:
|
|
94
|
+
"""Set repository-local Git config for a run and put the old values back.
|
|
95
|
+
|
|
96
|
+
Only the repo's own .git/config is touched, never the global config.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, repo):
|
|
100
|
+
self.repo = repo
|
|
101
|
+
self.saved = {}
|
|
102
|
+
|
|
103
|
+
def set(self, key, value):
|
|
104
|
+
if key not in self.saved:
|
|
105
|
+
current = run_git(self.repo, "config", "--local", "--get", key, check=False)
|
|
106
|
+
found = current.returncode == 0
|
|
107
|
+
self.saved[key] = current.stdout.strip() if found else None
|
|
108
|
+
run_git(self.repo, "config", "--local", key, str(value))
|
|
109
|
+
|
|
110
|
+
def restore(self):
|
|
111
|
+
for key, old in self.saved.items():
|
|
112
|
+
if old is None:
|
|
113
|
+
run_git(self.repo, "config", "--local", "--unset", key, check=False)
|
|
114
|
+
else:
|
|
115
|
+
run_git(self.repo, "config", "--local", key, old, check=False)
|
|
116
|
+
self.saved.clear()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class PushTarget:
|
|
121
|
+
"""Where a run pushes to, and how to tell what is not there yet."""
|
|
122
|
+
|
|
123
|
+
remote: str
|
|
124
|
+
branch: str
|
|
125
|
+
revs: list
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def ref(self):
|
|
129
|
+
return f"refs/heads/{self.branch}"
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def refspec(self):
|
|
133
|
+
return f"HEAD:{self.ref}"
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def label(self):
|
|
137
|
+
"""Display name for the target; safe to show, log, report and send."""
|
|
138
|
+
return redact(f"{self.remote}/{self.branch}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def resolve_target(repo, local_branch, remote=None, branch=None):
|
|
142
|
+
"""The remote branch to push to: explicit values first, then the upstream."""
|
|
143
|
+
configured_remote = run_git(
|
|
144
|
+
repo, "config", "--get", f"branch.{local_branch}.remote", check=False
|
|
145
|
+
).stdout.strip()
|
|
146
|
+
configured_merge = run_git(
|
|
147
|
+
repo, "config", "--get", f"branch.{local_branch}.merge", check=False
|
|
148
|
+
).stdout.strip()
|
|
149
|
+
|
|
150
|
+
remote = remote or configured_remote
|
|
151
|
+
if not remote:
|
|
152
|
+
raise CommitRocketError(
|
|
153
|
+
"No upstream configured. Configure one, or pass --remote "
|
|
154
|
+
"(and optionally --branch)."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
branch = branch or configured_merge.removeprefix("refs/heads/") or local_branch
|
|
158
|
+
|
|
159
|
+
# These end up on git command lines, so they must not look like options.
|
|
160
|
+
validate_ref_part(remote, "remote")
|
|
161
|
+
validate_ref_part(branch, "branch")
|
|
162
|
+
|
|
163
|
+
tracking_ref = f"refs/remotes/{remote}/{branch}"
|
|
164
|
+
exists = run_git(
|
|
165
|
+
repo, "rev-parse", "--verify", "--quiet", tracking_ref, check=False
|
|
166
|
+
).returncode
|
|
167
|
+
if exists == 0:
|
|
168
|
+
revs = [f"{tracking_ref}..HEAD"]
|
|
169
|
+
else:
|
|
170
|
+
# The branch was never pushed to this remote: everything the remote
|
|
171
|
+
# does not already have counts as unpushed.
|
|
172
|
+
revs = ["HEAD", "--not", f"--remotes={remote}"]
|
|
173
|
+
|
|
174
|
+
return PushTarget(remote, branch, revs)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_remote_url(repo, remote):
|
|
178
|
+
return run_git(repo, "remote", "get-url", "--", remote).stdout.strip()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def get_remote_head(repo, remote, ref):
|
|
182
|
+
"""Commit the remote currently has for ref, or None if it doesn't exist."""
|
|
183
|
+
fields = run_git(repo, "ls-remote", "--", remote, ref).stdout.split()
|
|
184
|
+
return fields[0] if fields else None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def get_unpushed_commits(repo, revs):
|
|
188
|
+
"""One-line summaries of local commits that are not on the remote yet."""
|
|
189
|
+
return run_git(repo, "log", "--oneline", *revs).stdout.splitlines()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def find_large_unpushed_blobs(repo, threshold_bytes, revs):
|
|
193
|
+
"""(path, size) of blobs above the threshold in commits not yet pushed."""
|
|
194
|
+
objects = run_git(repo, "rev-list", "--objects", *revs).stdout
|
|
195
|
+
if not objects.strip():
|
|
196
|
+
return []
|
|
197
|
+
|
|
198
|
+
result = subprocess.run(
|
|
199
|
+
[
|
|
200
|
+
"git",
|
|
201
|
+
"-C",
|
|
202
|
+
str(repo),
|
|
203
|
+
"cat-file",
|
|
204
|
+
"--batch-check=%(objecttype) %(objectsize) %(rest)",
|
|
205
|
+
],
|
|
206
|
+
input=objects,
|
|
207
|
+
text=True,
|
|
208
|
+
encoding="utf-8",
|
|
209
|
+
errors="replace",
|
|
210
|
+
capture_output=True,
|
|
211
|
+
)
|
|
212
|
+
if result.returncode != 0:
|
|
213
|
+
raise GitError(f"git cat-file failed:\n{result.stderr.strip()}")
|
|
214
|
+
|
|
215
|
+
large = {}
|
|
216
|
+
for line in result.stdout.splitlines():
|
|
217
|
+
kind, size, path = (line.split(" ", 2) + ["", ""])[:3]
|
|
218
|
+
if kind == "blob" and int(size) > threshold_bytes:
|
|
219
|
+
large[path or "(unnamed)"] = int(size)
|
|
220
|
+
return sorted(large.items())
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def get_file_size(repo, relative_path):
|
|
224
|
+
try:
|
|
225
|
+
path = Path(repo) / relative_path
|
|
226
|
+
return path.stat().st_size if path.is_file() else 0
|
|
227
|
+
except OSError:
|
|
228
|
+
return 0
|