gate-cache 0.1.0__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.
- gate_cache/__init__.py +3 -0
- gate_cache/cli.py +147 -0
- gate_cache/console.py +14 -0
- gate_cache/gate.py +162 -0
- gate_cache/markers.py +154 -0
- gate_cache/runner.py +127 -0
- gate_cache/snapshot.py +110 -0
- gate_cache-0.1.0.dist-info/METADATA +205 -0
- gate_cache-0.1.0.dist-info/RECORD +11 -0
- gate_cache-0.1.0.dist-info/WHEEL +4 -0
- gate_cache-0.1.0.dist-info/entry_points.txt +3 -0
gate_cache/__init__.py
ADDED
gate_cache/cli.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Command line interface for gate-cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
from cyclopts import App, Parameter
|
|
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
|
+
_USAGE_ERROR = 2
|
|
15
|
+
_INTERRUPTED = 130
|
|
16
|
+
_SUBCOMMANDS = {"status", "clear"}
|
|
17
|
+
_INFO_FLAGS = {"-h", "--help", "--version"}
|
|
18
|
+
|
|
19
|
+
_HELP = """\
|
|
20
|
+
Skip a lint or test command that already passed on the same git tree. gate-cache runs
|
|
21
|
+
the command, records a pass keyed on the git tree of the tracked files (the index, when
|
|
22
|
+
the working copy matches it), and skips the command next time the tree is identical.
|
|
23
|
+
With unstaged changes or untracked files the command runs without caching and nothing
|
|
24
|
+
is recorded.
|
|
25
|
+
|
|
26
|
+
Both --name and -- are required, and only --name may come before --. Everything after
|
|
27
|
+
-- is the command, verbatim.
|
|
28
|
+
|
|
29
|
+
Exit codes:
|
|
30
|
+
run the command's own exit code, or 0 when a recorded pass lets it skip.
|
|
31
|
+
Usage errors exit 2.
|
|
32
|
+
status 0 when a recorded pass exists for the current tree, 1 otherwise.
|
|
33
|
+
clear 0 on success, 1 on failure.
|
|
34
|
+
|
|
35
|
+
Set GATE_CACHE_DISABLE to any non-empty value other than 0 to always run the command.
|
|
36
|
+
|
|
37
|
+
Only file content is keyed. Never wrap checks that depend on anything else,
|
|
38
|
+
such as commit-message or branch-name checks.
|
|
39
|
+
|
|
40
|
+
Typical use:
|
|
41
|
+
gate-cache --name pytest -- uv run pytest -x
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
app = App(
|
|
45
|
+
name="gate-cache",
|
|
46
|
+
version=__version__,
|
|
47
|
+
help=_HELP,
|
|
48
|
+
help_format="plaintext",
|
|
49
|
+
usage=(
|
|
50
|
+
"Usage: gate-cache --name NAME -- COMMAND [ARGS...]\n"
|
|
51
|
+
" gate-cache status --name NAME\n"
|
|
52
|
+
" gate-cache clear"
|
|
53
|
+
),
|
|
54
|
+
result_action="return_int_as_exit_code_else_zero",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.default
|
|
59
|
+
def run(
|
|
60
|
+
command: Annotated[
|
|
61
|
+
list[str], Parameter(allow_leading_hyphen=True, negative_iterable="", show_default=False)
|
|
62
|
+
],
|
|
63
|
+
/,
|
|
64
|
+
*,
|
|
65
|
+
name: str,
|
|
66
|
+
) -> int:
|
|
67
|
+
"""Run COMMAND unless it already passed on the current git tree.
|
|
68
|
+
|
|
69
|
+
On a skip, prints one line to stderr and exits 0 without running anything. Otherwise
|
|
70
|
+
runs the command and exits with its exit code. The command runs without caching
|
|
71
|
+
(and nothing is recorded) when GATE_CACHE_DISABLE is set, the tree has unstaged
|
|
72
|
+
changes or untracked files, or this is not a git repository. A pass is recorded only
|
|
73
|
+
when the command exits 0 and the run leaves the tree unchanged.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
command
|
|
78
|
+
The command and its arguments, given after `--`. Example: -- uv run pytest -x
|
|
79
|
+
name
|
|
80
|
+
Gate name. Records match only when the name and the tree both match.
|
|
81
|
+
"""
|
|
82
|
+
if not command:
|
|
83
|
+
say("no command given after --")
|
|
84
|
+
return _USAGE_ERROR
|
|
85
|
+
return run_gate(name, command)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command
|
|
89
|
+
def status(*, name: str) -> int:
|
|
90
|
+
"""Report whether the gate would skip, without running anything.
|
|
91
|
+
|
|
92
|
+
Prints the current tree hash (or why it is unknown), the marker path, and hit or
|
|
93
|
+
miss to stdout. Exits 0 on a hit, 1 otherwise.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
name
|
|
98
|
+
Gate name to look up.
|
|
99
|
+
"""
|
|
100
|
+
return gate_status(name)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command
|
|
104
|
+
def clear() -> int:
|
|
105
|
+
"""Delete every recorded pass for this repository.
|
|
106
|
+
|
|
107
|
+
Use after something outside git changes, such as a tool upgrade. Exits 1 outside a
|
|
108
|
+
git repository.
|
|
109
|
+
"""
|
|
110
|
+
return clear_cache()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _usage_error(tokens: list[str]) -> str | None:
|
|
114
|
+
"""Return the usage problem with a run invocation, or None when it is well formed.
|
|
115
|
+
|
|
116
|
+
Only `--name X` or `--name=X` may precede `--`, so a stray option such as `--version`
|
|
117
|
+
or the first word of the command can never be mistaken for one of ours.
|
|
118
|
+
"""
|
|
119
|
+
if not tokens or tokens[0] in _SUBCOMMANDS | _INFO_FLAGS:
|
|
120
|
+
return None
|
|
121
|
+
index = 0
|
|
122
|
+
while index < len(tokens):
|
|
123
|
+
arg = tokens[index]
|
|
124
|
+
if arg == "--":
|
|
125
|
+
return None
|
|
126
|
+
if arg == "--name":
|
|
127
|
+
if index + 1 == len(tokens) or tokens[index + 1] == "--":
|
|
128
|
+
return "--name needs a value, e.g. gate-cache --name pytest -- uv run pytest"
|
|
129
|
+
index += 2
|
|
130
|
+
elif arg.startswith("--name="):
|
|
131
|
+
index += 1
|
|
132
|
+
else:
|
|
133
|
+
break
|
|
134
|
+
return "put -- before the command, e.g. gate-cache --name pytest -- uv run pytest"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main() -> None:
|
|
138
|
+
"""Run the gate-cache command line interface and exit with its code."""
|
|
139
|
+
tokens = sys.argv[1:]
|
|
140
|
+
if problem := _usage_error(tokens):
|
|
141
|
+
say(problem)
|
|
142
|
+
sys.exit(_USAGE_ERROR)
|
|
143
|
+
try:
|
|
144
|
+
code = app(tokens)
|
|
145
|
+
except KeyboardInterrupt:
|
|
146
|
+
sys.exit(_INTERRUPTED)
|
|
147
|
+
sys.exit(code)
|
gate_cache/console.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
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)
|
gate_cache/gate.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Run a gate command, skipping it when it already passed on the same tree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from nclutils import pp
|
|
9
|
+
|
|
10
|
+
from gate_cache.console import say
|
|
11
|
+
from gate_cache.markers import clear_markers, marker_path, prune_markers, write_marker
|
|
12
|
+
from gate_cache.runner import run_child
|
|
13
|
+
from gate_cache.snapshot import git_common_dir, take_snapshot
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Mapping, Sequence
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
DISABLE_ENV = "GATE_CACHE_DISABLE"
|
|
20
|
+
_DISABLED_REASON = f"caching disabled by {DISABLE_ENV}"
|
|
21
|
+
_NO_COMMON_DIR_REASON = "could not locate git directory"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cache_disabled(environ: Mapping[str, str] | None = None) -> bool:
|
|
25
|
+
"""Report whether GATE_CACHE_DISABLE turns caching off.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
environ: Environment to read; defaults to the process environment.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
True when the variable is set to any non-empty value other than `0`.
|
|
32
|
+
"""
|
|
33
|
+
value = (os.environ if environ is None else environ).get(DISABLE_ENV, "")
|
|
34
|
+
return value not in {"", "0"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _marker_exists(path: Path) -> bool:
|
|
38
|
+
"""Return True only when the marker is known to exist."""
|
|
39
|
+
try:
|
|
40
|
+
return path.exists()
|
|
41
|
+
except OSError:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _run_uncached(reason: str, command: Sequence[str]) -> int:
|
|
46
|
+
"""Explain why caching is off, then run the command."""
|
|
47
|
+
say(f"{reason}, running without cache")
|
|
48
|
+
return run_child(command).exit_code
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _record_pass(path: Path, *, tree: str, name: str, command: Sequence[str]) -> None:
|
|
52
|
+
"""Write the marker and prune old ones; a failure only warns."""
|
|
53
|
+
try:
|
|
54
|
+
write_marker(path, tree=tree, name=name, command=command)
|
|
55
|
+
except OSError as error:
|
|
56
|
+
say(f"warning: could not record pass: {error}")
|
|
57
|
+
return
|
|
58
|
+
prune_markers(path.parent)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _record_if_unchanged(path: Path, *, tree: str, name: str, command: Sequence[str]) -> None:
|
|
62
|
+
"""Record the pass only when the run left the tree it was keyed on untouched."""
|
|
63
|
+
# A command that rewrote tracked files or left new files behind did not pass on `tree`.
|
|
64
|
+
if take_snapshot().tree == tree:
|
|
65
|
+
_record_pass(path, tree=tree, name=name, command=command)
|
|
66
|
+
else:
|
|
67
|
+
say("files changed during the run, not recording pass")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_gate(name: str, command: Sequence[str]) -> int:
|
|
71
|
+
"""Run a gate command unless it already passed on the tree about to be tested.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
name: Gate name that, with the tree, identifies a recorded pass.
|
|
75
|
+
command: Command and arguments to run.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The command's exit code, or 0 when a recorded pass lets it skip.
|
|
79
|
+
"""
|
|
80
|
+
if cache_disabled():
|
|
81
|
+
return _run_uncached(_DISABLED_REASON, command)
|
|
82
|
+
|
|
83
|
+
snapshot = take_snapshot()
|
|
84
|
+
tree = snapshot.tree
|
|
85
|
+
if tree is None:
|
|
86
|
+
return _run_uncached(snapshot.reason or "tree unknown", command)
|
|
87
|
+
|
|
88
|
+
common_dir = git_common_dir()
|
|
89
|
+
if common_dir is None:
|
|
90
|
+
return _run_uncached(_NO_COMMON_DIR_REASON, command)
|
|
91
|
+
|
|
92
|
+
path = marker_path(common_dir, tree, name)
|
|
93
|
+
if _marker_exists(path):
|
|
94
|
+
say(f"{name} already passed on tree {tree[:12]}, skipping")
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
result = run_child(command)
|
|
98
|
+
if result.interrupted or result.exit_code != 0:
|
|
99
|
+
return result.exit_code
|
|
100
|
+
|
|
101
|
+
# Recording is best effort: nothing after a passing run may change its exit code.
|
|
102
|
+
try:
|
|
103
|
+
_record_if_unchanged(path, tree=tree, name=name, command=command)
|
|
104
|
+
except Exception as error: # noqa: BLE001
|
|
105
|
+
say(f"warning: could not record pass: {error}")
|
|
106
|
+
return result.exit_code
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def gate_status(name: str) -> int:
|
|
110
|
+
"""Print whether a gate would skip, without running anything.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
name: Gate name to look up.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
0 when a recorded pass exists for the current tree, 1 otherwise.
|
|
117
|
+
"""
|
|
118
|
+
if cache_disabled():
|
|
119
|
+
say(_DISABLED_REASON)
|
|
120
|
+
return 1
|
|
121
|
+
|
|
122
|
+
snapshot = take_snapshot()
|
|
123
|
+
tree = snapshot.tree
|
|
124
|
+
path: Path | None = None
|
|
125
|
+
marker = "none"
|
|
126
|
+
if tree is not None:
|
|
127
|
+
common_dir = git_common_dir()
|
|
128
|
+
if common_dir is None:
|
|
129
|
+
marker = f"none: {_NO_COMMON_DIR_REASON}"
|
|
130
|
+
else:
|
|
131
|
+
path = marker_path(common_dir, tree, name)
|
|
132
|
+
marker = str(path)
|
|
133
|
+
hit = path is not None and _marker_exists(path)
|
|
134
|
+
|
|
135
|
+
pp.kv(
|
|
136
|
+
{
|
|
137
|
+
"name": name,
|
|
138
|
+
"tree": tree if tree is not None else f"unknown: {snapshot.reason}",
|
|
139
|
+
"marker": marker,
|
|
140
|
+
"result": "hit" if hit else "miss",
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
return 0 if hit else 1
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def clear_cache() -> int:
|
|
147
|
+
"""Delete every marker for the current repository.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
0 on success, 1 outside a git repository or when markers cannot be removed.
|
|
151
|
+
"""
|
|
152
|
+
common_dir = git_common_dir()
|
|
153
|
+
if common_dir is None:
|
|
154
|
+
say("not a git repository")
|
|
155
|
+
return 1
|
|
156
|
+
try:
|
|
157
|
+
count = clear_markers(common_dir)
|
|
158
|
+
except OSError as error:
|
|
159
|
+
say(f"could not clear markers: {error}")
|
|
160
|
+
return 1
|
|
161
|
+
say(f"removed {count} marker{'' if count == 1 else 's'}")
|
|
162
|
+
return 0
|
gate_cache/markers.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Marker files that record a gate command passing on a git tree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from nclutils.utils import iso_timestamp
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
|
|
19
|
+
MARKER_DIRNAME = "gate-cache"
|
|
20
|
+
TEMP_PREFIX = ".tmp-"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def marker_key(tree: str, name: str) -> str:
|
|
24
|
+
"""Compute the marker key for a tree and gate name.
|
|
25
|
+
|
|
26
|
+
The key is the lowercase hex sha256 of tree, a NUL byte, and name. This is a public
|
|
27
|
+
contract shared with shell consumers; changing it is a breaking change.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
tree: Git tree hash.
|
|
31
|
+
name: Gate name.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The lowercase hex digest.
|
|
35
|
+
"""
|
|
36
|
+
return hashlib.sha256(f"{tree}\0{name}".encode()).hexdigest()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def marker_dir(common_dir: Path) -> Path:
|
|
40
|
+
"""Return the marker directory inside a git common dir.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
common_dir: Absolute path to the git common directory.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The directory that holds markers.
|
|
47
|
+
"""
|
|
48
|
+
return common_dir / MARKER_DIRNAME
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def marker_path(common_dir: Path, tree: str, name: str) -> Path:
|
|
52
|
+
"""Return the marker file path for a tree and gate name.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
common_dir: Absolute path to the git common directory.
|
|
56
|
+
tree: Git tree hash.
|
|
57
|
+
name: Gate name.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The path whose existence signals a cached pass.
|
|
61
|
+
"""
|
|
62
|
+
return marker_dir(common_dir) / marker_key(tree, name)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def write_marker(path: Path, *, tree: str, name: str, command: Sequence[str]) -> None:
|
|
66
|
+
"""Atomically write a marker file.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
path: Destination marker path; its parent directory is created if needed.
|
|
70
|
+
tree: Git tree hash the command passed on.
|
|
71
|
+
name: Gate name.
|
|
72
|
+
command: The command that passed.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
OSError: If the marker cannot be written. Any temp file is removed.
|
|
76
|
+
"""
|
|
77
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
payload = {
|
|
79
|
+
"tree": tree,
|
|
80
|
+
"name": name,
|
|
81
|
+
"command": list(command),
|
|
82
|
+
"timestamp": iso_timestamp(),
|
|
83
|
+
}
|
|
84
|
+
tmp_name: str | None = None
|
|
85
|
+
try:
|
|
86
|
+
with tempfile.NamedTemporaryFile(
|
|
87
|
+
"w", encoding="utf-8", dir=path.parent, prefix=TEMP_PREFIX, delete=False
|
|
88
|
+
) as tmp:
|
|
89
|
+
tmp_name = tmp.name
|
|
90
|
+
json.dump(payload, tmp)
|
|
91
|
+
Path(tmp_name).replace(path)
|
|
92
|
+
except OSError:
|
|
93
|
+
if tmp_name is not None:
|
|
94
|
+
with contextlib.suppress(OSError):
|
|
95
|
+
Path(tmp_name).unlink()
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def prune_markers(directory: Path, *, max_age_days: int = 30, now: float | None = None) -> None:
|
|
100
|
+
"""Delete markers older than the cutoff, ignoring every filesystem error.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
directory: Marker directory to prune.
|
|
104
|
+
max_age_days: Age in days beyond which a file is deleted.
|
|
105
|
+
now: Current epoch time; defaults to the system clock.
|
|
106
|
+
"""
|
|
107
|
+
cutoff = (time.time() if now is None else now) - max_age_days * 86400
|
|
108
|
+
try:
|
|
109
|
+
for entry in directory.iterdir():
|
|
110
|
+
with contextlib.suppress(OSError):
|
|
111
|
+
if entry.is_file() and not entry.is_symlink() and entry.stat().st_mtime < cutoff:
|
|
112
|
+
entry.unlink()
|
|
113
|
+
except OSError:
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def clear_markers(common_dir: Path) -> int:
|
|
118
|
+
"""Delete every marker and the marker directory.
|
|
119
|
+
|
|
120
|
+
Files that vanish mid-clear and leftover subdirectories are tolerated, so a concurrent
|
|
121
|
+
writer cannot make clearing fail.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
common_dir: Absolute path to the git common directory.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
The number of markers deleted, not counting in-flight temp files; 0 when the
|
|
128
|
+
directory is missing.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
OSError: If the marker directory cannot be listed, a file cannot be deleted, or the
|
|
132
|
+
directory remains afterward.
|
|
133
|
+
"""
|
|
134
|
+
directory = marker_dir(common_dir)
|
|
135
|
+
if not directory.is_dir():
|
|
136
|
+
return 0
|
|
137
|
+
count = 0
|
|
138
|
+
for entry in directory.iterdir():
|
|
139
|
+
if entry.is_dir() and not entry.is_symlink():
|
|
140
|
+
continue
|
|
141
|
+
try:
|
|
142
|
+
entry.unlink()
|
|
143
|
+
except FileNotFoundError:
|
|
144
|
+
continue
|
|
145
|
+
if not entry.name.startswith(TEMP_PREFIX):
|
|
146
|
+
count += 1
|
|
147
|
+
try:
|
|
148
|
+
directory.rmdir()
|
|
149
|
+
except OSError:
|
|
150
|
+
shutil.rmtree(directory, ignore_errors=True)
|
|
151
|
+
if directory.exists():
|
|
152
|
+
msg = f"could not remove {directory}"
|
|
153
|
+
raise OSError(msg)
|
|
154
|
+
return count
|
gate_cache/runner.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Run the wrapped gate command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import threading
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from gate_cache.console import say
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Generator, Sequence
|
|
16
|
+
from types import FrameType
|
|
17
|
+
|
|
18
|
+
_EXIT_NOT_EXECUTABLE = 126
|
|
19
|
+
_EXIT_NOT_FOUND = 127
|
|
20
|
+
_SIGNAL_EXIT_BASE = 128
|
|
21
|
+
_FORWARDED_SIGNALS = (signal.SIGTERM, signal.SIGHUP)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ChildResult:
|
|
26
|
+
"""Outcome of the wrapped command.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
exit_code: Exit code to propagate, with signal deaths mapped to 128 + signal.
|
|
30
|
+
interrupted: True when gate-cache received SIGINT, SIGTERM, or SIGHUP while the
|
|
31
|
+
child ran.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
exit_code: int
|
|
35
|
+
interrupted: bool
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class _SignalState:
|
|
40
|
+
"""Signals received while the child runs, and the child to forward them to."""
|
|
41
|
+
|
|
42
|
+
proc: subprocess.Popen[bytes] | None = None
|
|
43
|
+
interrupted: bool = False
|
|
44
|
+
pending: list[int] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
def handle(self, signum: int, _frame: FrameType | None) -> None:
|
|
47
|
+
"""Record a signal and forward SIGTERM or SIGHUP to the child."""
|
|
48
|
+
self.interrupted = True
|
|
49
|
+
if self.proc is None:
|
|
50
|
+
# A signal during spawn may predate the fork, so the child never saw it.
|
|
51
|
+
self.pending.append(signum)
|
|
52
|
+
elif signum != signal.SIGINT:
|
|
53
|
+
# SIGINT already reaches a running child through the shared process group. A
|
|
54
|
+
# group-wide TERM or HUP reaches it twice; that beats orphaning it on a direct one.
|
|
55
|
+
_forward(self.proc, signum)
|
|
56
|
+
|
|
57
|
+
def attach(self, proc: subprocess.Popen[bytes]) -> None:
|
|
58
|
+
"""Set the child and forward any signal that arrived while it was starting."""
|
|
59
|
+
self.proc = proc
|
|
60
|
+
while self.pending:
|
|
61
|
+
_forward(proc, self.pending.pop(0))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _forward(proc: subprocess.Popen[bytes], signum: int) -> None:
|
|
65
|
+
with contextlib.suppress(OSError):
|
|
66
|
+
proc.send_signal(signum)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@contextlib.contextmanager
|
|
70
|
+
def _trap_signals(state: _SignalState) -> Generator[None]:
|
|
71
|
+
"""Route SIGINT, SIGTERM, and SIGHUP to state, restoring the previous handlers after.
|
|
72
|
+
|
|
73
|
+
Python handlers reset to the default on exec, so the child keeps normal signal
|
|
74
|
+
behavior. Ignored signals stay ignored so the child inherits that disposition, as
|
|
75
|
+
under `nohup` or a background job. Handlers can only be set from the main thread;
|
|
76
|
+
elsewhere signals keep their current disposition.
|
|
77
|
+
"""
|
|
78
|
+
if threading.current_thread() is not threading.main_thread():
|
|
79
|
+
yield
|
|
80
|
+
return
|
|
81
|
+
previous = {
|
|
82
|
+
signum: signal.signal(signum, state.handle)
|
|
83
|
+
for signum in (signal.SIGINT, *_FORWARDED_SIGNALS)
|
|
84
|
+
if signal.getsignal(signum) is not signal.SIG_IGN
|
|
85
|
+
}
|
|
86
|
+
try:
|
|
87
|
+
yield
|
|
88
|
+
finally:
|
|
89
|
+
for signum, handler in previous.items():
|
|
90
|
+
# None means a handler installed outside Python, which cannot be reinstated.
|
|
91
|
+
if handler is not None:
|
|
92
|
+
signal.signal(signum, handler)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def run_child(argv: Sequence[str]) -> ChildResult:
|
|
96
|
+
"""Run a command with inherited streams and environment, and wait for it.
|
|
97
|
+
|
|
98
|
+
The child shares our process group, so Ctrl-C reaches it directly; SIGTERM and SIGHUP
|
|
99
|
+
sent to gate-cache are forwarded to it. Either way gate-cache keeps waiting and
|
|
100
|
+
reports the child's status instead of abandoning it.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
argv: Command and arguments to execute.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The exit code (127 if not found, 126 if it cannot be executed) and whether a
|
|
107
|
+
signal arrived while the child ran.
|
|
108
|
+
"""
|
|
109
|
+
state = _SignalState()
|
|
110
|
+
with _trap_signals(state):
|
|
111
|
+
try:
|
|
112
|
+
proc = subprocess.Popen(list(argv)) # noqa: S603
|
|
113
|
+
except FileNotFoundError:
|
|
114
|
+
say(f"command not found: {argv[0]}")
|
|
115
|
+
return ChildResult(_EXIT_NOT_FOUND, interrupted=state.interrupted)
|
|
116
|
+
except PermissionError:
|
|
117
|
+
say(f"permission denied: {argv[0]}")
|
|
118
|
+
return ChildResult(_EXIT_NOT_EXECUTABLE, interrupted=state.interrupted)
|
|
119
|
+
except OSError as error:
|
|
120
|
+
say(f"could not run {argv[0]}: {error}")
|
|
121
|
+
return ChildResult(_EXIT_NOT_EXECUTABLE, interrupted=state.interrupted)
|
|
122
|
+
state.attach(proc)
|
|
123
|
+
code = proc.wait()
|
|
124
|
+
|
|
125
|
+
if code < 0:
|
|
126
|
+
code = _SIGNAL_EXIT_BASE - code
|
|
127
|
+
return ChildResult(code, interrupted=state.interrupted)
|
gate_cache/snapshot.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Detect the git tree that a gate command is about to test."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from nclutils.git import run_git
|
|
9
|
+
from nclutils.sh import ShellCommandError, ShellCommandNotFoundError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Snapshot:
|
|
14
|
+
"""The tree a gate is about to test, or why that tree is unknown.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
tree: Git tree hash describing the content under test, or None when unknown.
|
|
18
|
+
reason: Why the tree is unknown, or None when it is known.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
tree: str | None
|
|
22
|
+
reason: str | None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def known(self) -> bool:
|
|
26
|
+
"""Return True when a tree hash identifies the content under test."""
|
|
27
|
+
return self.tree is not None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _work_tree_root(cwd: Path | None) -> Path | None:
|
|
31
|
+
"""Return the absolute top of the work tree, or None when there is none."""
|
|
32
|
+
result = run_git("rev-parse", "--show-toplevel", cwd=cwd, check=False)
|
|
33
|
+
output = result.stdout.strip()
|
|
34
|
+
if result.returncode != 0 or not output or "\n" in output:
|
|
35
|
+
return None
|
|
36
|
+
return Path(output)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _unknown_reason(cwd: Path | None) -> str | None:
|
|
40
|
+
"""Return why the working copy cannot be described by one tree, or None if it can."""
|
|
41
|
+
if run_git("rev-parse", "--git-dir", cwd=cwd, check=False).returncode != 0:
|
|
42
|
+
return "not a git repository"
|
|
43
|
+
# ls-files only lists paths under its cwd. Probing from the top avoids the `:/` pathspec,
|
|
44
|
+
# which GIT_LITERAL_PATHSPECS turns into a literal path that matches nothing.
|
|
45
|
+
root = _work_tree_root(cwd)
|
|
46
|
+
if root is None:
|
|
47
|
+
return "could not locate work tree"
|
|
48
|
+
if run_git("ls-files", "--unmerged", cwd=root).stdout:
|
|
49
|
+
return "unmerged index"
|
|
50
|
+
if run_git("diff", "--quiet", cwd=root, okay_codes=(0, 1)).returncode != 0:
|
|
51
|
+
return "unstaged changes to tracked files"
|
|
52
|
+
untracked = run_git("ls-files", "--others", "--exclude-standard", "-z", cwd=root).stdout
|
|
53
|
+
if untracked:
|
|
54
|
+
return f"untracked file: {untracked.split(chr(0))[0]}"
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def take_snapshot(cwd: Path | None = None) -> Snapshot:
|
|
59
|
+
"""Identify the tree a gate command is about to test.
|
|
60
|
+
|
|
61
|
+
The snapshot is known only when the working copy matches the index for tracked files and
|
|
62
|
+
no untracked, non-ignored files exist. Every other situation, including any git failure,
|
|
63
|
+
resolves to unknown so the caller runs the command uncached.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
cwd: Directory to run git in. Defaults to the process working directory.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
A Snapshot holding the tree hash, or the reason it is unknown.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
reason = _unknown_reason(cwd)
|
|
73
|
+
except ShellCommandNotFoundError:
|
|
74
|
+
return Snapshot(tree=None, reason="git not found")
|
|
75
|
+
except (ShellCommandError, UnicodeDecodeError):
|
|
76
|
+
return Snapshot(tree=None, reason="could not inspect git state")
|
|
77
|
+
if reason:
|
|
78
|
+
return Snapshot(tree=None, reason=reason)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
tree = run_git("write-tree", cwd=cwd).stdout
|
|
82
|
+
except (ShellCommandError, UnicodeDecodeError):
|
|
83
|
+
return Snapshot(tree=None, reason="could not compute tree")
|
|
84
|
+
return Snapshot(tree=tree, reason=None)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def git_common_dir(cwd: Path | None = None) -> Path | None:
|
|
88
|
+
"""Return the absolute git common directory shared by all linked worktrees.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
cwd: Directory to run git in. Defaults to the process working directory.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The absolute path, or None when git fails or its output is not a single path.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
result = run_git("rev-parse", "--git-common-dir", cwd=cwd)
|
|
98
|
+
except (ShellCommandError, UnicodeDecodeError):
|
|
99
|
+
return None
|
|
100
|
+
output = result.stdout.strip()
|
|
101
|
+
if not output or "\n" in output:
|
|
102
|
+
return None
|
|
103
|
+
path = Path(output)
|
|
104
|
+
if path.is_absolute():
|
|
105
|
+
return path
|
|
106
|
+
try:
|
|
107
|
+
base = Path(cwd) if cwd is not None else Path.cwd()
|
|
108
|
+
return (base / path).resolve()
|
|
109
|
+
except (OSError, RuntimeError):
|
|
110
|
+
return None
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: gate-cache
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Skip lint and test gates that already passed on the exact same git tree.
|
|
5
|
+
Author: Nate Landau
|
|
6
|
+
Author-email: Nate Landau <github@natenate.org>
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2026 Nate Landau
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
29
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
30
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
32
|
+
Requires-Dist: cyclopts>=5.2.0,<6.0.0
|
|
33
|
+
Requires-Dist: nclutils>=3.4.4,<4.0.0
|
|
34
|
+
Requires-Python: >=3.11
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# gate-cache
|
|
38
|
+
|
|
39
|
+
Skip lint and test gates that already passed on the exact same git tree.
|
|
40
|
+
|
|
41
|
+
The same code often goes through the same gates many times before it ships: at the end of local work, in a pre-commit hook, again after a rebase that changed nothing, and again in a pre-push hook. When a test suite takes minutes, this repetition is the slowest part of the cycle.
|
|
42
|
+
|
|
43
|
+
gate-cache wraps one gate command. When the command passes, gate-cache records the pass against the git tree of the tracked files. If you run the same named gate on the same tree again, gate-cache exits 0 in milliseconds and does not run the command. CI starts from a fresh clone with no records, so CI still runs every gate.
|
|
44
|
+
|
|
45
|
+
```console
|
|
46
|
+
$ gate-cache --name pytest -- uv run pytest
|
|
47
|
+
... pytest output ...
|
|
48
|
+
$ gate-cache --name pytest -- uv run pytest
|
|
49
|
+
gate-cache: pytest already passed on tree aaff74984ccc, skipping
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
gate-cache requires Python 3.11 or later.
|
|
55
|
+
|
|
56
|
+
To install the command line tool, run:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
uv tool install gate-cache
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
You do not need to install anything to use gate-cache as a hook. [prek](https://github.com/j178/prek) and [pre-commit](https://pre-commit.com) 3.2 or later install it from the hook repository.
|
|
63
|
+
|
|
64
|
+
## Use it as a hook
|
|
65
|
+
|
|
66
|
+
Add one `gate-cache` entry for each gate that you want to cache. Each entry gives the gate a name with `--name` and puts the command after `--`:
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
repos:
|
|
70
|
+
- repo: https://github.com/natelandau/gate-cache
|
|
71
|
+
rev: v0.1.0
|
|
72
|
+
hooks:
|
|
73
|
+
- id: gate-cache
|
|
74
|
+
name: pytest
|
|
75
|
+
alias: pytest
|
|
76
|
+
args: [--name, pytest, --, uv, run, pytest]
|
|
77
|
+
files: ^(src/|tests/|uv\.lock|pyproject\.toml)
|
|
78
|
+
- id: gate-cache
|
|
79
|
+
name: ruff
|
|
80
|
+
alias: ruff
|
|
81
|
+
args: [--name, ruff, --, uv, run, ruff, check, .]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The hook sets `pass_filenames: false`, so the runner does not append file names to the wrapped command. The `files` and `always_run` keys are your choice. The hook runs in the `pre-commit`, `pre-push`, and `manual` stages.
|
|
85
|
+
|
|
86
|
+
The wrapped command runs in the environment of the hook. prek and pre-commit install gate-cache in its own virtual environment, so the command sees `VIRTUAL_ENV` set to that environment, and the `bin` directory of that environment comes first on `PATH`. gate-cache does not change this environment. Thus:
|
|
87
|
+
|
|
88
|
+
- Start the command with `uv run`, or give an absolute path or a command that your project supplies. Do not use a bare tool name such as `pytest`, because it can find the copy in the hook environment, or find nothing.
|
|
89
|
+
- `uv run` prints `VIRTUAL_ENV=... does not match the project environment path .venv and will be ignored`. This warning is harmless: `uv run` uses the project environment.
|
|
90
|
+
|
|
91
|
+
Agents, scripts, and people can call the same command directly. A pass that one caller records is a hit for every other caller:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
gate-cache --name pytest -- uv run pytest
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Both `--name` and `--` are required, and only `--name` can come before `--`. gate-cache passes everything after `--` to the command without changes. A missing `--name` or `--`, or any other argument before `--`, is a usage error: gate-cache runs nothing and exits 2. Otherwise gate-cache exits with the exit code of the command, or 0 on a skip. If a signal stops the command, gate-cache exits with 128 plus the signal number.
|
|
98
|
+
|
|
99
|
+
## Never wrap these
|
|
100
|
+
|
|
101
|
+
gate-cache keys a pass only on the content of tracked files. Never wrap a command whose result depends on anything else. A wrapped check of that kind can be skipped when a fresh run fails.
|
|
102
|
+
|
|
103
|
+
Keep these as normal, unwrapped hooks:
|
|
104
|
+
|
|
105
|
+
- Commit message checks, such as the commitizen `commit-msg` hook.
|
|
106
|
+
- Branch name checks.
|
|
107
|
+
- Any command that talks to a remote.
|
|
108
|
+
- Secret scanners and other hooks that must always run, such as gitleaks.
|
|
109
|
+
|
|
110
|
+
gate-cache decides only about the command that it wraps. It never skips other hooks.
|
|
111
|
+
|
|
112
|
+
## How a cache hit is decided
|
|
113
|
+
|
|
114
|
+
Each time it runs, gate-cache does these steps in this sequence:
|
|
115
|
+
|
|
116
|
+
1. If `GATE_CACHE_DISABLE` turns caching off, run the command and record nothing.
|
|
117
|
+
2. Find the tree to key on. The tree is `git write-tree`, which is the content of the index. gate-cache uses it only if the tracked files in the working copy match the index and no untracked, non-ignored files exist.
|
|
118
|
+
3. If the tree is unknown, run the command without the cache and record nothing. The tree is unknown with unstaged changes, untracked non-ignored files, an unmerged index, outside a git repository, or after any git error.
|
|
119
|
+
4. If git cannot find the git directory that holds the markers, run the command without the cache and record nothing.
|
|
120
|
+
5. If a marker for the tree and name exists, print a skip line and exit 0.
|
|
121
|
+
6. Run the command.
|
|
122
|
+
7. If the command exits 0 and the tree is still the same, write the marker.
|
|
123
|
+
|
|
124
|
+
At commit time, prek and pre-commit stash unstaged edits, so the key is the staged content that you commit. At push time with a clean checkout, the key is the tree of `HEAD`.
|
|
125
|
+
|
|
126
|
+
A command that exits 0 but changes the tree does not get a record. Examples are a formatter that rewrites files and a command that leaves new untracked files. gate-cache prints `files changed during the run, not recording pass` in this case.
|
|
127
|
+
|
|
128
|
+
A failing command, a signal, or Ctrl-C never writes a marker. If gate-cache gets `SIGTERM` or `SIGHUP` while the command runs, it sends the signal to the command, waits for the command to stop, and exits with the status of the command. A cache error never changes the exit code of the command. If gate-cache cannot write a marker, it prints a warning and keeps the exit code.
|
|
129
|
+
|
|
130
|
+
Markers live in the git common directory, so all linked worktrees of a repository share them. After each new marker, gate-cache deletes markers older than 30 days.
|
|
131
|
+
|
|
132
|
+
## Marker format
|
|
133
|
+
|
|
134
|
+
Other tools can read and write markers without calling gate-cache. The marker format is a public contract, and any change to it is a breaking change.
|
|
135
|
+
|
|
136
|
+
The key is the lowercase hex SHA-256 of the tree hash, a NUL byte, and the gate name:
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
key = sha256( tree_hash + "\0" + name )
|
|
140
|
+
marker = $(git rev-parse --git-common-dir)/gate-cache/<key>
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
To find the marker for the `pytest` gate from a shell, run:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
tree=$(git write-tree)
|
|
147
|
+
key=$(printf '%s\0%s' "$tree" pytest | shasum -a 256 | cut -d' ' -f1)
|
|
148
|
+
marker="$(git rev-parse --git-common-dir)/gate-cache/$key"
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
On Linux systems without `shasum`, use `sha256sum` instead.
|
|
152
|
+
|
|
153
|
+
The existence of the marker file is the signal. The file holds a small JSON object with `tree`, `name`, `command`, and `timestamp` keys for debugging. Do not depend on its content.
|
|
154
|
+
|
|
155
|
+
## Blind spots
|
|
156
|
+
|
|
157
|
+
The key covers only the content and the modes of tracked files. It does not cover:
|
|
158
|
+
|
|
159
|
+
- Ignored inputs, such as `.env` and `.venv`.
|
|
160
|
+
- Environment variables.
|
|
161
|
+
- Tool versions that a lockfile does not pin.
|
|
162
|
+
- External services.
|
|
163
|
+
- Changes that git is told to ignore. `core.fileMode=false` hides changes to the executable bit. `git update-index --assume-unchanged` and `--skip-worktree` hide edits to tracked files.
|
|
164
|
+
- Changes in submodules that git is told to ignore. `diff.ignoreSubmodules` and the `submodule.<name>.ignore` setting can hide a dirty submodule, so a run with uncommitted submodule edits can record a pass for the tree of the parent repository.
|
|
165
|
+
|
|
166
|
+
If a gate depends on one of these, gate-cache can skip it when a fresh run fails. CI still runs every gate from a fresh clone. After a change outside git, such as a tool upgrade, run `gate-cache clear`. To run gates without the cache, set `GATE_CACHE_DISABLE=1`.
|
|
167
|
+
|
|
168
|
+
## Commands
|
|
169
|
+
|
|
170
|
+
gate-cache has two commands in addition to the default run. Run `gate-cache --help` for the full reference.
|
|
171
|
+
|
|
172
|
+
### status
|
|
173
|
+
|
|
174
|
+
`gate-cache status` reports whether a gate would skip on the current tree. It runs nothing.
|
|
175
|
+
|
|
176
|
+
```console
|
|
177
|
+
$ gate-cache status --name pytest
|
|
178
|
+
name: pytest
|
|
179
|
+
tree: aaff74984cccd156a469afa7d9ab10e4777beb24
|
|
180
|
+
marker: /path/to/repo/.git/gate-cache/698af5f62774dab7babf59a4b8969fed12d8668428cee49ca89baf4aa392de6d
|
|
181
|
+
result: hit
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
If the tree is unknown, the `tree` line gives the reason, such as `unknown: untracked file: notes.txt`. The command exits 0 on a hit and 1 otherwise.
|
|
185
|
+
|
|
186
|
+
### clear
|
|
187
|
+
|
|
188
|
+
`gate-cache clear` deletes every marker for the current repository, in all of its worktrees. Use it after a change that git cannot see, such as a tool upgrade. Outside a git repository, it exits 1.
|
|
189
|
+
|
|
190
|
+
```console
|
|
191
|
+
$ gate-cache clear
|
|
192
|
+
gate-cache: removed 3 markers
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Turn off caching
|
|
196
|
+
|
|
197
|
+
If `GATE_CACHE_DISABLE` has a value that is not empty and not `0`, gate-cache runs every command and never reads or writes markers. With caching off, `gate-cache status` exits 1.
|
|
198
|
+
|
|
199
|
+
```sh
|
|
200
|
+
GATE_CACHE_DISABLE=1 git push
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Supported runners and platforms
|
|
204
|
+
|
|
205
|
+
gate-cache works with prek and with pre-commit 3.2 or later. It runs on macOS and Linux. Windows is not supported.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
gate_cache/__init__.py,sha256=eGz5PZOmseZDtmcQqgxUajZMGqbRXk15YHEU_pWvvLM,102
|
|
2
|
+
gate_cache/cli.py,sha256=jSePrbgeYBuSp16g9eKl1d0NezXS57iPqqFBe24czKk,4597
|
|
3
|
+
gate_cache/console.py,sha256=8rjGYBWiXMeuZ9A0v8Ot0JKrQt2_std5Qb8AmJjd_WY,371
|
|
4
|
+
gate_cache/gate.py,sha256=Od-qNwGyvAWi1js5_r_TaHsBo4luJPg8ulunsRZRjG4,5091
|
|
5
|
+
gate_cache/markers.py,sha256=xYRvuXO1q0RZ3GhayPWoszWaSkz6iiHBJ4wZvjYUrG0,4510
|
|
6
|
+
gate_cache/runner.py,sha256=LRNAOWBG-7doKih-r9hUbfilbcl7_nqjInvHATypFhI,4475
|
|
7
|
+
gate_cache/snapshot.py,sha256=3HFijtH63T8C0zr-ZXN_4l47oqrY5LMIKe364xARSv8,3988
|
|
8
|
+
gate_cache-0.1.0.dist-info/WHEEL,sha256=xTCBFIZIIyRSxyowOv5Wqg_jFU6KsdIffuulBB09Ph8,80
|
|
9
|
+
gate_cache-0.1.0.dist-info/entry_points.txt,sha256=6w3QTtXIFBOvsGxS73hecc0ZO61aGFASNnQdvooGR28,52
|
|
10
|
+
gate_cache-0.1.0.dist-info/METADATA,sha256=P2TIxoz6eB7BW5ifETLxYnaIG1YlJvEI7q0v65iJWgg,10574
|
|
11
|
+
gate_cache-0.1.0.dist-info/RECORD,,
|