stacktrace-cli 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. stacktrace_cli/__init__.py +1 -1
  2. stacktrace_cli/analysis.py +202 -20
  3. stacktrace_cli/build.py +130 -0
  4. stacktrace_cli/cli.py +137 -55
  5. stacktrace_cli/correlate/acquire.py +23 -2
  6. stacktrace_cli/correlate/orchestrate.py +73 -9
  7. stacktrace_cli/correlate/render.py +7 -5
  8. stacktrace_cli/daemon/__init__.py +1 -0
  9. stacktrace_cli/daemon/cli.py +106 -0
  10. stacktrace_cli/daemon/client.py +134 -0
  11. stacktrace_cli/daemon/composition.py +133 -0
  12. stacktrace_cli/daemon/observer.py +173 -0
  13. stacktrace_cli/daemon/paths.py +35 -0
  14. stacktrace_cli/daemon/protocol.py +29 -0
  15. stacktrace_cli/daemon/runtime.py +222 -0
  16. stacktrace_cli/daemon/server.py +164 -0
  17. stacktrace_cli/daemon/service.py +154 -0
  18. stacktrace_cli/daemon/store.py +368 -0
  19. stacktrace_cli/detector/blocked.py +450 -0
  20. stacktrace_cli/detector/cache.py +245 -9
  21. stacktrace_cli/detector/deterministic.py +359 -511
  22. stacktrace_cli/detector/finding.py +163 -15
  23. stacktrace_cli/detector/history.py +210 -0
  24. stacktrace_cli/detector/jev.py +213 -0
  25. stacktrace_cli/detector/priors.py +53 -285
  26. stacktrace_cli/detector/prompts/__init__.py +20 -0
  27. stacktrace_cli/detector/prompts/jev-v1/stacktrace-deceptive-completion.json +16 -0
  28. stacktrace_cli/detector/prompts/jev-v2/stacktrace-deceptive-completion.json +16 -0
  29. stacktrace_cli/detector/reasoning.py +858 -308
  30. stacktrace_cli/detector/render.py +88 -7
  31. stacktrace_cli/detector/rules.py +69 -56
  32. stacktrace_cli/detector/run.py +423 -77
  33. stacktrace_cli/detector/secrets.py +8 -6
  34. stacktrace_cli/detector/windows.py +293 -0
  35. stacktrace_cli/monitor/reasoning.py +153 -21
  36. stacktrace_cli/monitor/render.py +421 -10
  37. stacktrace_cli/monitor/server.py +242 -21
  38. stacktrace_cli/monitor/site/app.js +1154 -178
  39. stacktrace_cli/monitor/site/index.html +16 -1
  40. stacktrace_cli/monitor/site/styles.css +89 -1
  41. stacktrace_cli/monitor/state.py +27 -1
  42. stacktrace_cli/monitor/watch.py +227 -47
  43. stacktrace_cli/options.py +107 -0
  44. stacktrace_cli/private_state.py +38 -0
  45. stacktrace_cli/remote/cli.py +20 -10
  46. stacktrace_cli/remote/detect_payload.py +37 -5
  47. stacktrace_cli/remote/redact.py +3 -3
  48. stacktrace_cli/remote/spool.py +3 -2
  49. stacktrace_cli/remote/sync_detect.py +15 -1
  50. stacktrace_cli/remote/upload_contract.py +7 -0
  51. stacktrace_cli/sessions/access.py +24 -0
  52. stacktrace_cli/sessions/index.py +30 -0
  53. stacktrace_cli/sessions/protocols.py +13 -0
  54. stacktrace_cli/sessions/render.py +15 -9
  55. stacktrace_cli/telemetry/__init__.py +11 -0
  56. stacktrace_cli/telemetry/cli.py +67 -0
  57. stacktrace_cli/telemetry/events.py +266 -0
  58. stacktrace_cli/telemetry/posthog.py +126 -0
  59. stacktrace_cli/telemetry/state.py +190 -0
  60. {stacktrace_cli-0.3.0.dist-info → stacktrace_cli-0.4.0.dist-info}/METADATA +56 -24
  61. stacktrace_cli-0.4.0.dist-info/RECORD +90 -0
  62. {stacktrace_cli-0.3.0.dist-info → stacktrace_cli-0.4.0.dist-info}/WHEEL +1 -1
  63. stacktrace_cli/detector/markers.py +0 -158
  64. stacktrace_cli/detector/prompts/v1/stacktrace-injected-instruction-followed.md +0 -14
  65. stacktrace_cli/detector/prompts/v1/stacktrace-intent-drift.md +0 -11
  66. stacktrace_cli-0.3.0.dist-info/RECORD +0 -67
  67. {stacktrace_cli-0.3.0.dist-info → stacktrace_cli-0.4.0.dist-info}/entry_points.txt +0 -0
@@ -1,3 +1,3 @@
1
1
  """The `stacktrace` command-line interface — detection and response for AI agents."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -29,9 +29,11 @@ from stacktrace_cli.correlate.orchestrate import Acquired, acquire_correlated_vi
29
29
  from stacktrace_cli.correlate.project_map import parse_mapping
30
30
  from stacktrace_cli.correlate.record import CorrelatedSession, CorrelatedView, Coverage
31
31
  from stacktrace_cli.detector.cache import VerdictCache
32
+ from stacktrace_cli.detector.history import DriftHistory
32
33
  from stacktrace_cli.detector.run import (
33
34
  DEFAULT_BUDGET,
34
35
  DEFAULT_SAMPLE_BUDGET,
36
+ NO_ANALYZER,
35
37
  DetectorRun,
36
38
  run_detector,
37
39
  )
@@ -82,20 +84,40 @@ def parse_since(value: str) -> datetime:
82
84
  raise ValueError(f"--since must be a positive day count, not {value!r}") from None
83
85
 
84
86
 
85
- def _only(view: SessionView, session_ids: tuple[str, ...]) -> SessionView:
86
- """The window narrowed to named sessions, or the window itself.
87
+ def _only(
88
+ view: SessionView,
89
+ session_ids: tuple[str, ...],
90
+ agent_kinds: tuple[str, ...],
91
+ ) -> SessionView:
92
+ """The window narrowed to named session identities, or the window itself.
87
93
 
88
94
  Narrowed *before* correlation rather than after judgement, because the point
89
95
  is that nothing else can be dispatched: a caller that pays for one named
90
96
  session must not be able to spend that payment on another one the detector
91
- happened to rank higher. `unavailable` is carried through unchanged — a
92
- reader that failed is still a reader that failed, whichever session was
93
- asked for.
97
+ happened to rank higher. The kind and id filters are both applied when the
98
+ caller supplies both halves of the identity. `unavailable` is carried
99
+ through unchanged — a reader that failed is still a reader that failed,
100
+ whichever session was asked for.
101
+
102
+ A kind-anonymous session (`agent_kind is None`) is never dropped by the
103
+ kind filter: `sessions/access.py`'s coverage contract is that a session
104
+ OpenAIDR could not map to a kind is a gap to surface, not a kind the user
105
+ filtered out, and this seam must not reintroduce that drop after
106
+ collection already kept it.
94
107
  """
95
- if not session_ids:
108
+ if not session_ids and not agent_kinds:
96
109
  return view
97
110
  wanted = set(session_ids)
98
- return replace(view, sessions=tuple(s for s in view.sessions if s.session_id in wanted))
111
+ kinds = set(agent_kinds)
112
+ return replace(
113
+ view,
114
+ sessions=tuple(
115
+ session
116
+ for session in view.sessions
117
+ if (not wanted or session.session_id in wanted)
118
+ and (not kinds or session.agent_kind is None or session.agent_kind in kinds)
119
+ ),
120
+ )
99
121
 
100
122
 
101
123
  def analyse(
@@ -111,6 +133,10 @@ def analyse(
111
133
  sample_budget: int = DEFAULT_SAMPLE_BUDGET,
112
134
  cache: VerdictCache | None = None,
113
135
  attach_advisories: Any = None,
136
+ build_all: Any = None,
137
+ analyzer: str = NO_ANALYZER,
138
+ jev_key: str | None = None,
139
+ history: DriftHistory | None = None,
114
140
  ) -> Analysis:
115
141
  """Read this machine's sessions, correlate them, and judge them.
116
142
 
@@ -122,23 +148,111 @@ def analyse(
122
148
  cache through it so osv.dev is asked once per component rather than once per
123
149
  pass; a one-shot command passes nothing and gets the default.
124
150
 
151
+ `build_all` is the same kind of seam for composition builds, which shell out
152
+ and cost seconds each. A long-lived process passes a cache through it so an
153
+ unchanged machine or project is not rebuilt on every pass; a one-shot
154
+ command passes nothing and gets the default.
155
+
125
156
  `session_ids` narrows the window to named sessions. Empty means the whole
126
157
  window, which is what every command passes; monitor's reasoning button is
127
158
  what needs the narrowing, and needs it to be structural.
128
159
 
129
160
  `since` is the window's spelling — `parse_since` reads it — or the cutoff
130
161
  itself, for a caller whose own `--since` spelling is not this one's.
162
+
163
+ `reasoning` switches the third stage on and is off by default; `jev_key`
164
+ hands the analyzer a key a caller already holds, in place of reading
165
+ `TYPESAFE_API_KEY`. Both travel to `run_detector` unchanged, so a
166
+ programmatic caller controls exactly what `detect` controls.
131
167
  """
132
168
  window_end = datetime.now(UTC)
133
169
  window_start = since if isinstance(since, datetime) else parse_since(since)
134
- acquired = _correlated(
170
+ sessions = _collected(
135
171
  agent_kinds=agent_kinds,
136
172
  window_start=window_start,
137
- bom_paths=bom_paths,
138
- project_map=project_map,
139
173
  root=root,
140
174
  session_ids=session_ids,
175
+ )
176
+ return _analyse_sessions(
177
+ sessions,
178
+ window_start=window_start,
179
+ window_end=window_end,
180
+ bom_paths=bom_paths,
181
+ project_map=project_map,
182
+ reasoning=reasoning,
183
+ budget=budget,
184
+ sample_budget=sample_budget,
185
+ cache=cache,
186
+ attach_advisories=attach_advisories,
187
+ build_all=build_all,
188
+ analyzer=analyzer,
189
+ jev_key=jev_key,
190
+ history=history,
191
+ )
192
+
193
+
194
+ def analyse_sessions(
195
+ sessions: SessionView,
196
+ *,
197
+ agent_kinds: tuple[str, ...] = (),
198
+ since: str | datetime = "7d",
199
+ bom_paths: tuple[Path, ...] = (),
200
+ project_map: tuple[str, ...] = (),
201
+ session_ids: tuple[str, ...] = (),
202
+ reasoning: bool = False,
203
+ budget: int = DEFAULT_BUDGET,
204
+ sample_budget: int = DEFAULT_SAMPLE_BUDGET,
205
+ cache: VerdictCache | None = None,
206
+ attach_advisories: Any = None,
207
+ build_all: Any = None,
208
+ analyzer: str = NO_ANALYZER,
209
+ jev_key: str | None = None,
210
+ history: DriftHistory | None = None,
211
+ ) -> Analysis:
212
+ """Correlate and judge a session view already collected by a long-lived reader."""
213
+ window_end = datetime.now(UTC)
214
+ window_start = since if isinstance(since, datetime) else parse_since(since)
215
+ return _analyse_sessions(
216
+ _only(sessions, session_ids, agent_kinds),
217
+ window_start=window_start,
218
+ window_end=window_end,
219
+ bom_paths=bom_paths,
220
+ project_map=project_map,
221
+ reasoning=reasoning,
222
+ budget=budget,
223
+ sample_budget=sample_budget,
224
+ cache=cache,
141
225
  attach_advisories=attach_advisories,
226
+ build_all=build_all,
227
+ analyzer=analyzer,
228
+ jev_key=jev_key,
229
+ history=history,
230
+ )
231
+
232
+
233
+ def _analyse_sessions(
234
+ sessions: SessionView,
235
+ *,
236
+ window_start: datetime,
237
+ window_end: datetime,
238
+ bom_paths: tuple[Path, ...],
239
+ project_map: tuple[str, ...],
240
+ reasoning: bool,
241
+ budget: int,
242
+ sample_budget: int,
243
+ cache: VerdictCache | None,
244
+ attach_advisories: Any,
245
+ build_all: Any,
246
+ analyzer: str,
247
+ jev_key: str | None,
248
+ history: DriftHistory | None,
249
+ ) -> Analysis:
250
+ acquired = _acquire(
251
+ sessions,
252
+ bom_paths=bom_paths,
253
+ project_map=project_map,
254
+ attach_advisories=attach_advisories,
255
+ build_all=build_all,
142
256
  )
143
257
  run = run_detector(
144
258
  acquired.view,
@@ -146,6 +260,9 @@ def analyse(
146
260
  budget=budget,
147
261
  sample_budget=sample_budget,
148
262
  cache=cache,
263
+ analyzer=analyzer,
264
+ jev_key=jev_key,
265
+ history=history,
149
266
  )
150
267
  return Analysis(
151
268
  run=run,
@@ -170,7 +287,11 @@ def _collected(
170
287
  session_ids: tuple[str, ...],
171
288
  ) -> SessionView:
172
289
  """Read the window. Its own function so a caller can read twice."""
173
- return _only(collect_sessions(list(agent_kinds) or None, window_start, root=root), session_ids)
290
+ return _only(
291
+ collect_sessions(list(agent_kinds) or None, window_start, root=root),
292
+ session_ids,
293
+ agent_kinds,
294
+ )
174
295
 
175
296
 
176
297
  def _acquire(
@@ -179,9 +300,14 @@ def _acquire(
179
300
  bom_paths: tuple[Path, ...],
180
301
  project_map: tuple[str, ...],
181
302
  attach_advisories: Any,
303
+ build_all: Any = None,
182
304
  ) -> Acquired:
183
305
  """Correlate what was read."""
184
- hook = {} if attach_advisories is None else {"attach_advisories": attach_advisories}
306
+ hook: dict[str, Any] = {}
307
+ if attach_advisories is not None:
308
+ hook["attach_advisories"] = attach_advisories
309
+ if build_all is not None:
310
+ hook["build_all"] = build_all
185
311
  return acquire_correlated_view(
186
312
  sessions,
187
313
  bom_paths=bom_paths,
@@ -199,6 +325,7 @@ def _correlated(
199
325
  root: Path | None,
200
326
  session_ids: tuple[str, ...],
201
327
  attach_advisories: Any,
328
+ build_all: Any = None,
202
329
  ) -> Acquired:
203
330
  """Everything up to the judging, shared by both entry points."""
204
331
  return _acquire(
@@ -211,6 +338,7 @@ def _correlated(
211
338
  bom_paths=bom_paths,
212
339
  project_map=project_map,
213
340
  attach_advisories=attach_advisories,
341
+ build_all=build_all,
214
342
  )
215
343
 
216
344
 
@@ -258,9 +386,18 @@ def analyse_progressively(
258
386
  preview: timedelta | None = PREVIEW_WINDOW,
259
387
  cache: VerdictCache | None = None,
260
388
  attach_advisories: Any = None,
389
+ analyzer: str = NO_ANALYZER,
390
+ jev_key: str | None = None,
391
+ history: DriftHistory | None = None,
392
+ reasoning: bool = False,
393
+ budget: int = DEFAULT_BUDGET,
261
394
  ) -> Iterator[Analysis]:
262
395
  """The same pipeline, delivered in instalments.
263
396
 
397
+ `history` is accepted and unused: this path never requests reasoning, so
398
+ it never adds a point. It is here so the watcher can forward one options
399
+ dict to both entry points (the test in `tests/monitor/test_watch.py`).
400
+
264
401
  `analyse` answers once, which is right for a command that prints and exits
265
402
  and wrong for a page meant to look alive. Measured over 339 real sessions
266
403
  the work splits about 6.6s to collect and correlate, then about 12ms to
@@ -289,11 +426,13 @@ def analyse_progressively(
289
426
  `cache` is read and never written. A session graded by an earlier
290
427
  `detect --reasoning` shows that grade here; this path never commissions one.
291
428
 
292
- **No `reasoning` parameter, deliberately.** Stage 3's budget is per *run*, so
293
- judging in batches would give each batch its own budget and spend a multiple
294
- of what was authorised. A streaming reasoning run needs a budget shared across
295
- batches; until it has one, this path does not offer the option rather than
296
- offering it wrongly.
429
+ **Reasoning runs once, after the batches, never inside them.** Stage 3's
430
+ budget is per *run*, so asking each batch would give each its own budget and
431
+ spend a multiple of what was authorised — which is why this path refused the
432
+ option entirely until now. Running it once over the whole view keeps the
433
+ budget meaning what the flag said, and keeps the ordering that matters: the
434
+ cheap stages paint first and stage 3 arrives as one more instalment, rather
435
+ than holding the first paint behind several hundred sequential requests.
297
436
  """
298
437
  window_end = datetime.now(UTC)
299
438
  window_start = since if isinstance(since, datetime) else parse_since(since)
@@ -348,20 +487,43 @@ def analyse_progressively(
348
487
  if preview is None:
349
488
  yield _stage(DetectorRun(collection_failures=failures), ())
350
489
 
490
+ # Newest *activity* first, not newest start. This decides which sessions are
491
+ # judged first and therefore what a progressive reader sees first, and
492
+ # `started_at` answers a different question: a session running for a day
493
+ # began long ago and is the most recent thing on the machine. Ordering by
494
+ # its start put the session doing the work behind sessions idle for hours --
495
+ # the same defect the monitor's own session list had, one layer up, so the
496
+ # batch order and the page's order now read the same clock.
351
497
  ordered = sorted(
352
498
  view.sessions,
353
- key=lambda correlated: correlated.session.started_at or datetime.min.replace(tzinfo=UTC),
499
+ key=lambda correlated: (
500
+ correlated.session.last_activity_at
501
+ or correlated.session.started_at
502
+ or datetime.min.replace(tzinfo=UTC)
503
+ ),
354
504
  reverse=True,
355
505
  )
356
506
  step = max(batch, 1)
357
- accumulated = DetectorRun(collection_failures=failures)
507
+ accumulated = DetectorRun(collection_failures=failures, analyzer=analyzer)
358
508
  for index in range(0, len(ordered), step):
359
509
  judged = run_detector(
360
510
  CorrelatedView(sessions=tuple(ordered[index : index + step])),
361
511
  # Read, never written, and never requesting: a session an earlier
362
512
  # `detect --reasoning` graded renders with that grade here, and this
363
- # path commissions nothing (ADR-0026 clause 2).
513
+ # path commissions nothing (ADR-0026 clause 2). The analyzer name
514
+ # is what lets a stored Jev map be served: the cache key carries
515
+ # the analyzer's identity, so a page started with --analyzer jev
516
+ # reads the entries that flag paid for and no others.
517
+ #
518
+ # `jev_key` rides along although this path asks nothing. Today the
519
+ # cache key reads `identity()`, which does not depend on the key,
520
+ # so omitting it would work — but it would leave one call building
521
+ # two differently-configured analyzers under one name, and the next
522
+ # thing to depend on the key would break here and not in the
523
+ # instalment below.
364
524
  cache=cache,
525
+ analyzer=analyzer,
526
+ jev_key=jev_key,
365
527
  )
366
528
  accumulated = DetectorRun(
367
529
  detections=accumulated.detections + judged.detections,
@@ -370,6 +532,26 @@ def analyse_progressively(
370
532
  requested=accumulated.requested + judged.requested,
371
533
  analysed=accumulated.analysed + judged.analysed,
372
534
  cache_hits=accumulated.cache_hits + judged.cache_hits,
535
+ maps=accumulated.maps + judged.maps,
373
536
  collection_failures=failures,
537
+ analyzer=analyzer,
374
538
  )
375
539
  yield _stage(accumulated, tuple(ordered))
540
+
541
+ if not reasoning or not ordered:
542
+ return
543
+
544
+ # One more instalment, one run, one budget. Everything above has already
545
+ # been published, so the page is complete and readable while this is in
546
+ # flight — on a large window it is several hundred sequential requests and
547
+ # holding the first paint behind it is what made the page look hung.
548
+ judged = run_detector(
549
+ view,
550
+ reasoning=True,
551
+ budget=budget,
552
+ cache=cache,
553
+ analyzer=analyzer,
554
+ jev_key=jev_key,
555
+ history=history,
556
+ )
557
+ yield _stage(judged, tuple(ordered))
@@ -0,0 +1,130 @@
1
+ """Which commit this install was built from, when it was not a PyPI release.
2
+
3
+ Read from the install's own PEP 610 record (`direct_url.json` in the
4
+ dist-info), which pip and uv write for every install that did not come from an
5
+ index:
6
+
7
+ - ``pip install git+https://…@branch`` / ``uv tool install git+…`` record the
8
+ resolved commit in ``vcs_info.commit_id``.
9
+ - ``pip install -e .`` / ``uv sync`` record an editable ``file://`` URL; the
10
+ code runs from that checkout, so its current ``HEAD`` is the answer, plus
11
+ ``.dirty`` when the worktree has uncommitted changes.
12
+
13
+ A wheel from PyPI has no record, and a non-editable install from a local
14
+ directory records only the path, whose ``HEAD`` may have moved since the build
15
+ — both answer `None` rather than a commit that may not be the one running.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import functools
21
+ import json
22
+ import os
23
+ import re
24
+ import subprocess
25
+ import threading
26
+ from importlib.metadata import distribution
27
+ from pathlib import Path
28
+ from urllib.parse import unquote, urlparse
29
+
30
+ DISTRIBUTION = "stacktrace-cli"
31
+
32
+ _SHA = re.compile(r"^[0-9a-f]{7,64}$")
33
+ _SHORT = 7
34
+
35
+ #: Serializes `_compute_version_label`'s cache misses. `functools.cache` alone
36
+ #: lets concurrent misses run the wrapped function more than once (verified:
37
+ #: two threads racing an uncached call both entered `build_commit`), so the
38
+ #: daemon's startup warm-up (`daemon/runtime.py`) and a request thread could
39
+ #: each launch their own `git` probe. Held only around the call: the thread
40
+ #: that loses the race blocks briefly here rather than repeating the winner's
41
+ #: work, and finds the answer already cached once it gets in.
42
+ _version_label_lock = threading.Lock()
43
+
44
+
45
+ def version_label() -> str:
46
+ """The version as `--version` prints it: `0.4.0`, or `0.4.0+86eef86` for a
47
+ build from a branch or a pull request.
48
+
49
+ One expression for both places that report it, the CLI and telemetry, so a
50
+ PostHog event and `--version` cannot disagree about which build ran.
51
+ """
52
+ with _version_label_lock:
53
+ return _compute_version_label()
54
+
55
+
56
+ @functools.cache
57
+ def _compute_version_label() -> str:
58
+ from . import __version__
59
+
60
+ commit = build_commit()
61
+ return f"{__version__}+{commit}" if commit else __version__
62
+
63
+
64
+ def build_commit() -> str | None:
65
+ """The short commit this install runs, or `None` for a release install."""
66
+ try:
67
+ return _read_build_commit()
68
+ except Exception: # noqa: BLE001 - direct_url.json is external, untrusted input
69
+ # Every step below reads or parses `direct_url.json`, an external
70
+ # document written by pip or uv. Three narrower guards in a row each
71
+ # missed a new failure mode in turn (`UnicodeDecodeError` from
72
+ # `read_text`'s UTF-8 decode, `urlparse`'s `ValueError` on a malformed
73
+ # URL, `json.loads`'s `RecursionError` on deep nesting) -- the fix is
74
+ # the boundary, not the exception list. Anything this parse can raise
75
+ # reads the same as no record.
76
+ return None
77
+
78
+
79
+ def _read_build_commit() -> str | None:
80
+ raw = distribution(DISTRIBUTION).read_text("direct_url.json")
81
+ if not raw:
82
+ return None
83
+ record = json.loads(raw)
84
+ if not isinstance(record, dict):
85
+ return None
86
+
87
+ vcs = record.get("vcs_info")
88
+ if isinstance(vcs, dict):
89
+ commit = vcs.get("commit_id")
90
+ if vcs.get("vcs") == "git" and isinstance(commit, str) and _SHA.match(commit):
91
+ return commit[:_SHORT]
92
+ return None
93
+
94
+ directory = record.get("dir_info")
95
+ url = record.get("url")
96
+ if isinstance(directory, dict) and directory.get("editable") is True and isinstance(url, str):
97
+ parsed = urlparse(url)
98
+ if parsed.scheme == "file":
99
+ return _checkout_commit(Path(unquote(parsed.path)))
100
+ return None
101
+
102
+
103
+ def _checkout_commit(root: Path) -> str | None:
104
+ # GIT_DIR/GIT_WORK_TREE in the caller's environment override -C, so a
105
+ # caller running us from inside another repo's hook can make git report
106
+ # that repo's HEAD instead of `root`'s.
107
+ env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE")}
108
+
109
+ def git(*args: str) -> str | None:
110
+ try:
111
+ done = subprocess.run(
112
+ ["git", "-C", str(root), *args],
113
+ capture_output=True,
114
+ text=True,
115
+ timeout=5,
116
+ check=False,
117
+ env=env,
118
+ )
119
+ except (OSError, subprocess.SubprocessError):
120
+ return None
121
+ return done.stdout if done.returncode == 0 else None
122
+
123
+ head = git("rev-parse", "HEAD")
124
+ if head is None or not _SHA.match(head.strip()):
125
+ return None
126
+ commit = head.strip()[:_SHORT]
127
+ status = git("status", "--porcelain")
128
+ if status is None:
129
+ return None
130
+ return f"{commit}.dirty" if status else commit