gate-cache 0.4.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.4.0 → gate_cache-0.6.0}/PKG-INFO +37 -28
- {gate_cache-0.4.0 → gate_cache-0.6.0}/README.md +35 -25
- {gate_cache-0.4.0 → gate_cache-0.6.0}/pyproject.toml +4 -7
- {gate_cache-0.4.0 → gate_cache-0.6.0}/pyproject.toml.orig +4 -5
- {gate_cache-0.4.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.4.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.4.0 → gate_cache-0.6.0}/src/gate_cache/markers.py +3 -3
- {gate_cache-0.4.0 → gate_cache-0.6.0}/src/gate_cache/snapshot.py +5 -7
- gate_cache-0.4.0/src/gate_cache/cli.py +0 -169
- gate_cache-0.4.0/src/gate_cache/console.py +0 -14
- {gate_cache-0.4.0 → gate_cache-0.6.0}/LICENSE +0 -0
- {gate_cache-0.4.0 → gate_cache-0.6.0}/src/gate_cache/runner.py +0 -0
- {gate_cache-0.4.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
|
|
@@ -41,14 +40,14 @@ Skip a command that already passed on the same code.
|
|
|
41
40
|
|
|
42
41
|
A lint or test command often runs many times on code that did not change. It runs when you finish the work, in a pre-commit hook, after a rebase that changed nothing, and in a pre-push hook. When an agent works in the repository, it runs the same command again. If the command takes minutes, the repeat runs take most of the time between finished code and a pushed branch.
|
|
43
42
|
|
|
44
|
-
gate-cache remembers which commands passed on which code. It wraps a command and
|
|
43
|
+
gate-cache remembers which commands passed on which code. It wraps a command, and the command line is the name of the gate. When the command passes, gate-cache writes a marker for that name and for the exact content of the files in the working copy that git does not ignore. The next time the same name runs on the same content, gate-cache finds the marker, skips the command, and exits 0 at once.
|
|
45
44
|
|
|
46
45
|
```console
|
|
47
|
-
$ gate-cache --
|
|
46
|
+
$ gate-cache -- uv run pytest
|
|
48
47
|
============================= 412 passed in 94.20s =============================
|
|
49
48
|
|
|
50
|
-
$ gate-cache --
|
|
51
|
-
gate-cache: pytest already passed on tree aaff74984ccc, skipping
|
|
49
|
+
$ gate-cache -- uv run pytest
|
|
50
|
+
gate-cache: uv run pytest already passed on tree aaff74984ccc, skipping
|
|
52
51
|
```
|
|
53
52
|
|
|
54
53
|
A change to any file that git does not ignore changes the content, so no marker matches and the command runs again. A CI runner that starts from a fresh clone has no markers, so it runs every command.
|
|
@@ -65,14 +64,22 @@ The prek and pre-commit hooks install gate-cache themselves, so the hooks work w
|
|
|
65
64
|
|
|
66
65
|
## Run a command through gate-cache
|
|
67
66
|
|
|
68
|
-
The
|
|
67
|
+
The command goes after `--`:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
gate-cache -- uv run pytest
|
|
71
|
+
gate-cache -- uv run ruff check .
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The command and its arguments are the name of the gate. Each name has its own markers, so a pass of `uv run pytest -k unit` never skips `uv run pytest`.
|
|
75
|
+
|
|
76
|
+
To choose the name yourself, put `--name` before `--`:
|
|
69
77
|
|
|
70
78
|
```sh
|
|
71
79
|
gate-cache --name pytest -- uv run pytest
|
|
72
|
-
gate-cache --name ruff -- uv run ruff check .
|
|
73
80
|
```
|
|
74
81
|
|
|
75
|
-
|
|
82
|
+
With `--name`, the name alone identifies the gate, and the arguments of the command are not part of it. Give a name when two different command lines do the same check and must share markers, or when a gate depends on [inputs that git does not track](#inputs-that-git-does-not-track).
|
|
76
83
|
|
|
77
84
|
gate-cache exits with the exit code of the command, or with 0 when it skips the command. It writes a marker only when the command passes, so a command that failed runs again the next time.
|
|
78
85
|
|
|
@@ -89,18 +96,18 @@ repos:
|
|
|
89
96
|
hooks:
|
|
90
97
|
- id: gate-cache
|
|
91
98
|
name: pytest
|
|
92
|
-
args: [
|
|
99
|
+
args: [--, uv, run, pytest]
|
|
93
100
|
files: ^(src/|tests/)
|
|
94
101
|
- id: gate-cache
|
|
95
102
|
name: ruff
|
|
96
|
-
args: [
|
|
103
|
+
args: [--, uv, run, ruff, check, .]
|
|
97
104
|
```
|
|
98
105
|
|
|
99
106
|
The hook runs at commit time and at push time. At commit time, gate-cache matches the content that you commit.
|
|
100
107
|
|
|
101
108
|
The hook runs in its own virtual environment, and the wrapped command inherits that environment. A command started with `uv run`, or a project command such as `just test`, runs in the project environment. `uv run` prints a warning that `VIRTUAL_ENV` does not match the project. The warning has no effect on the run.
|
|
102
109
|
|
|
103
|
-
Hooks, agents, and your terminal all read and write the same markers. When one of them runs `gate-cache --
|
|
110
|
+
Hooks, agents, and your terminal all read and write the same markers. When one of them runs `gate-cache -- uv run pytest` and it passes, the others skip `uv run pytest` on the same content.
|
|
104
111
|
|
|
105
112
|
## Examples
|
|
106
113
|
|
|
@@ -110,24 +117,26 @@ These examples show gate-cache inside the task runner of a project. When a recip
|
|
|
110
117
|
|
|
111
118
|
```just
|
|
112
119
|
test:
|
|
113
|
-
gate-cache --
|
|
120
|
+
gate-cache -- uv run pytest
|
|
114
121
|
|
|
115
122
|
lint:
|
|
116
|
-
gate-cache --
|
|
123
|
+
gate-cache -- uv run ruff check .
|
|
117
124
|
```
|
|
118
125
|
|
|
119
126
|
After `just test` passes, the next `just test` on the same content skips pytest.
|
|
120
127
|
|
|
121
128
|
### Recipes with arguments
|
|
122
129
|
|
|
123
|
-
|
|
130
|
+
A recipe that passes its arguments to the command needs no `--name`. Each set of arguments gives a different command line, so each set has its own markers:
|
|
124
131
|
|
|
125
132
|
```just
|
|
126
133
|
test *args:
|
|
127
|
-
gate-cache --
|
|
134
|
+
gate-cache -- uv run pytest {{ args }}
|
|
128
135
|
```
|
|
129
136
|
|
|
130
|
-
`just test` runs under the name `
|
|
137
|
+
`just test` runs under the name `uv run pytest`, and `just test -k unit` runs under the name `uv run pytest -k unit`. A pass of the unit tests never skips the full suite.
|
|
138
|
+
|
|
139
|
+
If the recipe gives `--name`, the arguments are not part of the name. Put them in the name, or a pass of `just test -k unit` also skips `just test`.
|
|
131
140
|
|
|
132
141
|
### Inputs that git does not track
|
|
133
142
|
|
|
@@ -143,7 +152,7 @@ integration:
|
|
|
143
152
|
gate-cache --name "integration-$(git hash-object .env)" -- uv run pytest tests/integration
|
|
144
153
|
```
|
|
145
154
|
|
|
146
|
-
`git hash-object` prints a hash of the file, so the name does not hold the secrets in `.env`. Do not put a secret value in a name, because the marker and `gate-cache status` record the name as plain text.
|
|
155
|
+
`git hash-object` prints a hash of the file, so the name does not hold the secrets in `.env`. Do not put a secret value in a name, because the marker and `gate-cache status` record the name as plain text. Without `--name`, the command line is the name, so give `--name` to a command that takes a secret as an argument.
|
|
147
156
|
|
|
148
157
|
The shell of the recipe expands `$(...)` before gate-cache starts. A hook `entry` or `args` list does not go through a shell, so put the command in a recipe and call the recipe from the hook. To look up the gate with `gate-cache status --name`, give the expanded name, such as `test-uv0.13.0`.
|
|
149
158
|
|
|
@@ -151,10 +160,10 @@ The shell of the recipe expands `$(...)` before gate-cache starts. A hook `entry
|
|
|
151
160
|
|
|
152
161
|
```make
|
|
153
162
|
test:
|
|
154
|
-
gate-cache --
|
|
163
|
+
gate-cache -- uv run pytest
|
|
155
164
|
|
|
156
165
|
lint:
|
|
157
|
-
gate-cache --
|
|
166
|
+
gate-cache -- uv run ruff check .
|
|
158
167
|
```
|
|
159
168
|
|
|
160
169
|
### A hook that calls the recipe
|
|
@@ -200,17 +209,17 @@ GATE_CACHE_DISABLE=1 just test
|
|
|
200
209
|
```console
|
|
201
210
|
$ gate-cache status
|
|
202
211
|
tree: aaff74984cccd156a469afa7d9ab10e4777beb24
|
|
203
|
-
passed: pytest
|
|
204
|
-
missed: ruff (exit code 1)
|
|
212
|
+
passed: uv run pytest
|
|
213
|
+
missed: uv run ruff check . (exit code 1)
|
|
205
214
|
```
|
|
206
215
|
|
|
207
216
|
A name is missed when its command failed, or when it exited 0 but changed the files. A later pass of the same name removes it from the `missed` line.
|
|
208
217
|
|
|
209
|
-
With `--name`, it reports on one name:
|
|
218
|
+
With `--name`, it reports on one name. For a gate that ran without `--name`, give the command line as the name:
|
|
210
219
|
|
|
211
220
|
```console
|
|
212
|
-
$ gate-cache status --name ruff
|
|
213
|
-
name: ruff
|
|
221
|
+
$ gate-cache status --name "uv run ruff check ."
|
|
222
|
+
name: uv run ruff check .
|
|
214
223
|
tree: aaff74984cccd156a469afa7d9ab10e4777beb24
|
|
215
224
|
marker: /path/to/repo/.git/gate-cache/7d94ca2f57ec0a77f60d328fb405d339fb0c2a645991b97c042ac54403104e9e
|
|
216
225
|
result: miss
|
|
@@ -229,9 +238,9 @@ $ gate-cache status --json
|
|
|
229
238
|
"enabled": true,
|
|
230
239
|
"tree": "aaff74984cccd156a469afa7d9ab10e4777beb24",
|
|
231
240
|
"reason": null,
|
|
232
|
-
"passed": ["pytest"],
|
|
241
|
+
"passed": ["uv run pytest"],
|
|
233
242
|
"missed": [
|
|
234
|
-
{"name": "ruff", "exit_code": 1, "reason": "exit code 1", "timestamp": "2026-10-12T12:31:55Z"}
|
|
243
|
+
{"name": "uv run ruff check .", "exit_code": 1, "reason": "exit code 1", "timestamp": "2026-10-12T12:31:55Z"}
|
|
235
244
|
]
|
|
236
245
|
}
|
|
237
246
|
```
|
|
@@ -4,14 +4,14 @@ Skip a command that already passed on the same code.
|
|
|
4
4
|
|
|
5
5
|
A lint or test command often runs many times on code that did not change. It runs when you finish the work, in a pre-commit hook, after a rebase that changed nothing, and in a pre-push hook. When an agent works in the repository, it runs the same command again. If the command takes minutes, the repeat runs take most of the time between finished code and a pushed branch.
|
|
6
6
|
|
|
7
|
-
gate-cache remembers which commands passed on which code. It wraps a command and
|
|
7
|
+
gate-cache remembers which commands passed on which code. It wraps a command, and the command line is the name of the gate. When the command passes, gate-cache writes a marker for that name and for the exact content of the files in the working copy that git does not ignore. The next time the same name runs on the same content, gate-cache finds the marker, skips the command, and exits 0 at once.
|
|
8
8
|
|
|
9
9
|
```console
|
|
10
|
-
$ gate-cache --
|
|
10
|
+
$ gate-cache -- uv run pytest
|
|
11
11
|
============================= 412 passed in 94.20s =============================
|
|
12
12
|
|
|
13
|
-
$ gate-cache --
|
|
14
|
-
gate-cache: pytest already passed on tree aaff74984ccc, skipping
|
|
13
|
+
$ gate-cache -- uv run pytest
|
|
14
|
+
gate-cache: uv run pytest already passed on tree aaff74984ccc, skipping
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
A change to any file that git does not ignore changes the content, so no marker matches and the command runs again. A CI runner that starts from a fresh clone has no markers, so it runs every command.
|
|
@@ -28,14 +28,22 @@ The prek and pre-commit hooks install gate-cache themselves, so the hooks work w
|
|
|
28
28
|
|
|
29
29
|
## Run a command through gate-cache
|
|
30
30
|
|
|
31
|
-
The
|
|
31
|
+
The command goes after `--`:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
gate-cache -- uv run pytest
|
|
35
|
+
gate-cache -- uv run ruff check .
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The command and its arguments are the name of the gate. Each name has its own markers, so a pass of `uv run pytest -k unit` never skips `uv run pytest`.
|
|
39
|
+
|
|
40
|
+
To choose the name yourself, put `--name` before `--`:
|
|
32
41
|
|
|
33
42
|
```sh
|
|
34
43
|
gate-cache --name pytest -- uv run pytest
|
|
35
|
-
gate-cache --name ruff -- uv run ruff check .
|
|
36
44
|
```
|
|
37
45
|
|
|
38
|
-
|
|
46
|
+
With `--name`, the name alone identifies the gate, and the arguments of the command are not part of it. Give a name when two different command lines do the same check and must share markers, or when a gate depends on [inputs that git does not track](#inputs-that-git-does-not-track).
|
|
39
47
|
|
|
40
48
|
gate-cache exits with the exit code of the command, or with 0 when it skips the command. It writes a marker only when the command passes, so a command that failed runs again the next time.
|
|
41
49
|
|
|
@@ -52,18 +60,18 @@ repos:
|
|
|
52
60
|
hooks:
|
|
53
61
|
- id: gate-cache
|
|
54
62
|
name: pytest
|
|
55
|
-
args: [
|
|
63
|
+
args: [--, uv, run, pytest]
|
|
56
64
|
files: ^(src/|tests/)
|
|
57
65
|
- id: gate-cache
|
|
58
66
|
name: ruff
|
|
59
|
-
args: [
|
|
67
|
+
args: [--, uv, run, ruff, check, .]
|
|
60
68
|
```
|
|
61
69
|
|
|
62
70
|
The hook runs at commit time and at push time. At commit time, gate-cache matches the content that you commit.
|
|
63
71
|
|
|
64
72
|
The hook runs in its own virtual environment, and the wrapped command inherits that environment. A command started with `uv run`, or a project command such as `just test`, runs in the project environment. `uv run` prints a warning that `VIRTUAL_ENV` does not match the project. The warning has no effect on the run.
|
|
65
73
|
|
|
66
|
-
Hooks, agents, and your terminal all read and write the same markers. When one of them runs `gate-cache --
|
|
74
|
+
Hooks, agents, and your terminal all read and write the same markers. When one of them runs `gate-cache -- uv run pytest` and it passes, the others skip `uv run pytest` on the same content.
|
|
67
75
|
|
|
68
76
|
## Examples
|
|
69
77
|
|
|
@@ -73,24 +81,26 @@ These examples show gate-cache inside the task runner of a project. When a recip
|
|
|
73
81
|
|
|
74
82
|
```just
|
|
75
83
|
test:
|
|
76
|
-
gate-cache --
|
|
84
|
+
gate-cache -- uv run pytest
|
|
77
85
|
|
|
78
86
|
lint:
|
|
79
|
-
gate-cache --
|
|
87
|
+
gate-cache -- uv run ruff check .
|
|
80
88
|
```
|
|
81
89
|
|
|
82
90
|
After `just test` passes, the next `just test` on the same content skips pytest.
|
|
83
91
|
|
|
84
92
|
### Recipes with arguments
|
|
85
93
|
|
|
86
|
-
|
|
94
|
+
A recipe that passes its arguments to the command needs no `--name`. Each set of arguments gives a different command line, so each set has its own markers:
|
|
87
95
|
|
|
88
96
|
```just
|
|
89
97
|
test *args:
|
|
90
|
-
gate-cache --
|
|
98
|
+
gate-cache -- uv run pytest {{ args }}
|
|
91
99
|
```
|
|
92
100
|
|
|
93
|
-
`just test` runs under the name `
|
|
101
|
+
`just test` runs under the name `uv run pytest`, and `just test -k unit` runs under the name `uv run pytest -k unit`. A pass of the unit tests never skips the full suite.
|
|
102
|
+
|
|
103
|
+
If the recipe gives `--name`, the arguments are not part of the name. Put them in the name, or a pass of `just test -k unit` also skips `just test`.
|
|
94
104
|
|
|
95
105
|
### Inputs that git does not track
|
|
96
106
|
|
|
@@ -106,7 +116,7 @@ integration:
|
|
|
106
116
|
gate-cache --name "integration-$(git hash-object .env)" -- uv run pytest tests/integration
|
|
107
117
|
```
|
|
108
118
|
|
|
109
|
-
`git hash-object` prints a hash of the file, so the name does not hold the secrets in `.env`. Do not put a secret value in a name, because the marker and `gate-cache status` record the name as plain text.
|
|
119
|
+
`git hash-object` prints a hash of the file, so the name does not hold the secrets in `.env`. Do not put a secret value in a name, because the marker and `gate-cache status` record the name as plain text. Without `--name`, the command line is the name, so give `--name` to a command that takes a secret as an argument.
|
|
110
120
|
|
|
111
121
|
The shell of the recipe expands `$(...)` before gate-cache starts. A hook `entry` or `args` list does not go through a shell, so put the command in a recipe and call the recipe from the hook. To look up the gate with `gate-cache status --name`, give the expanded name, such as `test-uv0.13.0`.
|
|
112
122
|
|
|
@@ -114,10 +124,10 @@ The shell of the recipe expands `$(...)` before gate-cache starts. A hook `entry
|
|
|
114
124
|
|
|
115
125
|
```make
|
|
116
126
|
test:
|
|
117
|
-
gate-cache --
|
|
127
|
+
gate-cache -- uv run pytest
|
|
118
128
|
|
|
119
129
|
lint:
|
|
120
|
-
gate-cache --
|
|
130
|
+
gate-cache -- uv run ruff check .
|
|
121
131
|
```
|
|
122
132
|
|
|
123
133
|
### A hook that calls the recipe
|
|
@@ -163,17 +173,17 @@ GATE_CACHE_DISABLE=1 just test
|
|
|
163
173
|
```console
|
|
164
174
|
$ gate-cache status
|
|
165
175
|
tree: aaff74984cccd156a469afa7d9ab10e4777beb24
|
|
166
|
-
passed: pytest
|
|
167
|
-
missed: ruff (exit code 1)
|
|
176
|
+
passed: uv run pytest
|
|
177
|
+
missed: uv run ruff check . (exit code 1)
|
|
168
178
|
```
|
|
169
179
|
|
|
170
180
|
A name is missed when its command failed, or when it exited 0 but changed the files. A later pass of the same name removes it from the `missed` line.
|
|
171
181
|
|
|
172
|
-
With `--name`, it reports on one name:
|
|
182
|
+
With `--name`, it reports on one name. For a gate that ran without `--name`, give the command line as the name:
|
|
173
183
|
|
|
174
184
|
```console
|
|
175
|
-
$ gate-cache status --name ruff
|
|
176
|
-
name: ruff
|
|
185
|
+
$ gate-cache status --name "uv run ruff check ."
|
|
186
|
+
name: uv run ruff check .
|
|
177
187
|
tree: aaff74984cccd156a469afa7d9ab10e4777beb24
|
|
178
188
|
marker: /path/to/repo/.git/gate-cache/7d94ca2f57ec0a77f60d328fb405d339fb0c2a645991b97c042ac54403104e9e
|
|
179
189
|
result: miss
|
|
@@ -192,9 +202,9 @@ $ gate-cache status --json
|
|
|
192
202
|
"enabled": true,
|
|
193
203
|
"tree": "aaff74984cccd156a469afa7d9ab10e4777beb24",
|
|
194
204
|
"reason": null,
|
|
195
|
-
"passed": ["pytest"],
|
|
205
|
+
"passed": ["uv run pytest"],
|
|
196
206
|
"missed": [
|
|
197
|
-
{"name": "ruff", "exit_code": 1, "reason": "exit code 1", "timestamp": "2026-10-12T12:31:55Z"}
|
|
207
|
+
{"name": "uv run ruff check .", "exit_code": 1, "reason": "exit code 1", "timestamp": "2026-10-12T12:31:55Z"}
|
|
198
208
|
]
|
|
199
209
|
}
|
|
200
210
|
```
|
|
@@ -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,169 +0,0 @@
|
|
|
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
|
|
21
|
-
runs the command, records a pass keyed on the git tree of the working copy
|
|
22
|
-
(tracked and untracked, non-ignored files), and skips the command next time the
|
|
23
|
-
tree is identical. A pass on uncommitted work therefore skips when the same
|
|
24
|
-
content is committed.
|
|
25
|
-
|
|
26
|
-
Both --name and -- are required, and only --name may come before --. Everything
|
|
27
|
-
after -- 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 the named gate, or any gate without --name, passed on the
|
|
33
|
-
current tree. 1 otherwise. --json prints one JSON object instead.
|
|
34
|
-
clear 0 on success, 1 on failure.
|
|
35
|
-
|
|
36
|
-
GATE_CACHE_DISABLE and CI are on when the value is not empty, 0, or false (any
|
|
37
|
-
case). If GATE_CACHE_DISABLE is on, the command always runs without the cache.
|
|
38
|
-
If it is 0 or false, caching stays on. If it is empty or not set, CI decides the
|
|
39
|
-
same way.
|
|
40
|
-
Set GATE_CACHE_MAX_AGE_DAYS to a positive whole number to change how many days a
|
|
41
|
-
recorded pass lasts (default 30).
|
|
42
|
-
|
|
43
|
-
Only the name and the file content are keyed. To also key on recipe arguments,
|
|
44
|
-
an ignored file, an environment variable, or a tool version, put its value in
|
|
45
|
-
--name. A changed value gives a new name, so the command runs again. Never put a
|
|
46
|
-
secret in a name, because markers and status store it as plain text. Never wrap
|
|
47
|
-
checks that depend on the commit message or the branch name.
|
|
48
|
-
|
|
49
|
-
Typical use:
|
|
50
|
-
gate-cache --name pytest -- uv run pytest -x
|
|
51
|
-
gate-cache --name "pytest -k unit" -- uv run pytest -k unit
|
|
52
|
-
gate-cache --name "test-$(git hash-object .env)" -- uv run pytest
|
|
53
|
-
"""
|
|
54
|
-
|
|
55
|
-
app = App(
|
|
56
|
-
name="gate-cache",
|
|
57
|
-
version=__version__,
|
|
58
|
-
help=_HELP,
|
|
59
|
-
help_format="plaintext",
|
|
60
|
-
usage=(
|
|
61
|
-
"Usage: gate-cache --name NAME -- COMMAND [ARGS...]\n"
|
|
62
|
-
" gate-cache status [--name NAME] [--json]\n"
|
|
63
|
-
" gate-cache clear"
|
|
64
|
-
),
|
|
65
|
-
result_action="return_int_as_exit_code_else_zero",
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
@app.default
|
|
70
|
-
def run(
|
|
71
|
-
command: Annotated[
|
|
72
|
-
list[str], Parameter(allow_leading_hyphen=True, negative_iterable="", show_default=False)
|
|
73
|
-
],
|
|
74
|
-
/,
|
|
75
|
-
*,
|
|
76
|
-
name: str,
|
|
77
|
-
) -> int:
|
|
78
|
-
"""Run COMMAND unless it already passed on the current git tree.
|
|
79
|
-
|
|
80
|
-
On a skip, prints one line to stderr and exits 0 without running anything. Otherwise
|
|
81
|
-
runs the command and exits with its exit code. The command runs without caching
|
|
82
|
-
(and nothing is recorded) when GATE_CACHE_DISABLE or CI turns caching off, the tree
|
|
83
|
-
is unknown (for example an unmerged index), or this is not a git repository. A pass is recorded only
|
|
84
|
-
when the command exits 0 and the run leaves the tree unchanged.
|
|
85
|
-
|
|
86
|
-
Parameters
|
|
87
|
-
----------
|
|
88
|
-
command
|
|
89
|
-
The command and its arguments, given after `--`. Example: -- uv run pytest -x
|
|
90
|
-
name
|
|
91
|
-
Gate name. Records match only when the name and the tree both match.
|
|
92
|
-
Include arguments or other inputs that the tree does not cover.
|
|
93
|
-
"""
|
|
94
|
-
if not command:
|
|
95
|
-
say("no command given after --")
|
|
96
|
-
return _USAGE_ERROR
|
|
97
|
-
return run_gate(name, command)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
@app.command
|
|
101
|
-
def status(
|
|
102
|
-
*,
|
|
103
|
-
name: str | None = None,
|
|
104
|
-
as_json: Annotated[bool, Parameter(name="--json", negative="")] = False,
|
|
105
|
-
) -> int:
|
|
106
|
-
"""Report whether gates would skip, without running anything.
|
|
107
|
-
|
|
108
|
-
With --name, print the tree hash (or why it is unknown), the marker path,
|
|
109
|
-
hit or miss, and how the last run on this tree failed when it did. Exit 0 on a
|
|
110
|
-
hit, 1 otherwise.
|
|
111
|
-
|
|
112
|
-
Without --name, list the gates that passed on the current tree and the gates
|
|
113
|
-
that ran on it without passing. Exit 0 when at least one gate passed, 1 otherwise.
|
|
114
|
-
|
|
115
|
-
Parameters
|
|
116
|
-
----------
|
|
117
|
-
name
|
|
118
|
-
Gate name to look up. Omit it to list every gate that ran on this tree.
|
|
119
|
-
as_json
|
|
120
|
-
Print one JSON object to stdout instead of text. Exit codes are unchanged.
|
|
121
|
-
"""
|
|
122
|
-
return gate_status(name, as_json=as_json)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
@app.command
|
|
126
|
-
def clear() -> int:
|
|
127
|
-
"""Delete every recorded pass for this repository.
|
|
128
|
-
|
|
129
|
-
Use after something outside git changes, such as a tool upgrade. Exits 1 outside a
|
|
130
|
-
git repository.
|
|
131
|
-
"""
|
|
132
|
-
return clear_cache()
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def _usage_error(tokens: list[str]) -> str | None:
|
|
136
|
-
"""Return the usage problem with a run invocation, or None when it is well formed.
|
|
137
|
-
|
|
138
|
-
Only `--name X` or `--name=X` may precede `--`, so a stray option such as `--version`
|
|
139
|
-
or the first word of the command can never be mistaken for one of ours.
|
|
140
|
-
"""
|
|
141
|
-
if not tokens or tokens[0] in _SUBCOMMANDS | _INFO_FLAGS:
|
|
142
|
-
return None
|
|
143
|
-
index = 0
|
|
144
|
-
while index < len(tokens):
|
|
145
|
-
arg = tokens[index]
|
|
146
|
-
if arg == "--":
|
|
147
|
-
return None
|
|
148
|
-
if arg == "--name":
|
|
149
|
-
if index + 1 == len(tokens) or tokens[index + 1] == "--":
|
|
150
|
-
return "--name needs a value, e.g. gate-cache --name pytest -- uv run pytest"
|
|
151
|
-
index += 2
|
|
152
|
-
elif arg.startswith("--name="):
|
|
153
|
-
index += 1
|
|
154
|
-
else:
|
|
155
|
-
break
|
|
156
|
-
return "put -- before the command, e.g. gate-cache --name pytest -- uv run pytest"
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def main() -> None:
|
|
160
|
-
"""Run the gate-cache command line interface and exit with its code."""
|
|
161
|
-
tokens = sys.argv[1:]
|
|
162
|
-
if problem := _usage_error(tokens):
|
|
163
|
-
say(problem)
|
|
164
|
-
sys.exit(_USAGE_ERROR)
|
|
165
|
-
try:
|
|
166
|
-
code = app(tokens)
|
|
167
|
-
except KeyboardInterrupt:
|
|
168
|
-
sys.exit(_INTERRUPTED)
|
|
169
|
-
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
|