gate-cache 0.3.0__tar.gz → 0.4.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.3.0 → gate_cache-0.4.0}/PKG-INFO +30 -16
- {gate_cache-0.3.0 → gate_cache-0.4.0}/README.md +28 -15
- {gate_cache-0.3.0 → gate_cache-0.4.0}/pyproject.toml +2 -1
- {gate_cache-0.3.0 → gate_cache-0.4.0}/pyproject.toml.orig +6 -2
- {gate_cache-0.3.0 → gate_cache-0.4.0}/src/gate_cache/__init__.py +1 -1
- {gate_cache-0.3.0 → gate_cache-0.4.0}/src/gate_cache/cli.py +16 -10
- {gate_cache-0.3.0 → gate_cache-0.4.0}/src/gate_cache/gate.py +23 -21
- {gate_cache-0.3.0 → gate_cache-0.4.0}/src/gate_cache/markers.py +42 -10
- gate_cache-0.4.0/src/gate_cache/scratch.py +67 -0
- gate_cache-0.4.0/src/gate_cache/snapshot.py +366 -0
- gate_cache-0.3.0/src/gate_cache/snapshot.py +0 -148
- {gate_cache-0.3.0 → gate_cache-0.4.0}/LICENSE +0 -0
- {gate_cache-0.3.0 → gate_cache-0.4.0}/src/gate_cache/console.py +0 -0
- {gate_cache-0.3.0 → gate_cache-0.4.0}/src/gate_cache/runner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: gate-cache
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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>
|
|
@@ -31,6 +31,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
31
31
|
Classifier: Programming Language :: Python :: 3.14
|
|
32
32
|
Requires-Dist: cyclopts>=5.2.0,<6.0.0
|
|
33
33
|
Requires-Dist: nclutils>=3.4.4,<4.0.0
|
|
34
|
+
Requires-Dist: platformdirs>=4.13.0,<5.0.0
|
|
34
35
|
Requires-Python: >=3.11
|
|
35
36
|
Description-Content-Type: text/markdown
|
|
36
37
|
|
|
@@ -40,7 +41,7 @@ Skip a command that already passed on the same code.
|
|
|
40
41
|
|
|
41
42
|
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.
|
|
42
43
|
|
|
43
|
-
gate-cache remembers which commands passed on which code. It wraps a command and gives it a name. When the command passes, gate-cache writes a marker for that name and for the exact content of the
|
|
44
|
+
gate-cache remembers which commands passed on which code. It wraps a command and gives it a name. 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.
|
|
44
45
|
|
|
45
46
|
```console
|
|
46
47
|
$ gate-cache --name pytest -- uv run pytest
|
|
@@ -50,7 +51,7 @@ $ gate-cache --name pytest -- uv run pytest
|
|
|
50
51
|
gate-cache: pytest already passed on tree aaff74984ccc, skipping
|
|
51
52
|
```
|
|
52
53
|
|
|
53
|
-
A change to
|
|
54
|
+
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.
|
|
54
55
|
|
|
55
56
|
## Install
|
|
56
57
|
|
|
@@ -75,7 +76,7 @@ Each name has its own markers. A command run under one name never skips because
|
|
|
75
76
|
|
|
76
77
|
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.
|
|
77
78
|
|
|
78
|
-
|
|
79
|
+
The tree includes uncommitted edits and untracked files that git does not ignore. A gate that passes on uncommitted work therefore skips in the commit hook when you commit the same content. gate-cache computes the tree from a private copy of the index, so it never stages files or changes your index.
|
|
79
80
|
|
|
80
81
|
## Run it as a hook
|
|
81
82
|
|
|
@@ -119,20 +120,33 @@ After `just test` passes, the next `just test` on the same content skips pytest.
|
|
|
119
120
|
|
|
120
121
|
### Recipes with arguments
|
|
121
122
|
|
|
122
|
-
gate-cache identifies a command by its name alone, not by its arguments. If `just test -k unit` and `just test` ran under the same name, a pass of the unit tests would also skip the full suite.
|
|
123
|
-
|
|
124
|
-
This recipe caches `just test` and runs `just test -k unit` without the cache:
|
|
123
|
+
gate-cache identifies a command by its name alone, not by its arguments. If `just test -k unit` and `just test` ran under the same name, a pass of the unit tests would also skip the full suite. To keep them apart, put the arguments in the name:
|
|
125
124
|
|
|
126
125
|
```just
|
|
127
126
|
test *args:
|
|
128
|
-
|
|
129
|
-
set -euo pipefail
|
|
130
|
-
if [[ -z "{{ args }}" ]]; then
|
|
131
|
-
exec gate-cache --name test -- uv run pytest
|
|
132
|
-
fi
|
|
133
|
-
exec uv run pytest {{ args }}
|
|
127
|
+
gate-cache --name "{{ trim('test ' + args) }}" -- uv run pytest {{ args }}
|
|
134
128
|
```
|
|
135
129
|
|
|
130
|
+
`just test` runs under the name `test`, and `just test -k unit` runs under the name `test -k unit`. Each set of arguments has its own markers. `trim` removes the trailing space when there are no arguments, so `gate-cache status --name test` finds the bare form.
|
|
131
|
+
|
|
132
|
+
### Inputs that git does not track
|
|
133
|
+
|
|
134
|
+
A marker does not cover ignored files, environment variables, or tool versions. To make a gate depend on one of these inputs, put its value in the name. When the value changes, the name changes, so no marker matches and the command runs again:
|
|
135
|
+
|
|
136
|
+
```just
|
|
137
|
+
# Run again when the uv version changes
|
|
138
|
+
test:
|
|
139
|
+
gate-cache --name "test-uv$(uv --version | cut -d' ' -f2)" -- uv run pytest
|
|
140
|
+
|
|
141
|
+
# Run again when the ignored .env file changes
|
|
142
|
+
integration:
|
|
143
|
+
gate-cache --name "integration-$(git hash-object .env)" -- uv run pytest tests/integration
|
|
144
|
+
```
|
|
145
|
+
|
|
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.
|
|
147
|
+
|
|
148
|
+
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
|
+
|
|
136
150
|
### Makefile
|
|
137
151
|
|
|
138
152
|
```make
|
|
@@ -205,7 +219,7 @@ $ gate-cache status --name ruff
|
|
|
205
219
|
|
|
206
220
|
The `last run` line shows only when the name ran on this content without a pass.
|
|
207
221
|
|
|
208
|
-
`status` runs nothing. With `--name`, it exits 0 when a marker matches and 1 when none does. Without `--name`, it exits 0 when at least one name passed. When gate-cache cannot match the content, for example
|
|
222
|
+
`status` runs nothing. With `--name`, it exits 0 when a marker matches and 1 when none does. Without `--name`, it exits 0 when at least one name passed. When gate-cache cannot match the content, for example during a merge conflict, the `tree` line gives the reason.
|
|
209
223
|
|
|
210
224
|
With `--json`, `status` writes one JSON object to stdout instead of text. The exit codes do not change:
|
|
211
225
|
|
|
@@ -222,11 +236,11 @@ $ gate-cache status --json
|
|
|
222
236
|
}
|
|
223
237
|
```
|
|
224
238
|
|
|
225
|
-
`reason` tells why gate-cache cannot match the content, and is `null` when it can. When caching is off, `enabled` is `false
|
|
239
|
+
`reason` tells why gate-cache cannot match the content, and is `null` when it can. When caching is off, `enabled` is `false`, `reason` names the variable, and the other keys are empty or `null`. With `--name`, the object has the `name`, `marker`, `result`, and `last_run` keys in place of `passed` and `missed`.
|
|
226
240
|
|
|
227
241
|
## Start fresh
|
|
228
242
|
|
|
229
|
-
A marker records only the content of the
|
|
243
|
+
A marker records only the content of the files that git does not ignore. A new tool version, a changed environment variable, or a change to a service does not change that content, so the old markers still match. To make a gate run again when one of these inputs changes, put the input in the name, as [Inputs that git does not track](#inputs-that-git-does-not-track) shows.
|
|
230
244
|
|
|
231
245
|
`gate-cache clear` deletes every marker for the repository, and the records of missed runs:
|
|
232
246
|
|
|
@@ -4,7 +4,7 @@ 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 gives it a name. When the command passes, gate-cache writes a marker for that name and for the exact content of the
|
|
7
|
+
gate-cache remembers which commands passed on which code. It wraps a command and gives it a name. 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
10
|
$ gate-cache --name pytest -- uv run pytest
|
|
@@ -14,7 +14,7 @@ $ gate-cache --name pytest -- uv run pytest
|
|
|
14
14
|
gate-cache: pytest already passed on tree aaff74984ccc, skipping
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
A change to
|
|
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.
|
|
18
18
|
|
|
19
19
|
## Install
|
|
20
20
|
|
|
@@ -39,7 +39,7 @@ Each name has its own markers. A command run under one name never skips because
|
|
|
39
39
|
|
|
40
40
|
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
41
|
|
|
42
|
-
|
|
42
|
+
The tree includes uncommitted edits and untracked files that git does not ignore. A gate that passes on uncommitted work therefore skips in the commit hook when you commit the same content. gate-cache computes the tree from a private copy of the index, so it never stages files or changes your index.
|
|
43
43
|
|
|
44
44
|
## Run it as a hook
|
|
45
45
|
|
|
@@ -83,20 +83,33 @@ After `just test` passes, the next `just test` on the same content skips pytest.
|
|
|
83
83
|
|
|
84
84
|
### Recipes with arguments
|
|
85
85
|
|
|
86
|
-
gate-cache identifies a command by its name alone, not by its arguments. If `just test -k unit` and `just test` ran under the same name, a pass of the unit tests would also skip the full suite.
|
|
87
|
-
|
|
88
|
-
This recipe caches `just test` and runs `just test -k unit` without the cache:
|
|
86
|
+
gate-cache identifies a command by its name alone, not by its arguments. If `just test -k unit` and `just test` ran under the same name, a pass of the unit tests would also skip the full suite. To keep them apart, put the arguments in the name:
|
|
89
87
|
|
|
90
88
|
```just
|
|
91
89
|
test *args:
|
|
92
|
-
|
|
93
|
-
set -euo pipefail
|
|
94
|
-
if [[ -z "{{ args }}" ]]; then
|
|
95
|
-
exec gate-cache --name test -- uv run pytest
|
|
96
|
-
fi
|
|
97
|
-
exec uv run pytest {{ args }}
|
|
90
|
+
gate-cache --name "{{ trim('test ' + args) }}" -- uv run pytest {{ args }}
|
|
98
91
|
```
|
|
99
92
|
|
|
93
|
+
`just test` runs under the name `test`, and `just test -k unit` runs under the name `test -k unit`. Each set of arguments has its own markers. `trim` removes the trailing space when there are no arguments, so `gate-cache status --name test` finds the bare form.
|
|
94
|
+
|
|
95
|
+
### Inputs that git does not track
|
|
96
|
+
|
|
97
|
+
A marker does not cover ignored files, environment variables, or tool versions. To make a gate depend on one of these inputs, put its value in the name. When the value changes, the name changes, so no marker matches and the command runs again:
|
|
98
|
+
|
|
99
|
+
```just
|
|
100
|
+
# Run again when the uv version changes
|
|
101
|
+
test:
|
|
102
|
+
gate-cache --name "test-uv$(uv --version | cut -d' ' -f2)" -- uv run pytest
|
|
103
|
+
|
|
104
|
+
# Run again when the ignored .env file changes
|
|
105
|
+
integration:
|
|
106
|
+
gate-cache --name "integration-$(git hash-object .env)" -- uv run pytest tests/integration
|
|
107
|
+
```
|
|
108
|
+
|
|
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.
|
|
110
|
+
|
|
111
|
+
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
|
+
|
|
100
113
|
### Makefile
|
|
101
114
|
|
|
102
115
|
```make
|
|
@@ -169,7 +182,7 @@ $ gate-cache status --name ruff
|
|
|
169
182
|
|
|
170
183
|
The `last run` line shows only when the name ran on this content without a pass.
|
|
171
184
|
|
|
172
|
-
`status` runs nothing. With `--name`, it exits 0 when a marker matches and 1 when none does. Without `--name`, it exits 0 when at least one name passed. When gate-cache cannot match the content, for example
|
|
185
|
+
`status` runs nothing. With `--name`, it exits 0 when a marker matches and 1 when none does. Without `--name`, it exits 0 when at least one name passed. When gate-cache cannot match the content, for example during a merge conflict, the `tree` line gives the reason.
|
|
173
186
|
|
|
174
187
|
With `--json`, `status` writes one JSON object to stdout instead of text. The exit codes do not change:
|
|
175
188
|
|
|
@@ -186,11 +199,11 @@ $ gate-cache status --json
|
|
|
186
199
|
}
|
|
187
200
|
```
|
|
188
201
|
|
|
189
|
-
`reason` tells why gate-cache cannot match the content, and is `null` when it can. When caching is off, `enabled` is `false
|
|
202
|
+
`reason` tells why gate-cache cannot match the content, and is `null` when it can. When caching is off, `enabled` is `false`, `reason` names the variable, and the other keys are empty or `null`. With `--name`, the object has the `name`, `marker`, `result`, and `last_run` keys in place of `passed` and `missed`.
|
|
190
203
|
|
|
191
204
|
## Start fresh
|
|
192
205
|
|
|
193
|
-
A marker records only the content of the
|
|
206
|
+
A marker records only the content of the files that git does not ignore. A new tool version, a changed environment variable, or a change to a service does not change that content, so the old markers still match. To make a gate run again when one of these inputs changes, put the input in the name, as [Inputs that git does not track](#inputs-that-git-does-not-track) shows.
|
|
194
207
|
|
|
195
208
|
`gate-cache clear` deletes every marker for the repository, and the records of missed runs:
|
|
196
209
|
|
|
@@ -8,12 +8,13 @@ classifiers = [
|
|
|
8
8
|
dependencies = [
|
|
9
9
|
"cyclopts>=5.2.0,<6.0.0",
|
|
10
10
|
"nclutils>=3.4.4,<4.0.0",
|
|
11
|
+
"platformdirs>=4.13.0,<5.0.0",
|
|
11
12
|
]
|
|
12
13
|
description = "Skip lint and test gates that already passed on the exact same git tree."
|
|
13
14
|
name = "gate-cache"
|
|
14
15
|
readme = "README.md"
|
|
15
16
|
requires-python = ">=3.11"
|
|
16
|
-
version = "0.
|
|
17
|
+
version = "0.4.0"
|
|
17
18
|
|
|
18
19
|
[[project.authors]]
|
|
19
20
|
name = "Nate Landau"
|
|
@@ -6,13 +6,17 @@
|
|
|
6
6
|
"Programming Language :: Python :: 3.13",
|
|
7
7
|
"Programming Language :: Python :: 3.14",
|
|
8
8
|
]
|
|
9
|
-
dependencies = [
|
|
9
|
+
dependencies = [
|
|
10
|
+
"cyclopts>=5.2.0,<6.0.0",
|
|
11
|
+
"nclutils>=3.4.4,<4.0.0",
|
|
12
|
+
"platformdirs>=4.13.0,<5.0.0",
|
|
13
|
+
]
|
|
10
14
|
description = "Skip lint and test gates that already passed on the exact same git tree."
|
|
11
15
|
license = { file = "LICENSE" }
|
|
12
16
|
name = "gate-cache"
|
|
13
17
|
readme = "README.md"
|
|
14
18
|
requires-python = ">=3.11"
|
|
15
|
-
version = "0.
|
|
19
|
+
version = "0.4.0"
|
|
16
20
|
|
|
17
21
|
[project.scripts]
|
|
18
22
|
gate-cache = "gate_cache.cli:main"
|
|
@@ -17,14 +17,14 @@ _SUBCOMMANDS = {"status", "clear"}
|
|
|
17
17
|
_INFO_FLAGS = {"-h", "--help", "--version"}
|
|
18
18
|
|
|
19
19
|
_HELP = """\
|
|
20
|
-
Skip a lint or test command that already passed on the same git tree. gate-cache
|
|
21
|
-
the command, records a pass keyed on the git tree of the
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
is
|
|
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
25
|
|
|
26
|
-
Both --name and -- are required, and only --name may come before --. Everything
|
|
27
|
-
-- is the command, verbatim.
|
|
26
|
+
Both --name and -- are required, and only --name may come before --. Everything
|
|
27
|
+
after -- is the command, verbatim.
|
|
28
28
|
|
|
29
29
|
Exit codes:
|
|
30
30
|
run the command's own exit code, or 0 when a recorded pass lets it skip.
|
|
@@ -40,11 +40,16 @@ same way.
|
|
|
40
40
|
Set GATE_CACHE_MAX_AGE_DAYS to a positive whole number to change how many days a
|
|
41
41
|
recorded pass lasts (default 30).
|
|
42
42
|
|
|
43
|
-
Only file content
|
|
44
|
-
|
|
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.
|
|
45
48
|
|
|
46
49
|
Typical use:
|
|
47
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
|
|
48
53
|
"""
|
|
49
54
|
|
|
50
55
|
app = App(
|
|
@@ -75,7 +80,7 @@ def run(
|
|
|
75
80
|
On a skip, prints one line to stderr and exits 0 without running anything. Otherwise
|
|
76
81
|
runs the command and exits with its exit code. The command runs without caching
|
|
77
82
|
(and nothing is recorded) when GATE_CACHE_DISABLE or CI turns caching off, the tree
|
|
78
|
-
|
|
83
|
+
is unknown (for example an unmerged index), or this is not a git repository. A pass is recorded only
|
|
79
84
|
when the command exits 0 and the run leaves the tree unchanged.
|
|
80
85
|
|
|
81
86
|
Parameters
|
|
@@ -84,6 +89,7 @@ def run(
|
|
|
84
89
|
The command and its arguments, given after `--`. Example: -- uv run pytest -x
|
|
85
90
|
name
|
|
86
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.
|
|
87
93
|
"""
|
|
88
94
|
if not command:
|
|
89
95
|
say("no command given after --")
|
|
@@ -12,8 +12,10 @@ from nclutils import pp
|
|
|
12
12
|
from gate_cache.console import say
|
|
13
13
|
from gate_cache.markers import (
|
|
14
14
|
clear_markers,
|
|
15
|
+
marker_exists,
|
|
15
16
|
marker_path,
|
|
16
17
|
miss_path,
|
|
18
|
+
miss_record,
|
|
17
19
|
missed_records,
|
|
18
20
|
passed_names,
|
|
19
21
|
prune_markers,
|
|
@@ -95,14 +97,6 @@ def marker_max_age_days(environ: Mapping[str, str] | None = None) -> int:
|
|
|
95
97
|
return days
|
|
96
98
|
|
|
97
99
|
|
|
98
|
-
def _marker_exists(path: Path) -> bool:
|
|
99
|
-
"""Return True only when the marker is known to exist."""
|
|
100
|
-
try:
|
|
101
|
-
return path.exists()
|
|
102
|
-
except OSError:
|
|
103
|
-
return False
|
|
104
|
-
|
|
105
|
-
|
|
106
100
|
def _run_uncached(reason: str, command: Sequence[str]) -> int:
|
|
107
101
|
"""Explain why caching is off, then run the command."""
|
|
108
102
|
say(f"{reason}, running without cache")
|
|
@@ -170,7 +164,7 @@ def run_gate(name: str, command: Sequence[str]) -> int:
|
|
|
170
164
|
if common_dir is None:
|
|
171
165
|
return _run_uncached(_NO_COMMON_DIR_REASON, command)
|
|
172
166
|
|
|
173
|
-
if
|
|
167
|
+
if marker_exists(marker_path(common_dir, tree, name)):
|
|
174
168
|
say(f"{name} already passed on tree {tree[:12]}, skipping")
|
|
175
169
|
return 0
|
|
176
170
|
|
|
@@ -210,7 +204,12 @@ def gate_status(name: str | None = None, *, as_json: bool = False) -> int:
|
|
|
210
204
|
reason = disabled_reason()
|
|
211
205
|
if reason is not None:
|
|
212
206
|
if as_json:
|
|
213
|
-
|
|
207
|
+
empty = (
|
|
208
|
+
{"name": name, "marker": None, "result": "miss", "last_run": None}
|
|
209
|
+
if name is not None
|
|
210
|
+
else {"passed": [], "missed": []}
|
|
211
|
+
)
|
|
212
|
+
_print_json({"enabled": False, "tree": None, "reason": reason, **empty})
|
|
214
213
|
else:
|
|
215
214
|
say(reason)
|
|
216
215
|
return 1
|
|
@@ -249,15 +248,13 @@ def _named_status(name: str, snapshot: Snapshot, common_dir: Path | None, *, as_
|
|
|
249
248
|
"""Print whether one gate would skip on the snapshot's tree."""
|
|
250
249
|
tree = snapshot.tree
|
|
251
250
|
path: Path | None = None
|
|
251
|
+
hit = False
|
|
252
252
|
last_run: dict[str, Any] | None = None
|
|
253
253
|
if tree is not None and common_dir is not None:
|
|
254
254
|
path = marker_path(common_dir, tree, name)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
last_run =
|
|
258
|
-
(_miss_summary(r) for r in missed_records(common_dir, tree) if r["name"] == name),
|
|
259
|
-
None,
|
|
260
|
-
)
|
|
255
|
+
hit = marker_exists(path)
|
|
256
|
+
record = None if hit else miss_record(common_dir, tree, name)
|
|
257
|
+
last_run = _miss_summary(record) if record is not None else None
|
|
261
258
|
reason = _unknown_reason(snapshot, common_dir)
|
|
262
259
|
|
|
263
260
|
if as_json:
|
|
@@ -274,12 +271,15 @@ def _named_status(name: str, snapshot: Snapshot, common_dir: Path | None, *, as_
|
|
|
274
271
|
)
|
|
275
272
|
return 0 if hit else 1
|
|
276
273
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
274
|
+
if path is not None:
|
|
275
|
+
marker = str(path)
|
|
276
|
+
elif tree is not None:
|
|
277
|
+
marker = f"none: {reason}"
|
|
278
|
+
else:
|
|
279
|
+
marker = "none"
|
|
280
280
|
rows = {
|
|
281
281
|
"name": name,
|
|
282
|
-
"tree": tree if tree is not None else f"unknown: {
|
|
282
|
+
"tree": tree if tree is not None else f"unknown: {reason}",
|
|
283
283
|
"marker": marker,
|
|
284
284
|
"result": "hit" if hit else "miss",
|
|
285
285
|
}
|
|
@@ -325,7 +325,9 @@ def _list_gates(snapshot: Snapshot, common_dir: Path | None, *, as_json: bool) -
|
|
|
325
325
|
if passed:
|
|
326
326
|
rows["passed"] = ", ".join(passed)
|
|
327
327
|
if missed:
|
|
328
|
-
rows["missed"] = ", ".join(
|
|
328
|
+
rows["missed"] = ", ".join(
|
|
329
|
+
f"{m['name']} ({m['reason']})" if m["reason"] else m["name"] for m in missed
|
|
330
|
+
)
|
|
329
331
|
pp.kv(rows)
|
|
330
332
|
return code
|
|
331
333
|
|
|
@@ -162,8 +162,8 @@ def passed_names(common_dir: Path, tree: str) -> list[str]:
|
|
|
162
162
|
def missed_records(common_dir: Path, tree: str) -> list[dict[str, Any]]:
|
|
163
163
|
"""List the gates that ran on a tree without passing, for reporting what would run.
|
|
164
164
|
|
|
165
|
-
A gate
|
|
166
|
-
|
|
165
|
+
A gate whose marker exists on the tree is left out, since a later pass supersedes the
|
|
166
|
+
miss. This holds for markers without JSON content too, because those still skip.
|
|
167
167
|
|
|
168
168
|
Args:
|
|
169
169
|
common_dir: Absolute path to the git common directory.
|
|
@@ -172,15 +172,50 @@ def missed_records(common_dir: Path, tree: str) -> list[dict[str, Any]]:
|
|
|
172
172
|
Returns:
|
|
173
173
|
Each miss record's JSON content, sorted by gate name.
|
|
174
174
|
"""
|
|
175
|
-
passed = set(passed_names(common_dir, tree))
|
|
176
175
|
records = [
|
|
177
176
|
record
|
|
178
177
|
for record in _records(common_dir, tree, suffix=MISS_SUFFIX)
|
|
179
|
-
if record["name"]
|
|
178
|
+
if not marker_exists(marker_path(common_dir, tree, record["name"]))
|
|
180
179
|
]
|
|
181
180
|
return sorted(records, key=lambda record: record["name"])
|
|
182
181
|
|
|
183
182
|
|
|
183
|
+
def miss_record(common_dir: Path, tree: str, name: str) -> dict[str, Any] | None:
|
|
184
|
+
"""Read one gate's miss record on a tree.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
common_dir: Absolute path to the git common directory.
|
|
188
|
+
tree: Git tree hash.
|
|
189
|
+
name: Gate name.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
The record's JSON content, or None when it is missing, unreadable, or does not
|
|
193
|
+
match the tree and name.
|
|
194
|
+
"""
|
|
195
|
+
path = miss_path(common_dir, tree, name)
|
|
196
|
+
data = _read_record(path)
|
|
197
|
+
if data is None or data.get("tree") != tree or data.get("name") != name:
|
|
198
|
+
return None
|
|
199
|
+
return data
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def marker_exists(path: Path) -> bool:
|
|
203
|
+
"""Return True only when the path is known to exist."""
|
|
204
|
+
try:
|
|
205
|
+
return path.exists()
|
|
206
|
+
except OSError:
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _read_record(path: Path) -> dict[str, Any] | None:
|
|
211
|
+
"""Parse a JSON object from a record file, or return None when it is not one."""
|
|
212
|
+
try:
|
|
213
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
214
|
+
except (OSError, UnicodeDecodeError, ValueError):
|
|
215
|
+
return None
|
|
216
|
+
return data if isinstance(data, dict) else None
|
|
217
|
+
|
|
218
|
+
|
|
184
219
|
def _records(common_dir: Path, tree: str, *, suffix: str) -> list[dict[str, Any]]:
|
|
185
220
|
"""Read the records for a tree whose filename is the key of their own name plus suffix."""
|
|
186
221
|
try:
|
|
@@ -189,13 +224,10 @@ def _records(common_dir: Path, tree: str, *, suffix: str) -> list[dict[str, Any]
|
|
|
189
224
|
return []
|
|
190
225
|
records: list[dict[str, Any]] = []
|
|
191
226
|
for entry in entries:
|
|
192
|
-
if entry.name.startswith(TEMP_PREFIX):
|
|
193
|
-
continue
|
|
194
|
-
try:
|
|
195
|
-
data = json.loads(entry.read_text(encoding="utf-8"))
|
|
196
|
-
except (OSError, UnicodeDecodeError, ValueError):
|
|
227
|
+
if entry.name.startswith(TEMP_PREFIX) or not entry.name.endswith(suffix):
|
|
197
228
|
continue
|
|
198
|
-
|
|
229
|
+
data = _read_record(entry)
|
|
230
|
+
if data is None:
|
|
199
231
|
continue
|
|
200
232
|
name = data.get("name")
|
|
201
233
|
if (
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Provide private, short-lived directories for gate-cache's own git probes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
import warnings
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import platformdirs
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Generator
|
|
18
|
+
|
|
19
|
+
SCRATCH_PREFIX = "snap-"
|
|
20
|
+
STALE_AFTER_SECONDS = 3600
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def runtime_area() -> Path:
|
|
24
|
+
"""Return the private directory that holds every scratch directory, creating it if needed.
|
|
25
|
+
|
|
26
|
+
Honors XDG_RUNTIME_DIR and falls back to the platform's per-user runtime location.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The absolute path of the area, with mode 0700.
|
|
30
|
+
"""
|
|
31
|
+
# An unset XDG_RUNTIME_DIR is normal in containers and SSH sessions, so the fallback
|
|
32
|
+
# warning would only add noise to gate output.
|
|
33
|
+
with warnings.catch_warnings():
|
|
34
|
+
warnings.simplefilter("ignore", platformdirs.RuntimeDirWarning)
|
|
35
|
+
area = platformdirs.user_runtime_path("gate-cache", appauthor=False)
|
|
36
|
+
area.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
37
|
+
return area
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def sweep_stale(area: Path, *, now: float | None = None) -> None:
|
|
41
|
+
"""Delete scratch directories that a killed run left behind.
|
|
42
|
+
|
|
43
|
+
Only directories older than STALE_AFTER_SECONDS go, so a parallel run's directory survives.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
area: Directory holding scratch directories.
|
|
47
|
+
now: Current time in seconds since the epoch; defaults to the clock.
|
|
48
|
+
"""
|
|
49
|
+
cutoff = (time.time() if now is None else now) - STALE_AFTER_SECONDS
|
|
50
|
+
with contextlib.suppress(OSError):
|
|
51
|
+
for entry in area.glob(f"{SCRATCH_PREFIX}*"):
|
|
52
|
+
with contextlib.suppress(OSError):
|
|
53
|
+
if entry.stat().st_mtime < cutoff:
|
|
54
|
+
shutil.rmtree(entry, ignore_errors=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@contextmanager
|
|
58
|
+
def scratch_dir() -> Generator[Path]:
|
|
59
|
+
"""Yield a private directory that is deleted when the block exits, however it exits.
|
|
60
|
+
|
|
61
|
+
Yields:
|
|
62
|
+
The path of a new, empty directory inside the runtime area.
|
|
63
|
+
"""
|
|
64
|
+
area = runtime_area()
|
|
65
|
+
sweep_stale(area)
|
|
66
|
+
with tempfile.TemporaryDirectory(prefix=SCRATCH_PREFIX, dir=area) as path:
|
|
67
|
+
yield Path(path)
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Detect the git tree that a gate command is about to test."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from nclutils.git import run_git
|
|
11
|
+
from nclutils.sh import ShellCommandError, ShellCommandNotFoundError
|
|
12
|
+
|
|
13
|
+
from gate_cache import scratch
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Snapshot:
|
|
18
|
+
"""The tree a gate is about to test, or why that tree is unknown.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
tree: Git tree hash describing the content under test, or None when unknown.
|
|
22
|
+
reason: Why the tree is unknown, or None when it is known.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
tree: str | None
|
|
26
|
+
reason: str | None
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def known(self) -> bool:
|
|
30
|
+
"""Return True when a tree hash identifies the content under test."""
|
|
31
|
+
return self.tree is not None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _hook_work_tree(cwd: Path | None) -> Path | None:
|
|
35
|
+
"""Return the work tree that a GIT_DIR without GIT_WORK_TREE belongs to, if derivable.
|
|
36
|
+
|
|
37
|
+
Git hands hooks GIT_DIR alone, and then takes the current directory for the top of the
|
|
38
|
+
work tree. A hook that changes into a subdirectory would see every other file as changed.
|
|
39
|
+
"""
|
|
40
|
+
git_dir = os.environ.get("GIT_DIR")
|
|
41
|
+
if not git_dir or os.environ.get("GIT_WORK_TREE"):
|
|
42
|
+
return None
|
|
43
|
+
if run_git("config", "--get", "core.worktree", cwd=cwd, check=False).stdout.strip():
|
|
44
|
+
return None
|
|
45
|
+
base = Path(cwd) if cwd is not None else Path.cwd()
|
|
46
|
+
path = (base / git_dir).resolve()
|
|
47
|
+
# A linked worktree's git dir holds a `gitdir` file naming `<work tree>/.git`.
|
|
48
|
+
gitdir_file = path / "gitdir"
|
|
49
|
+
if gitdir_file.is_file():
|
|
50
|
+
target = Path(gitdir_file.read_text(encoding="utf-8").strip())
|
|
51
|
+
return (path / target).resolve().parent
|
|
52
|
+
if path.name == ".git":
|
|
53
|
+
return path.parent
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _git_env(cwd: Path | None) -> dict[str, str] | None:
|
|
58
|
+
"""Return an environment for gate-cache's own git probes, or None to inherit as is."""
|
|
59
|
+
try:
|
|
60
|
+
work_tree = _hook_work_tree(cwd)
|
|
61
|
+
except OSError:
|
|
62
|
+
return None
|
|
63
|
+
if work_tree is None:
|
|
64
|
+
return None
|
|
65
|
+
return {**os.environ, "GIT_WORK_TREE": str(work_tree)}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _work_tree_root(cwd: Path | None, env: dict[str, str] | None) -> Path | None:
|
|
69
|
+
"""Return the absolute top of the work tree, or None when there is none."""
|
|
70
|
+
result = run_git("rev-parse", "--show-toplevel", cwd=cwd, env=env, check=False)
|
|
71
|
+
output = result.stdout.strip()
|
|
72
|
+
if result.returncode != 0 or not output or "\n" in output:
|
|
73
|
+
return None
|
|
74
|
+
return Path(output)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_GITLINK_MODE = "160000"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _hidden_entry_reason(root: Path, env: dict[str, str] | None, label: str = "") -> str | None:
|
|
81
|
+
"""Return a reason when an index entry tells git to ignore edits to its file."""
|
|
82
|
+
# `ls-files -v` tags assume-unchanged entries in lowercase and skip-worktree entries `S`.
|
|
83
|
+
# Sparse checkout marks every path outside the cone skip-worktree, so it lands here too.
|
|
84
|
+
output = run_git("ls-files", "-v", "-z", cwd=root, env=env).stdout
|
|
85
|
+
for record in output.split("\0"):
|
|
86
|
+
tag, _, path = record.partition(" ")
|
|
87
|
+
if path and (tag.islower() or tag == "S"):
|
|
88
|
+
return f"skip-worktree or assume-unchanged entry: {label}{path}"
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class _State:
|
|
94
|
+
"""Where the work tree is, or why no tree can describe it."""
|
|
95
|
+
|
|
96
|
+
root: Path | None
|
|
97
|
+
reason: str | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class _StatusRecord:
|
|
102
|
+
"""One entry of `git status --porcelain=v2`."""
|
|
103
|
+
|
|
104
|
+
kind: str
|
|
105
|
+
submodule: str
|
|
106
|
+
path: str
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def submodule_moved(self) -> bool:
|
|
110
|
+
"""Return True when a submodule's commit, tracked content, or untracked files differ."""
|
|
111
|
+
return self.submodule.startswith("S") and any(flag in self.submodule for flag in "CMU")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _status(directory: Path, env: dict[str, str] | None) -> list[_StatusRecord]:
|
|
115
|
+
"""List every difference git status sees, regardless of status and submodule config."""
|
|
116
|
+
# Optional locks off: status would otherwise refresh and rewrite the index it reads,
|
|
117
|
+
# taking index.lock from a concurrent git command. `-unormal` and
|
|
118
|
+
# `--ignore-submodules=none` override config that would hide untracked files and
|
|
119
|
+
# submodule changes.
|
|
120
|
+
output = run_git(
|
|
121
|
+
"--no-optional-locks",
|
|
122
|
+
"status",
|
|
123
|
+
"--porcelain=v2",
|
|
124
|
+
"-z",
|
|
125
|
+
"--untracked-files=normal",
|
|
126
|
+
"--ignore-submodules=none",
|
|
127
|
+
cwd=directory,
|
|
128
|
+
env=env,
|
|
129
|
+
).stdout
|
|
130
|
+
found: list[_StatusRecord] = []
|
|
131
|
+
records = iter(output.split("\0"))
|
|
132
|
+
for record in records:
|
|
133
|
+
kind = record[:1]
|
|
134
|
+
if kind in {"1", "2"}:
|
|
135
|
+
fields = record.split(" ", 9 if kind == "2" else 8)
|
|
136
|
+
if kind == "2":
|
|
137
|
+
next(records, None) # a rename record is followed by its original path
|
|
138
|
+
found.append(_StatusRecord(kind=kind, submodule=fields[2], path=fields[-1]))
|
|
139
|
+
elif kind == "u":
|
|
140
|
+
fields = record.split(" ", 10)
|
|
141
|
+
found.append(_StatusRecord(kind=kind, submodule=fields[2], path=fields[-1]))
|
|
142
|
+
elif kind == "?":
|
|
143
|
+
found.append(_StatusRecord(kind=kind, submodule="N...", path=record[2:]))
|
|
144
|
+
return found
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _gitlinks(directory: Path, env: dict[str, str] | None) -> list[str]:
|
|
148
|
+
"""Return the paths of every gitlink (submodule or embedded repo) in the index."""
|
|
149
|
+
output = run_git("ls-files", "--stage", "-z", cwd=directory, env=env).stdout
|
|
150
|
+
links: list[str] = []
|
|
151
|
+
for record in output.split("\0"):
|
|
152
|
+
info, _, path = record.partition("\t")
|
|
153
|
+
if path and info.split(" ", 1)[0] == _GITLINK_MODE:
|
|
154
|
+
links.append(path)
|
|
155
|
+
return links
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _gitlink_reason(directory: Path, label: str, env: dict[str, str]) -> str | None:
|
|
159
|
+
"""Return why a submodule's checkout may hold content its recorded commit does not.
|
|
160
|
+
|
|
161
|
+
The superproject's status runs the submodule's own status, which obeys the submodule's
|
|
162
|
+
config and index flags, so each checkout is probed directly, recursively.
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
# An uninitialized submodule is an empty directory. `git submodule update --init`
|
|
166
|
+
# later fills it without changing the tree, so an earlier pass proves nothing.
|
|
167
|
+
if not (directory.is_dir() and any(directory.iterdir())):
|
|
168
|
+
return f"submodule is not checked out: {label}"
|
|
169
|
+
checked_out = (directory / ".git").exists()
|
|
170
|
+
except OSError:
|
|
171
|
+
checked_out = False
|
|
172
|
+
if not checked_out:
|
|
173
|
+
return f"submodule is not a checked-out repository: {label}"
|
|
174
|
+
hidden = _hidden_entry_reason(directory, env, label=f"{label}/")
|
|
175
|
+
if hidden:
|
|
176
|
+
return hidden
|
|
177
|
+
if _status(directory, env):
|
|
178
|
+
return f"submodule not at its index state: {label}"
|
|
179
|
+
nested = (
|
|
180
|
+
_gitlink_reason(directory / path, f"{label}/{path}", env)
|
|
181
|
+
for path in _gitlinks(directory, env)
|
|
182
|
+
)
|
|
183
|
+
return next((reason for reason in nested if reason), None)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _inspect(cwd: Path | None, env: dict[str, str] | None) -> _State:
|
|
187
|
+
"""Probe the repository for anything that keeps one tree from describing the working copy."""
|
|
188
|
+
if run_git("rev-parse", "--git-dir", cwd=cwd, env=env, check=False).returncode != 0:
|
|
189
|
+
return _State(root=None, reason="not a git repository")
|
|
190
|
+
# ls-files only lists paths under its cwd. Probing from the top avoids the `:/` pathspec,
|
|
191
|
+
# which GIT_LITERAL_PATHSPECS turns into a literal path that matches nothing.
|
|
192
|
+
root = _work_tree_root(cwd, env)
|
|
193
|
+
if root is None:
|
|
194
|
+
return _State(root=None, reason="could not locate work tree")
|
|
195
|
+
if run_git("ls-files", "--unmerged", cwd=root, env=env).stdout:
|
|
196
|
+
return _State(root=root, reason="unmerged index")
|
|
197
|
+
return _State(root=root, reason=_hidden_entry_reason(root, env))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _git_path(name: str, cwd: Path | None, env: dict[str, str] | None) -> Path:
|
|
201
|
+
"""Return the absolute path git uses for NAME, honoring GIT_INDEX_FILE and friends."""
|
|
202
|
+
output = run_git("rev-parse", "--git-path", name, cwd=cwd, env=env).stdout.strip()
|
|
203
|
+
if not output or "\n" in output:
|
|
204
|
+
msg = f"unexpected `git rev-parse --git-path {name}` output: {output!r}"
|
|
205
|
+
raise OSError(msg)
|
|
206
|
+
path = Path(output)
|
|
207
|
+
if path.is_absolute():
|
|
208
|
+
return path
|
|
209
|
+
return (Path(cwd) if cwd is not None else Path.cwd()) / path
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _require_private(env: dict[str, str], private: Path) -> None:
|
|
213
|
+
"""Refuse to run git unless its index and object store sit inside PRIVATE.
|
|
214
|
+
|
|
215
|
+
The env is built a few lines earlier, so this never fires today. It stands between any
|
|
216
|
+
future change to that env and a `git add -A` that would stage into the real index.
|
|
217
|
+
"""
|
|
218
|
+
for key in ("GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY"):
|
|
219
|
+
if not Path(env[key]).resolve().is_relative_to(private.resolve()):
|
|
220
|
+
msg = f"{key}={env[key]!r} is outside {private}"
|
|
221
|
+
raise OSError(msg)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _submodule_env(env: dict[str, str]) -> dict[str, str]:
|
|
225
|
+
"""Return ENV without the variables that tie git to the superproject's repository."""
|
|
226
|
+
local = run_git("rev-parse", "--local-env-vars", env=env).stdout.split()
|
|
227
|
+
return {key: value for key, value in env.items() if key not in local}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _staged_reason(root: Path, env: dict[str, str]) -> str | None:
|
|
231
|
+
"""Return why the staged private index may not describe what the command reads."""
|
|
232
|
+
links = _gitlinks(root, env)
|
|
233
|
+
# Only gitlinks produce submodule records, so a repo without them skips a full status scan.
|
|
234
|
+
if links:
|
|
235
|
+
for record in _status(root, env):
|
|
236
|
+
if record.submodule_moved:
|
|
237
|
+
return f"submodule not at its index state: {record.path}"
|
|
238
|
+
submodule_env = _submodule_env(env)
|
|
239
|
+
for path in links:
|
|
240
|
+
reason = _gitlink_reason(root / path, path, submodule_env)
|
|
241
|
+
if reason:
|
|
242
|
+
return reason
|
|
243
|
+
ignorecase = run_git("config", "--bool", "core.ignorecase", cwd=root, env=env, check=False)
|
|
244
|
+
if ignorecase.stdout.strip() == "true":
|
|
245
|
+
return _case_mismatch_reason(root, env)
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _case_mismatch_reason(root: Path, env: dict[str, str]) -> str | None:
|
|
250
|
+
"""Return a reason when an indexed path is spelled differently on disk than in the index.
|
|
251
|
+
|
|
252
|
+
On a case-insensitive filesystem a case-only rename keeps the old index entry, so the tree
|
|
253
|
+
would name a path the command does not see. Comparing each path component with the real
|
|
254
|
+
directory entries is exact and does not depend on ignore rules.
|
|
255
|
+
"""
|
|
256
|
+
output = run_git("ls-files", "-z", cwd=root, env=env).stdout
|
|
257
|
+
listings: dict[Path, set[str]] = {}
|
|
258
|
+
for path in (entry for entry in output.split("\0") if entry):
|
|
259
|
+
parent = root
|
|
260
|
+
for part in path.split("/"):
|
|
261
|
+
if parent not in listings:
|
|
262
|
+
listings[parent] = {child.name for child in parent.iterdir()}
|
|
263
|
+
if part not in listings[parent]:
|
|
264
|
+
return f"path differs from the index only by case: {path}"
|
|
265
|
+
parent = parent / part
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _private_snapshot(cwd: Path | None, root: Path, env: dict[str, str] | None) -> Snapshot:
|
|
270
|
+
"""Stage the working copy into a private index and return its tree, writing nothing real.
|
|
271
|
+
|
|
272
|
+
A copy of the index and a throwaway object store take every write, so the real index,
|
|
273
|
+
its lock, and `.git/objects` stay untouched even by write-tree's cache-tree update.
|
|
274
|
+
"""
|
|
275
|
+
# Resolved from cwd, as git itself resolves an inherited relative GIT_INDEX_FILE.
|
|
276
|
+
index = _git_path("index", cwd, env)
|
|
277
|
+
objects = _git_path("objects", cwd, env)
|
|
278
|
+
base = dict(os.environ if env is None else env)
|
|
279
|
+
with scratch.scratch_dir() as private:
|
|
280
|
+
private_index = private / "index"
|
|
281
|
+
private_objects = private / "objects"
|
|
282
|
+
# copy2 keeps the mtime git compares against to spot racily clean entries. A fresh
|
|
283
|
+
# mtime would make git trust stat data for a file edited in the same second.
|
|
284
|
+
if index.exists():
|
|
285
|
+
shutil.copy2(index, private_index)
|
|
286
|
+
private_objects.mkdir()
|
|
287
|
+
alternates = [str(objects), base.get("GIT_ALTERNATE_OBJECT_DIRECTORIES", "")]
|
|
288
|
+
private_env = {
|
|
289
|
+
**base,
|
|
290
|
+
"GIT_INDEX_FILE": str(private_index),
|
|
291
|
+
"GIT_OBJECT_DIRECTORY": str(private_objects),
|
|
292
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES": os.pathsep.join(filter(None, alternates)),
|
|
293
|
+
}
|
|
294
|
+
_require_private(private_env, private)
|
|
295
|
+
# Split index mode would write a shared index file into the real git directory.
|
|
296
|
+
no_split = ("-c", "core.splitIndex=false")
|
|
297
|
+
added = run_git(*no_split, "add", "-A", cwd=root, env=private_env, check=False)
|
|
298
|
+
if added.returncode != 0:
|
|
299
|
+
# Name git's complaint, such as an embedded repository with no commit, so the
|
|
300
|
+
# user can see what keeps the cache off.
|
|
301
|
+
lines = [line for line in added.stderr.splitlines() if line.strip()]
|
|
302
|
+
errors = [line for line in lines if line.startswith(("fatal:", "error:"))]
|
|
303
|
+
detail = (errors or lines or [None])[0]
|
|
304
|
+
reason = "could not stage working copy"
|
|
305
|
+
return Snapshot(tree=None, reason=f"{reason}: {detail}" if detail else reason)
|
|
306
|
+
reason = _staged_reason(root, private_env)
|
|
307
|
+
if reason:
|
|
308
|
+
return Snapshot(tree=None, reason=reason)
|
|
309
|
+
tree = run_git(*no_split, "write-tree", cwd=root, env=private_env).stdout
|
|
310
|
+
return Snapshot(tree=tree, reason=None)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def take_snapshot(cwd: Path | None = None) -> Snapshot:
|
|
314
|
+
"""Identify the tree a gate command is about to test.
|
|
315
|
+
|
|
316
|
+
The tree is the one a commit of the working copy would get, so a pass on uncommitted work
|
|
317
|
+
matches the later commit. Every situation where the tree might not describe what the
|
|
318
|
+
command reads, including any git failure, resolves to unknown so the caller runs the
|
|
319
|
+
command uncached.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
cwd: Directory to run git in. Defaults to the process working directory.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
A Snapshot holding the tree hash, or the reason it is unknown.
|
|
326
|
+
"""
|
|
327
|
+
try:
|
|
328
|
+
env = _git_env(cwd)
|
|
329
|
+
state = _inspect(cwd, env)
|
|
330
|
+
except ShellCommandNotFoundError:
|
|
331
|
+
return Snapshot(tree=None, reason="git not found")
|
|
332
|
+
except (ShellCommandError, UnicodeDecodeError):
|
|
333
|
+
return Snapshot(tree=None, reason="could not inspect git state")
|
|
334
|
+
if state.reason or state.root is None:
|
|
335
|
+
return Snapshot(tree=None, reason=state.reason or "could not locate work tree")
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
return _private_snapshot(cwd, state.root, env)
|
|
339
|
+
except (ShellCommandError, OSError, UnicodeDecodeError):
|
|
340
|
+
return Snapshot(tree=None, reason="could not hash working copy")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def git_common_dir(cwd: Path | None = None) -> Path | None:
|
|
344
|
+
"""Return the absolute git common directory shared by all linked worktrees.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
cwd: Directory to run git in. Defaults to the process working directory.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
The absolute path, or None when git fails or its output is not a single path.
|
|
351
|
+
"""
|
|
352
|
+
try:
|
|
353
|
+
result = run_git("rev-parse", "--git-common-dir", cwd=cwd)
|
|
354
|
+
except (ShellCommandError, UnicodeDecodeError):
|
|
355
|
+
return None
|
|
356
|
+
output = result.stdout.strip()
|
|
357
|
+
if not output or "\n" in output:
|
|
358
|
+
return None
|
|
359
|
+
path = Path(output)
|
|
360
|
+
if path.is_absolute():
|
|
361
|
+
return path
|
|
362
|
+
try:
|
|
363
|
+
base = Path(cwd) if cwd is not None else Path.cwd()
|
|
364
|
+
return (base / path).resolve()
|
|
365
|
+
except (OSError, RuntimeError):
|
|
366
|
+
return None
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
"""Detect the git tree that a gate command is about to test."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
from dataclasses import dataclass
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
from nclutils.git import run_git
|
|
10
|
-
from nclutils.sh import ShellCommandError, ShellCommandNotFoundError
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@dataclass(frozen=True)
|
|
14
|
-
class Snapshot:
|
|
15
|
-
"""The tree a gate is about to test, or why that tree is unknown.
|
|
16
|
-
|
|
17
|
-
Attributes:
|
|
18
|
-
tree: Git tree hash describing the content under test, or None when unknown.
|
|
19
|
-
reason: Why the tree is unknown, or None when it is known.
|
|
20
|
-
"""
|
|
21
|
-
|
|
22
|
-
tree: str | None
|
|
23
|
-
reason: str | None
|
|
24
|
-
|
|
25
|
-
@property
|
|
26
|
-
def known(self) -> bool:
|
|
27
|
-
"""Return True when a tree hash identifies the content under test."""
|
|
28
|
-
return self.tree is not None
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _hook_work_tree(cwd: Path | None) -> Path | None:
|
|
32
|
-
"""Return the work tree that a GIT_DIR without GIT_WORK_TREE belongs to, if derivable.
|
|
33
|
-
|
|
34
|
-
Git hands hooks GIT_DIR alone, and then takes the current directory for the top of the
|
|
35
|
-
work tree. A hook that changes into a subdirectory would see every other file as changed.
|
|
36
|
-
"""
|
|
37
|
-
git_dir = os.environ.get("GIT_DIR")
|
|
38
|
-
if not git_dir or os.environ.get("GIT_WORK_TREE"):
|
|
39
|
-
return None
|
|
40
|
-
if run_git("config", "--get", "core.worktree", cwd=cwd, check=False).stdout.strip():
|
|
41
|
-
return None
|
|
42
|
-
base = Path(cwd) if cwd is not None else Path.cwd()
|
|
43
|
-
path = (base / git_dir).resolve()
|
|
44
|
-
# A linked worktree's git dir holds a `gitdir` file naming `<work tree>/.git`.
|
|
45
|
-
gitdir_file = path / "gitdir"
|
|
46
|
-
if gitdir_file.is_file():
|
|
47
|
-
target = Path(gitdir_file.read_text(encoding="utf-8").strip())
|
|
48
|
-
return (path / target).resolve().parent
|
|
49
|
-
if path.name == ".git":
|
|
50
|
-
return path.parent
|
|
51
|
-
return None
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _git_env(cwd: Path | None) -> dict[str, str] | None:
|
|
55
|
-
"""Return an environment for gate-cache's own git probes, or None to inherit as is."""
|
|
56
|
-
try:
|
|
57
|
-
work_tree = _hook_work_tree(cwd)
|
|
58
|
-
except OSError:
|
|
59
|
-
return None
|
|
60
|
-
if work_tree is None:
|
|
61
|
-
return None
|
|
62
|
-
return {**os.environ, "GIT_WORK_TREE": str(work_tree)}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def _work_tree_root(cwd: Path | None, env: dict[str, str] | None) -> Path | None:
|
|
66
|
-
"""Return the absolute top of the work tree, or None when there is none."""
|
|
67
|
-
result = run_git("rev-parse", "--show-toplevel", cwd=cwd, env=env, check=False)
|
|
68
|
-
output = result.stdout.strip()
|
|
69
|
-
if result.returncode != 0 or not output or "\n" in output:
|
|
70
|
-
return None
|
|
71
|
-
return Path(output)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def _unknown_reason(cwd: Path | None, env: dict[str, str] | None) -> str | None:
|
|
75
|
-
"""Return why the working copy cannot be described by one tree, or None if it can."""
|
|
76
|
-
if run_git("rev-parse", "--git-dir", cwd=cwd, env=env, check=False).returncode != 0:
|
|
77
|
-
return "not a git repository"
|
|
78
|
-
# ls-files only lists paths under its cwd. Probing from the top avoids the `:/` pathspec,
|
|
79
|
-
# which GIT_LITERAL_PATHSPECS turns into a literal path that matches nothing.
|
|
80
|
-
root = _work_tree_root(cwd, env)
|
|
81
|
-
if root is None:
|
|
82
|
-
return "could not locate work tree"
|
|
83
|
-
if run_git("ls-files", "--unmerged", cwd=root, env=env).stdout:
|
|
84
|
-
return "unmerged index"
|
|
85
|
-
if run_git("diff", "--quiet", cwd=root, env=env, okay_codes=(0, 1)).returncode != 0:
|
|
86
|
-
return "unstaged changes to tracked files"
|
|
87
|
-
untracked = run_git(
|
|
88
|
-
"ls-files", "--others", "--exclude-standard", "-z", cwd=root, env=env
|
|
89
|
-
).stdout
|
|
90
|
-
if untracked:
|
|
91
|
-
return f"untracked file: {untracked.split(chr(0))[0]}"
|
|
92
|
-
return None
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def take_snapshot(cwd: Path | None = None) -> Snapshot:
|
|
96
|
-
"""Identify the tree a gate command is about to test.
|
|
97
|
-
|
|
98
|
-
The snapshot is known only when the working copy matches the index for tracked files and
|
|
99
|
-
no untracked, non-ignored files exist. Every other situation, including any git failure,
|
|
100
|
-
resolves to unknown so the caller runs the command uncached.
|
|
101
|
-
|
|
102
|
-
Args:
|
|
103
|
-
cwd: Directory to run git in. Defaults to the process working directory.
|
|
104
|
-
|
|
105
|
-
Returns:
|
|
106
|
-
A Snapshot holding the tree hash, or the reason it is unknown.
|
|
107
|
-
"""
|
|
108
|
-
try:
|
|
109
|
-
env = _git_env(cwd)
|
|
110
|
-
reason = _unknown_reason(cwd, env)
|
|
111
|
-
except ShellCommandNotFoundError:
|
|
112
|
-
return Snapshot(tree=None, reason="git not found")
|
|
113
|
-
except (ShellCommandError, UnicodeDecodeError):
|
|
114
|
-
return Snapshot(tree=None, reason="could not inspect git state")
|
|
115
|
-
if reason:
|
|
116
|
-
return Snapshot(tree=None, reason=reason)
|
|
117
|
-
|
|
118
|
-
try:
|
|
119
|
-
tree = run_git("write-tree", cwd=cwd, env=env).stdout
|
|
120
|
-
except (ShellCommandError, UnicodeDecodeError):
|
|
121
|
-
return Snapshot(tree=None, reason="could not compute tree")
|
|
122
|
-
return Snapshot(tree=tree, reason=None)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def git_common_dir(cwd: Path | None = None) -> Path | None:
|
|
126
|
-
"""Return the absolute git common directory shared by all linked worktrees.
|
|
127
|
-
|
|
128
|
-
Args:
|
|
129
|
-
cwd: Directory to run git in. Defaults to the process working directory.
|
|
130
|
-
|
|
131
|
-
Returns:
|
|
132
|
-
The absolute path, or None when git fails or its output is not a single path.
|
|
133
|
-
"""
|
|
134
|
-
try:
|
|
135
|
-
result = run_git("rev-parse", "--git-common-dir", cwd=cwd)
|
|
136
|
-
except (ShellCommandError, UnicodeDecodeError):
|
|
137
|
-
return None
|
|
138
|
-
output = result.stdout.strip()
|
|
139
|
-
if not output or "\n" in output:
|
|
140
|
-
return None
|
|
141
|
-
path = Path(output)
|
|
142
|
-
if path.is_absolute():
|
|
143
|
-
return path
|
|
144
|
-
try:
|
|
145
|
-
base = Path(cwd) if cwd is not None else Path.cwd()
|
|
146
|
-
return (base / path).resolve()
|
|
147
|
-
except (OSError, RuntimeError):
|
|
148
|
-
return None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|