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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: gate-cache
3
- Version: 0.3.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 tracked files. 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
+ 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 a tracked file 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
+ 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
- When the working copy has uncommitted edits or files that git does not track, the content on disk is not a commit-ready snapshot. gate-cache then runs the command and writes no marker.
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. Giving each set of arguments its own name, or caching only the bare form, keeps the two apart.
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
- #!/usr/bin/env bash
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 because of uncommitted edits, the `tree` line gives the reason.
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` and `reason` names the variable. With `--name`, the object has the `name`, `marker`, `result`, and `last_run` keys in place of `passed` and `missed`.
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 tracked files. 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.
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 tracked files. The next time the same name runs on the same content, gate-cache finds the marker, skips the command, and exits 0 at once.
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 a tracked file 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.
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
- When the working copy has uncommitted edits or files that git does not track, the content on disk is not a commit-ready snapshot. gate-cache then runs the command and writes no marker.
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. Giving each set of arguments its own name, or caching only the bare form, keeps the two apart.
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
- #!/usr/bin/env bash
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 because of uncommitted edits, the `tree` line gives the reason.
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` and `reason` names the variable. With `--name`, the object has the `name`, `marker`, `result`, and `last_run` keys in place of `passed` and `missed`.
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 tracked files. 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.
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.3.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 = ["cyclopts>=5.2.0,<6.0.0", "nclutils>=3.4.4,<4.0.0"]
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.3.0"
19
+ version = "0.4.0"
16
20
 
17
21
  [project.scripts]
18
22
  gate-cache = "gate_cache.cli:main"
@@ -1,3 +1,3 @@
1
1
  """Skip lint and test gates that already passed on the exact same git tree."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -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 runs
21
- the command, records a pass keyed on the git tree of the tracked files (the index, when
22
- the working copy matches it), and skips the command next time the tree is identical.
23
- With unstaged changes or untracked files the command runs without caching and nothing
24
- is recorded.
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 after
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 is keyed. Never wrap checks that depend on anything else,
44
- such as commit-message or branch-name checks.
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
- has unstaged changes or untracked files, or this is not a git repository. A pass is recorded only
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 _marker_exists(marker_path(common_dir, tree, name)):
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
- _print_json({"enabled": False, "reason": reason, "tree": None})
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
- hit = path is not None and _marker_exists(path)
256
- if not hit and tree is not None and common_dir is not None:
257
- last_run = next(
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
- marker = str(path) if path is not None else "none"
278
- if tree is not None and common_dir is None:
279
- marker = f"none: {_NO_COMMON_DIR_REASON}"
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: {snapshot.reason}",
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(f"{m['name']} ({m['reason']})" for m in missed)
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 that also has a pass marker on the tree is left out, since a later pass
166
- supersedes the miss.
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"] not in passed
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
- if not isinstance(data, dict):
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