gate-cache 0.5.0__tar.gz → 0.6.0__tar.gz
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.
- {gate_cache-0.5.0 → gate_cache-0.6.0}/PKG-INFO +2 -3
- {gate_cache-0.5.0 → gate_cache-0.6.0}/pyproject.toml +4 -7
- {gate_cache-0.5.0 → gate_cache-0.6.0}/pyproject.toml.orig +4 -5
- {gate_cache-0.5.0 → gate_cache-0.6.0}/src/gate_cache/__init__.py +1 -1
- gate_cache-0.6.0/src/gate_cache/cli.py +247 -0
- gate_cache-0.6.0/src/gate_cache/console.py +43 -0
- {gate_cache-0.5.0 → gate_cache-0.6.0}/src/gate_cache/gate.py +7 -9
- gate_cache-0.6.0/src/gate_cache/git.py +98 -0
- {gate_cache-0.5.0 → gate_cache-0.6.0}/src/gate_cache/markers.py +3 -3
- {gate_cache-0.5.0 → gate_cache-0.6.0}/src/gate_cache/snapshot.py +5 -7
- gate_cache-0.5.0/src/gate_cache/cli.py +0 -179
- gate_cache-0.5.0/src/gate_cache/console.py +0 -14
- {gate_cache-0.5.0 → gate_cache-0.6.0}/LICENSE +0 -0
- {gate_cache-0.5.0 → gate_cache-0.6.0}/README.md +0 -0
- {gate_cache-0.5.0 → gate_cache-0.6.0}/src/gate_cache/runner.py +0 -0
- {gate_cache-0.5.0 → gate_cache-0.6.0}/src/gate_cache/scratch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: gate-cache
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Skip lint and test gates that already passed on the exact same git tree.
|
|
5
5
|
Author: Nate Landau
|
|
6
6
|
Author-email: Nate Landau <github@natenate.org>
|
|
@@ -29,8 +29,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
29
29
|
Classifier: Programming Language :: Python :: 3.12
|
|
30
30
|
Classifier: Programming Language :: Python :: 3.13
|
|
31
31
|
Classifier: Programming Language :: Python :: 3.14
|
|
32
|
-
|
|
33
|
-
Requires-Dist: nclutils>=3.4.4,<4.0.0
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
34
33
|
Requires-Dist: platformdirs>=4.13.0,<5.0.0
|
|
35
34
|
Requires-Python: >=3.11
|
|
36
35
|
Description-Content-Type: text/markdown
|
|
@@ -4,17 +4,14 @@ classifiers = [
|
|
|
4
4
|
"Programming Language :: Python :: 3.12",
|
|
5
5
|
"Programming Language :: Python :: 3.13",
|
|
6
6
|
"Programming Language :: Python :: 3.14",
|
|
7
|
+
"Programming Language :: Python :: 3.15",
|
|
7
8
|
]
|
|
8
|
-
dependencies = [
|
|
9
|
-
"cyclopts>=5.2.0,<6.0.0",
|
|
10
|
-
"nclutils>=3.4.4,<4.0.0",
|
|
11
|
-
"platformdirs>=4.13.0,<5.0.0",
|
|
12
|
-
]
|
|
9
|
+
dependencies = ["platformdirs>=4.13.0,<5.0.0"]
|
|
13
10
|
description = "Skip lint and test gates that already passed on the exact same git tree."
|
|
14
11
|
name = "gate-cache"
|
|
15
12
|
readme = "README.md"
|
|
16
13
|
requires-python = ">=3.11"
|
|
17
|
-
version = "0.
|
|
14
|
+
version = "0.6.0"
|
|
18
15
|
|
|
19
16
|
[[project.authors]]
|
|
20
17
|
name = "Nate Landau"
|
|
@@ -28,7 +25,7 @@ gate-cache = "gate_cache.cli:main"
|
|
|
28
25
|
|
|
29
26
|
[build-system]
|
|
30
27
|
build-backend = "uv_build"
|
|
31
|
-
requires = ["uv_build>=0.
|
|
28
|
+
requires = ["uv_build>=0.13.0,<0.14"]
|
|
32
29
|
|
|
33
30
|
[tool.uv.build-backend]
|
|
34
31
|
module-name = "gate_cache"
|
|
@@ -5,25 +5,24 @@
|
|
|
5
5
|
"Programming Language :: Python :: 3.12",
|
|
6
6
|
"Programming Language :: Python :: 3.13",
|
|
7
7
|
"Programming Language :: Python :: 3.14",
|
|
8
|
+
"Programming Language :: Python :: 3.15",
|
|
8
9
|
]
|
|
9
10
|
dependencies = [
|
|
10
|
-
"cyclopts>=5.2.0,<6.0.0",
|
|
11
|
-
"nclutils>=3.4.4,<4.0.0",
|
|
12
11
|
"platformdirs>=4.13.0,<5.0.0",
|
|
13
|
-
|
|
12
|
+
]
|
|
14
13
|
description = "Skip lint and test gates that already passed on the exact same git tree."
|
|
15
14
|
license = { file = "LICENSE" }
|
|
16
15
|
name = "gate-cache"
|
|
17
16
|
readme = "README.md"
|
|
18
17
|
requires-python = ">=3.11"
|
|
19
|
-
version = "0.
|
|
18
|
+
version = "0.6.0"
|
|
20
19
|
|
|
21
20
|
[project.scripts]
|
|
22
21
|
gate-cache = "gate_cache.cli:main"
|
|
23
22
|
|
|
24
23
|
[build-system]
|
|
25
24
|
build-backend = "uv_build"
|
|
26
|
-
requires = ["uv_build>=0.
|
|
25
|
+
requires = ["uv_build>=0.13.0,<0.14"]
|
|
27
26
|
|
|
28
27
|
[tool.uv.build-backend]
|
|
29
28
|
module-name = "gate_cache"
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Command line interface for gate-cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import shlex
|
|
7
|
+
import sys
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from gate_cache import __version__
|
|
11
|
+
from gate_cache.console import say
|
|
12
|
+
from gate_cache.gate import clear_cache, gate_status, run_gate
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
|
|
17
|
+
_USAGE_ERROR = 2
|
|
18
|
+
_INTERRUPTED = 130
|
|
19
|
+
_INFO_FLAGS = {"-h", "--help", "--version"}
|
|
20
|
+
_NAME_NEEDS_VALUE = "--name needs a value, e.g. gate-cache --name pytest -- uv run pytest"
|
|
21
|
+
_NEEDS_SEPARATOR = "put -- before the command, e.g. gate-cache -- uv run pytest"
|
|
22
|
+
_NAME_REPEATED = "--name given more than once"
|
|
23
|
+
_NO_COMMAND = "no command given after --"
|
|
24
|
+
|
|
25
|
+
_USAGE = """\
|
|
26
|
+
gate-cache [--name NAME] -- COMMAND [ARGS...]
|
|
27
|
+
gate-cache status [--name NAME] [--json]
|
|
28
|
+
gate-cache clear"""
|
|
29
|
+
|
|
30
|
+
_HELP = """\
|
|
31
|
+
Skip a lint or test command that already passed on the same git tree. gate-cache
|
|
32
|
+
runs the command, records a pass keyed on the git tree of the working copy
|
|
33
|
+
(tracked and untracked, non-ignored files), and skips the command next time the
|
|
34
|
+
tree is identical. A pass on uncommitted work therefore skips when the same
|
|
35
|
+
content is committed.
|
|
36
|
+
|
|
37
|
+
-- is required, and only --name may come before it. Everything after -- is the
|
|
38
|
+
command, verbatim. Without --name, the gate name is the command itself, so each
|
|
39
|
+
set of arguments has its own records. Give --name to share records between
|
|
40
|
+
commands that check the same thing, or to key on inputs that the tree does not
|
|
41
|
+
cover.
|
|
42
|
+
|
|
43
|
+
Exit codes:
|
|
44
|
+
run the command's own exit code, or 0 when a recorded pass lets it skip.
|
|
45
|
+
Usage errors exit 2.
|
|
46
|
+
status 0 when the named gate, or any gate without --name, passed on the
|
|
47
|
+
current tree. 1 otherwise. --json prints one JSON object instead.
|
|
48
|
+
clear 0 on success, 1 on failure.
|
|
49
|
+
|
|
50
|
+
GATE_CACHE_DISABLE and CI are on when the value is not empty, 0, or false (any
|
|
51
|
+
case). If GATE_CACHE_DISABLE is on, the command always runs without the cache.
|
|
52
|
+
If it is 0 or false, caching stays on. If it is empty or not set, CI decides the
|
|
53
|
+
same way.
|
|
54
|
+
Set GATE_CACHE_MAX_AGE_DAYS to a positive whole number to change how many days a
|
|
55
|
+
recorded pass lasts (default 30).
|
|
56
|
+
|
|
57
|
+
Only the name and the file content are keyed. The working directory is not, so
|
|
58
|
+
a command whose meaning depends on it needs a distinct --name. To also key on an
|
|
59
|
+
ignored file, an environment variable, or a tool version, put its value in
|
|
60
|
+
--name. A changed value gives a new name, so the command runs again. Never put a
|
|
61
|
+
secret in a name, because markers and status store it as plain text. Without
|
|
62
|
+
--name the command line is the name, so give --name to a command that takes a
|
|
63
|
+
secret argument. Never wrap checks that depend on the commit message or the
|
|
64
|
+
branch name.
|
|
65
|
+
|
|
66
|
+
Typical use:
|
|
67
|
+
gate-cache -- uv run pytest -x
|
|
68
|
+
gate-cache --name pytest -- uv run pytest -x
|
|
69
|
+
gate-cache --name "test-$(git hash-object .env)" -- uv run pytest
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
_RUN_HELP = """\
|
|
73
|
+
subcommands:
|
|
74
|
+
status Report whether gates would skip, without running anything.
|
|
75
|
+
clear Delete every recorded pass for this repository.
|
|
76
|
+
|
|
77
|
+
run:
|
|
78
|
+
gate-cache [--name NAME] -- COMMAND [ARGS...] runs COMMAND unless it already
|
|
79
|
+
passed on the current git tree. On a skip, it prints one line to stderr and
|
|
80
|
+
exits 0 without running anything. Otherwise it runs the command and exits with
|
|
81
|
+
its exit code. The command runs without caching (and nothing is recorded) when
|
|
82
|
+
GATE_CACHE_DISABLE or CI turns caching off, the tree is unknown (for example an
|
|
83
|
+
unmerged index), or this is not a git repository. A pass is recorded only when
|
|
84
|
+
the command exits 0 and the run leaves the tree unchanged.
|
|
85
|
+
|
|
86
|
+
Run gate-cache SUBCOMMAND --help for the options of a subcommand.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
_STATUS_HELP = """\
|
|
90
|
+
Report whether gates would skip, without running anything.
|
|
91
|
+
|
|
92
|
+
With --name, print the tree hash (or why it is unknown), the marker path,
|
|
93
|
+
hit or miss, and how the last run on this tree failed when it did. Exit 0 on a
|
|
94
|
+
hit, 1 otherwise.
|
|
95
|
+
|
|
96
|
+
Without --name, list the gates that passed on the current tree and the gates
|
|
97
|
+
that ran on it without passing. Exit 0 when at least one gate passed, 1 otherwise.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
_CLEAR_HELP = """\
|
|
101
|
+
Delete every recorded pass for this repository.
|
|
102
|
+
|
|
103
|
+
Use after something outside git changes, such as a tool upgrade. Exits 1 outside a
|
|
104
|
+
git repository.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _UsageError(Exception):
|
|
109
|
+
"""A malformed run invocation, reported as one line on stderr with exit code 2."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _StoreOnce(argparse.Action):
|
|
113
|
+
"""Store an option's value, rejecting a second occurrence instead of overwriting the first."""
|
|
114
|
+
|
|
115
|
+
def __call__(
|
|
116
|
+
self,
|
|
117
|
+
parser: argparse.ArgumentParser,
|
|
118
|
+
namespace: argparse.Namespace,
|
|
119
|
+
values: str | Sequence[object] | None,
|
|
120
|
+
option_string: str | None = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
if getattr(namespace, self.dest) is not None:
|
|
123
|
+
parser.error(f"{option_string} given more than once")
|
|
124
|
+
setattr(namespace, self.dest, values)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _main_parser() -> argparse.ArgumentParser:
|
|
128
|
+
"""Build the top-level parser, which serves only --help and --version.
|
|
129
|
+
|
|
130
|
+
The run form is not parsed here because everything after -- must reach the wrapped
|
|
131
|
+
command verbatim. The parser still declares --name so that --help documents it.
|
|
132
|
+
"""
|
|
133
|
+
parser = argparse.ArgumentParser(
|
|
134
|
+
prog="gate-cache",
|
|
135
|
+
usage=_USAGE,
|
|
136
|
+
description=_HELP,
|
|
137
|
+
epilog=_RUN_HELP,
|
|
138
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
139
|
+
allow_abbrev=False,
|
|
140
|
+
)
|
|
141
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
142
|
+
parser.add_argument(
|
|
143
|
+
"--name",
|
|
144
|
+
metavar="NAME",
|
|
145
|
+
help="Gate name. Records match only when the name and the tree both match. "
|
|
146
|
+
"Defaults to the command and its arguments, quoted as a shell would.",
|
|
147
|
+
)
|
|
148
|
+
return parser
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _status_parser() -> argparse.ArgumentParser:
|
|
152
|
+
"""Build the parser for the status subcommand."""
|
|
153
|
+
parser = argparse.ArgumentParser(
|
|
154
|
+
prog="gate-cache status",
|
|
155
|
+
description=_STATUS_HELP,
|
|
156
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
157
|
+
allow_abbrev=False,
|
|
158
|
+
)
|
|
159
|
+
parser.add_argument(
|
|
160
|
+
"--name",
|
|
161
|
+
action=_StoreOnce,
|
|
162
|
+
help="Gate name to look up. Omit it to list every gate that ran on this tree.",
|
|
163
|
+
)
|
|
164
|
+
parser.add_argument(
|
|
165
|
+
"--json",
|
|
166
|
+
dest="as_json",
|
|
167
|
+
action="store_true",
|
|
168
|
+
help="Print one JSON object to stdout instead of text. Exit codes are unchanged.",
|
|
169
|
+
)
|
|
170
|
+
return parser
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _clear_parser() -> argparse.ArgumentParser:
|
|
174
|
+
"""Build the parser for the clear subcommand."""
|
|
175
|
+
return argparse.ArgumentParser(
|
|
176
|
+
prog="gate-cache clear",
|
|
177
|
+
description=_CLEAR_HELP,
|
|
178
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
179
|
+
allow_abbrev=False,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _split_run(tokens: list[str]) -> tuple[str | None, list[str]]:
|
|
184
|
+
"""Split a run invocation into its --name value and the command after the first --.
|
|
185
|
+
|
|
186
|
+
Only `--name X` or `--name=X` may precede `--`, so a stray option such as `--version`
|
|
187
|
+
or the first word of the command can never be mistaken for one of ours.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
_UsageError: When anything else precedes `--`, `--` is missing, or `--name` is
|
|
191
|
+
missing its value or given twice.
|
|
192
|
+
"""
|
|
193
|
+
name: str | None = None
|
|
194
|
+
index = 0
|
|
195
|
+
while index < len(tokens):
|
|
196
|
+
arg = tokens[index]
|
|
197
|
+
if arg == "--":
|
|
198
|
+
return name, tokens[index + 1 :]
|
|
199
|
+
if arg == "--name":
|
|
200
|
+
if index + 1 == len(tokens) or tokens[index + 1] == "--":
|
|
201
|
+
raise _UsageError(_NAME_NEEDS_VALUE)
|
|
202
|
+
value = tokens[index + 1]
|
|
203
|
+
index += 2
|
|
204
|
+
elif arg.startswith("--name="):
|
|
205
|
+
value = arg.removeprefix("--name=")
|
|
206
|
+
index += 1
|
|
207
|
+
else:
|
|
208
|
+
break
|
|
209
|
+
if name is not None:
|
|
210
|
+
raise _UsageError(_NAME_REPEATED)
|
|
211
|
+
name = value
|
|
212
|
+
raise _UsageError(_NEEDS_SEPARATOR)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _dispatch(tokens: list[str]) -> int:
|
|
216
|
+
"""Run the invocation in tokens and return its exit code.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
_UsageError: When a run invocation is malformed.
|
|
220
|
+
"""
|
|
221
|
+
if tokens and tokens[0] in _INFO_FLAGS:
|
|
222
|
+
_main_parser().parse_args(tokens) # prints help or the version, then exits
|
|
223
|
+
if tokens and tokens[0] == "status":
|
|
224
|
+
args = _status_parser().parse_args(tokens[1:])
|
|
225
|
+
return gate_status(args.name, as_json=args.as_json)
|
|
226
|
+
if tokens and tokens[0] == "clear":
|
|
227
|
+
_clear_parser().parse_args(tokens[1:])
|
|
228
|
+
return clear_cache()
|
|
229
|
+
|
|
230
|
+
name, command = _split_run(tokens)
|
|
231
|
+
if not command:
|
|
232
|
+
raise _UsageError(_NO_COMMAND)
|
|
233
|
+
if name == "":
|
|
234
|
+
raise _UsageError(_NAME_NEEDS_VALUE)
|
|
235
|
+
return run_gate(shlex.join(command) if name is None else name, command)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def main() -> None:
|
|
239
|
+
"""Run the gate-cache command line interface and exit with its code."""
|
|
240
|
+
try:
|
|
241
|
+
code = _dispatch(sys.argv[1:])
|
|
242
|
+
except _UsageError as error:
|
|
243
|
+
say(str(error))
|
|
244
|
+
sys.exit(_USAGE_ERROR)
|
|
245
|
+
except KeyboardInterrupt:
|
|
246
|
+
sys.exit(_INTERRUPTED)
|
|
247
|
+
sys.exit(code)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""User-facing gate-cache messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
_DIM = "\033[2m"
|
|
9
|
+
_RESET = "\033[0m"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def say(message: str) -> None:
|
|
13
|
+
"""Print a gate-cache message to stderr, verbatim.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
message: Text to print after the `gate-cache: ` prefix.
|
|
17
|
+
"""
|
|
18
|
+
print(f"gate-cache: {message}", file=sys.stderr) # noqa: T201
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _use_color() -> bool:
|
|
22
|
+
"""Return True when stdout is a terminal that accepts ANSI styling."""
|
|
23
|
+
if os.environ.get("NO_COLOR") or os.environ.get("TERM") == "dumb":
|
|
24
|
+
return False
|
|
25
|
+
return sys.stdout.isatty()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def kv(rows: dict[str, str]) -> None:
|
|
29
|
+
"""Print key/value rows on stdout with the values aligned in one column.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
rows: Labels mapped to their values, printed in insertion order.
|
|
33
|
+
"""
|
|
34
|
+
if not rows:
|
|
35
|
+
return
|
|
36
|
+
cell_width = max(len(key) for key in rows) + len(": ")
|
|
37
|
+
dim, reset = (_DIM, _RESET) if _use_color() else ("", "")
|
|
38
|
+
for key, value in rows.items():
|
|
39
|
+
pad = " " * (cell_width - len(key) - len(": "))
|
|
40
|
+
first, *rest = value.splitlines() or [""]
|
|
41
|
+
print(f" {dim}{key}{reset}: {pad}{first}") # noqa: T201
|
|
42
|
+
for line in rest:
|
|
43
|
+
print(f"{' ' * (2 + cell_width)}{line}") # noqa: T201
|
|
@@ -7,9 +7,7 @@ import json
|
|
|
7
7
|
import os
|
|
8
8
|
from typing import TYPE_CHECKING, Any
|
|
9
9
|
|
|
10
|
-
from
|
|
11
|
-
|
|
12
|
-
from gate_cache.console import say
|
|
10
|
+
from gate_cache.console import kv, say
|
|
13
11
|
from gate_cache.markers import (
|
|
14
12
|
clear_markers,
|
|
15
13
|
marker_exists,
|
|
@@ -285,7 +283,7 @@ def _named_status(name: str, snapshot: Snapshot, common_dir: Path | None, *, as_
|
|
|
285
283
|
}
|
|
286
284
|
if last_run is not None:
|
|
287
285
|
rows["last run"] = str(last_run["reason"])
|
|
288
|
-
|
|
286
|
+
kv(rows)
|
|
289
287
|
return 0 if hit else 1
|
|
290
288
|
|
|
291
289
|
|
|
@@ -312,14 +310,14 @@ def _list_gates(snapshot: Snapshot, common_dir: Path | None, *, as_json: bool) -
|
|
|
312
310
|
return code
|
|
313
311
|
|
|
314
312
|
if tree is None:
|
|
315
|
-
|
|
313
|
+
kv({"tree": f"unknown: {snapshot.reason}"})
|
|
316
314
|
return 1
|
|
317
315
|
if common_dir is None:
|
|
318
|
-
|
|
316
|
+
kv({"tree": tree, "passed": f"unknown: {_NO_COMMON_DIR_REASON}"})
|
|
319
317
|
return 1
|
|
320
318
|
if not passed and not missed:
|
|
321
|
-
|
|
322
|
-
|
|
319
|
+
kv({"tree": tree})
|
|
320
|
+
print("No gates match the current tree") # noqa: T201
|
|
323
321
|
return 1
|
|
324
322
|
rows = {"tree": tree}
|
|
325
323
|
if passed:
|
|
@@ -328,7 +326,7 @@ def _list_gates(snapshot: Snapshot, common_dir: Path | None, *, as_json: bool) -
|
|
|
328
326
|
rows["missed"] = ", ".join(
|
|
329
327
|
f"{m['name']} ({m['reason']})" if m["reason"] else m["name"] for m in missed
|
|
330
328
|
)
|
|
331
|
-
|
|
329
|
+
kv(rows)
|
|
332
330
|
return code
|
|
333
331
|
|
|
334
332
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Run git subcommands and capture their output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GitError(Exception):
|
|
15
|
+
"""Base class for every error raised while running git."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GitNotFoundError(GitError):
|
|
19
|
+
"""Raised when the git executable is not on PATH."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GitFailedError(GitError):
|
|
23
|
+
"""Raised when git cannot start in the requested directory or exits with an error."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class GitResult:
|
|
28
|
+
"""The exit code and decoded output of a finished git command.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
returncode: The exit code of the git process.
|
|
32
|
+
stdout: Standard output with trailing newlines removed.
|
|
33
|
+
stderr: Standard error with trailing newlines removed.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
returncode: int
|
|
37
|
+
stdout: str
|
|
38
|
+
stderr: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_git(
|
|
42
|
+
*args: str,
|
|
43
|
+
cwd: Path | str | None = None,
|
|
44
|
+
env: Mapping[str, str] | None = None,
|
|
45
|
+
check: bool = True,
|
|
46
|
+
) -> GitResult:
|
|
47
|
+
"""Run `git` with the given arguments and return its decoded output.
|
|
48
|
+
|
|
49
|
+
Output is decoded as strict UTF-8 from raw bytes. Text mode would use the locale
|
|
50
|
+
encoding and translate carriage returns in NUL-delimited path listings.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
*args: The git subcommand and its arguments.
|
|
54
|
+
cwd: Directory to run git in. None inherits the process working directory.
|
|
55
|
+
env: Environment that replaces the inherited one, or None to inherit it.
|
|
56
|
+
check: Raise GitFailedError when git exits with a non-zero code.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
The exit code and output of the command.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
GitNotFoundError: git is not on PATH.
|
|
63
|
+
GitFailedError: cwd is not a directory, git cannot start, or git fails under check.
|
|
64
|
+
UnicodeDecodeError: git printed output that is not valid UTF-8.
|
|
65
|
+
"""
|
|
66
|
+
argv = ["git", *args]
|
|
67
|
+
resolved: Path | None = None
|
|
68
|
+
if cwd is not None:
|
|
69
|
+
resolved = Path(cwd).expanduser().resolve()
|
|
70
|
+
# Popen reports a missing cwd as FileNotFoundError, the same as a missing git.
|
|
71
|
+
if not resolved.is_dir():
|
|
72
|
+
msg = f"Cannot enter directory {resolved}: not a directory"
|
|
73
|
+
raise GitFailedError(msg)
|
|
74
|
+
try:
|
|
75
|
+
proc = subprocess.run( # noqa: S603
|
|
76
|
+
argv,
|
|
77
|
+
cwd=resolved,
|
|
78
|
+
env=dict(env) if env is not None else None,
|
|
79
|
+
stdin=subprocess.DEVNULL,
|
|
80
|
+
capture_output=True,
|
|
81
|
+
check=False,
|
|
82
|
+
)
|
|
83
|
+
except FileNotFoundError as e:
|
|
84
|
+
msg = "git not found on PATH"
|
|
85
|
+
raise GitNotFoundError(msg) from e
|
|
86
|
+
except OSError as e:
|
|
87
|
+
msg = f"Failed to start git: {e}"
|
|
88
|
+
raise GitFailedError(msg) from e
|
|
89
|
+
|
|
90
|
+
result = GitResult(
|
|
91
|
+
returncode=proc.returncode,
|
|
92
|
+
stdout=proc.stdout.decode("utf-8").rstrip("\n"),
|
|
93
|
+
stderr=proc.stderr.decode("utf-8").rstrip("\n"),
|
|
94
|
+
)
|
|
95
|
+
if check and result.returncode != 0:
|
|
96
|
+
msg = f"git {' '.join(args)} failed with exit code {result.returncode}: {result.stderr}"
|
|
97
|
+
raise GitFailedError(msg)
|
|
98
|
+
return result
|
|
@@ -8,11 +8,10 @@ import json
|
|
|
8
8
|
import shutil
|
|
9
9
|
import tempfile
|
|
10
10
|
import time
|
|
11
|
+
from datetime import UTC, datetime
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import TYPE_CHECKING, Any
|
|
13
14
|
|
|
14
|
-
from nclutils.utils import iso_timestamp
|
|
15
|
-
|
|
16
15
|
if TYPE_CHECKING:
|
|
17
16
|
from collections.abc import Sequence
|
|
18
17
|
|
|
@@ -125,7 +124,8 @@ def write_miss(
|
|
|
125
124
|
def _write_json(path: Path, payload: dict[str, object]) -> None:
|
|
126
125
|
"""Write a timestamped JSON record via a temp file and rename, so readers never see half."""
|
|
127
126
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
-
|
|
127
|
+
timestamp = datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
128
|
+
payload = {**payload, "timestamp": timestamp}
|
|
129
129
|
tmp_name: str | None = None
|
|
130
130
|
try:
|
|
131
131
|
with tempfile.NamedTemporaryFile(
|
|
@@ -7,10 +7,8 @@ import shutil
|
|
|
7
7
|
from dataclasses import dataclass
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
from nclutils.git import run_git
|
|
11
|
-
from nclutils.sh import ShellCommandError, ShellCommandNotFoundError
|
|
12
|
-
|
|
13
10
|
from gate_cache import scratch
|
|
11
|
+
from gate_cache.git import GitError, GitNotFoundError, run_git
|
|
14
12
|
|
|
15
13
|
|
|
16
14
|
@dataclass(frozen=True)
|
|
@@ -327,16 +325,16 @@ def take_snapshot(cwd: Path | None = None) -> Snapshot:
|
|
|
327
325
|
try:
|
|
328
326
|
env = _git_env(cwd)
|
|
329
327
|
state = _inspect(cwd, env)
|
|
330
|
-
except
|
|
328
|
+
except GitNotFoundError:
|
|
331
329
|
return Snapshot(tree=None, reason="git not found")
|
|
332
|
-
except (
|
|
330
|
+
except (GitError, UnicodeDecodeError):
|
|
333
331
|
return Snapshot(tree=None, reason="could not inspect git state")
|
|
334
332
|
if state.reason or state.root is None:
|
|
335
333
|
return Snapshot(tree=None, reason=state.reason or "could not locate work tree")
|
|
336
334
|
|
|
337
335
|
try:
|
|
338
336
|
return _private_snapshot(cwd, state.root, env)
|
|
339
|
-
except (
|
|
337
|
+
except (GitError, OSError, UnicodeDecodeError):
|
|
340
338
|
return Snapshot(tree=None, reason="could not hash working copy")
|
|
341
339
|
|
|
342
340
|
|
|
@@ -351,7 +349,7 @@ def git_common_dir(cwd: Path | None = None) -> Path | None:
|
|
|
351
349
|
"""
|
|
352
350
|
try:
|
|
353
351
|
result = run_git("rev-parse", "--git-common-dir", cwd=cwd)
|
|
354
|
-
except (
|
|
352
|
+
except (GitError, UnicodeDecodeError):
|
|
355
353
|
return None
|
|
356
354
|
output = result.stdout.strip()
|
|
357
355
|
if not output or "\n" in output:
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
"""Command line interface for gate-cache."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import shlex
|
|
6
|
-
import sys
|
|
7
|
-
from typing import Annotated
|
|
8
|
-
|
|
9
|
-
from cyclopts import App, Parameter
|
|
10
|
-
|
|
11
|
-
from gate_cache import __version__
|
|
12
|
-
from gate_cache.console import say
|
|
13
|
-
from gate_cache.gate import clear_cache, gate_status, run_gate
|
|
14
|
-
|
|
15
|
-
_USAGE_ERROR = 2
|
|
16
|
-
_INTERRUPTED = 130
|
|
17
|
-
_SUBCOMMANDS = {"status", "clear"}
|
|
18
|
-
_INFO_FLAGS = {"-h", "--help", "--version"}
|
|
19
|
-
|
|
20
|
-
_HELP = """\
|
|
21
|
-
Skip a lint or test command that already passed on the same git tree. gate-cache
|
|
22
|
-
runs the command, records a pass keyed on the git tree of the working copy
|
|
23
|
-
(tracked and untracked, non-ignored files), and skips the command next time the
|
|
24
|
-
tree is identical. A pass on uncommitted work therefore skips when the same
|
|
25
|
-
content is committed.
|
|
26
|
-
|
|
27
|
-
-- is required, and only --name may come before it. Everything after -- is the
|
|
28
|
-
command, verbatim. Without --name, the gate name is the command itself, so each
|
|
29
|
-
set of arguments has its own records. Give --name to share records between
|
|
30
|
-
commands that check the same thing, or to key on inputs that the tree does not
|
|
31
|
-
cover.
|
|
32
|
-
|
|
33
|
-
Exit codes:
|
|
34
|
-
run the command's own exit code, or 0 when a recorded pass lets it skip.
|
|
35
|
-
Usage errors exit 2.
|
|
36
|
-
status 0 when the named gate, or any gate without --name, passed on the
|
|
37
|
-
current tree. 1 otherwise. --json prints one JSON object instead.
|
|
38
|
-
clear 0 on success, 1 on failure.
|
|
39
|
-
|
|
40
|
-
GATE_CACHE_DISABLE and CI are on when the value is not empty, 0, or false (any
|
|
41
|
-
case). If GATE_CACHE_DISABLE is on, the command always runs without the cache.
|
|
42
|
-
If it is 0 or false, caching stays on. If it is empty or not set, CI decides the
|
|
43
|
-
same way.
|
|
44
|
-
Set GATE_CACHE_MAX_AGE_DAYS to a positive whole number to change how many days a
|
|
45
|
-
recorded pass lasts (default 30).
|
|
46
|
-
|
|
47
|
-
Only the name and the file content are keyed. The working directory is not, so
|
|
48
|
-
a command whose meaning depends on it needs a distinct --name. To also key on an
|
|
49
|
-
ignored file, an environment variable, or a tool version, put its value in
|
|
50
|
-
--name. A changed value gives a new name, so the command runs again. Never put a
|
|
51
|
-
secret in a name, because markers and status store it as plain text. Without
|
|
52
|
-
--name the command line is the name, so give --name to a command that takes a
|
|
53
|
-
secret argument. Never wrap checks that depend on the commit message or the
|
|
54
|
-
branch name.
|
|
55
|
-
|
|
56
|
-
Typical use:
|
|
57
|
-
gate-cache -- uv run pytest -x
|
|
58
|
-
gate-cache --name pytest -- uv run pytest -x
|
|
59
|
-
gate-cache --name "test-$(git hash-object .env)" -- uv run pytest
|
|
60
|
-
"""
|
|
61
|
-
|
|
62
|
-
app = App(
|
|
63
|
-
name="gate-cache",
|
|
64
|
-
version=__version__,
|
|
65
|
-
help=_HELP,
|
|
66
|
-
help_format="plaintext",
|
|
67
|
-
usage=(
|
|
68
|
-
"Usage: gate-cache [--name NAME] -- COMMAND [ARGS...]\n"
|
|
69
|
-
" gate-cache status [--name NAME] [--json]\n"
|
|
70
|
-
" gate-cache clear"
|
|
71
|
-
),
|
|
72
|
-
result_action="return_int_as_exit_code_else_zero",
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
@app.default
|
|
77
|
-
def run(
|
|
78
|
-
command: Annotated[
|
|
79
|
-
list[str], Parameter(allow_leading_hyphen=True, negative_iterable="", show_default=False)
|
|
80
|
-
],
|
|
81
|
-
/,
|
|
82
|
-
*,
|
|
83
|
-
name: str | None = None,
|
|
84
|
-
) -> int:
|
|
85
|
-
"""Run COMMAND unless it already passed on the current git tree.
|
|
86
|
-
|
|
87
|
-
On a skip, prints one line to stderr and exits 0 without running anything. Otherwise
|
|
88
|
-
runs the command and exits with its exit code. The command runs without caching
|
|
89
|
-
(and nothing is recorded) when GATE_CACHE_DISABLE or CI turns caching off, the tree
|
|
90
|
-
is unknown (for example an unmerged index), or this is not a git repository. A pass is recorded only
|
|
91
|
-
when the command exits 0 and the run leaves the tree unchanged.
|
|
92
|
-
|
|
93
|
-
Parameters
|
|
94
|
-
----------
|
|
95
|
-
command
|
|
96
|
-
The command and its arguments, given after `--`. Example: -- uv run pytest -x
|
|
97
|
-
name
|
|
98
|
-
Gate name. Records match only when the name and the tree both match.
|
|
99
|
-
Defaults to the command and its arguments, quoted as a shell would.
|
|
100
|
-
"""
|
|
101
|
-
if not command:
|
|
102
|
-
say("no command given after --")
|
|
103
|
-
return _USAGE_ERROR
|
|
104
|
-
if name == "":
|
|
105
|
-
say("--name needs a value, e.g. gate-cache --name pytest -- uv run pytest")
|
|
106
|
-
return _USAGE_ERROR
|
|
107
|
-
return run_gate(shlex.join(command) if name is None else name, command)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
@app.command
|
|
111
|
-
def status(
|
|
112
|
-
*,
|
|
113
|
-
name: str | None = None,
|
|
114
|
-
as_json: Annotated[bool, Parameter(name="--json", negative="")] = False,
|
|
115
|
-
) -> int:
|
|
116
|
-
"""Report whether gates would skip, without running anything.
|
|
117
|
-
|
|
118
|
-
With --name, print the tree hash (or why it is unknown), the marker path,
|
|
119
|
-
hit or miss, and how the last run on this tree failed when it did. Exit 0 on a
|
|
120
|
-
hit, 1 otherwise.
|
|
121
|
-
|
|
122
|
-
Without --name, list the gates that passed on the current tree and the gates
|
|
123
|
-
that ran on it without passing. Exit 0 when at least one gate passed, 1 otherwise.
|
|
124
|
-
|
|
125
|
-
Parameters
|
|
126
|
-
----------
|
|
127
|
-
name
|
|
128
|
-
Gate name to look up. Omit it to list every gate that ran on this tree.
|
|
129
|
-
as_json
|
|
130
|
-
Print one JSON object to stdout instead of text. Exit codes are unchanged.
|
|
131
|
-
"""
|
|
132
|
-
return gate_status(name, as_json=as_json)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
@app.command
|
|
136
|
-
def clear() -> int:
|
|
137
|
-
"""Delete every recorded pass for this repository.
|
|
138
|
-
|
|
139
|
-
Use after something outside git changes, such as a tool upgrade. Exits 1 outside a
|
|
140
|
-
git repository.
|
|
141
|
-
"""
|
|
142
|
-
return clear_cache()
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def _usage_error(tokens: list[str]) -> str | None:
|
|
146
|
-
"""Return the usage problem with a run invocation, or None when it is well formed.
|
|
147
|
-
|
|
148
|
-
Only `--name X` or `--name=X` may precede `--`, so a stray option such as `--version`
|
|
149
|
-
or the first word of the command can never be mistaken for one of ours.
|
|
150
|
-
"""
|
|
151
|
-
if not tokens or tokens[0] in _SUBCOMMANDS | _INFO_FLAGS:
|
|
152
|
-
return None
|
|
153
|
-
index = 0
|
|
154
|
-
while index < len(tokens):
|
|
155
|
-
arg = tokens[index]
|
|
156
|
-
if arg == "--":
|
|
157
|
-
return None
|
|
158
|
-
if arg == "--name":
|
|
159
|
-
if index + 1 == len(tokens) or tokens[index + 1] == "--":
|
|
160
|
-
return "--name needs a value, e.g. gate-cache --name pytest -- uv run pytest"
|
|
161
|
-
index += 2
|
|
162
|
-
elif arg.startswith("--name="):
|
|
163
|
-
index += 1
|
|
164
|
-
else:
|
|
165
|
-
break
|
|
166
|
-
return "put -- before the command, e.g. gate-cache -- uv run pytest"
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def main() -> None:
|
|
170
|
-
"""Run the gate-cache command line interface and exit with its code."""
|
|
171
|
-
tokens = sys.argv[1:]
|
|
172
|
-
if problem := _usage_error(tokens):
|
|
173
|
-
say(problem)
|
|
174
|
-
sys.exit(_USAGE_ERROR)
|
|
175
|
-
try:
|
|
176
|
-
code = app(tokens)
|
|
177
|
-
except KeyboardInterrupt:
|
|
178
|
-
sys.exit(_INTERRUPTED)
|
|
179
|
-
sys.exit(code)
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
"""User-facing gate-cache messages."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from nclutils import pp
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def say(message: str) -> None:
|
|
9
|
-
"""Print a gate-cache message to stderr, verbatim.
|
|
10
|
-
|
|
11
|
-
Args:
|
|
12
|
-
message: Text to print after the `gate-cache: ` prefix.
|
|
13
|
-
"""
|
|
14
|
-
pp.err_console().print(f"gate-cache: {message}", markup=False, highlight=False, soft_wrap=True)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|