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 ADDED
@@ -0,0 +1,3 @@
1
+ """Skip lint and test gates that already passed on the exact same git tree."""
2
+
3
+ __version__ = "0.1.0"
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ gate-cache = gate_cache.cli:main
3
+