dna-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dna_cli/_ctx.py ADDED
@@ -0,0 +1,433 @@
1
+ """Shared CLI context — kernel session + output formatting (kernel-local).
2
+
3
+ The ``dna`` CLI is a thin wrapper over the DNA kernel: every command
4
+ boots a local Kernel against ``DNA_SOURCE_URL`` / ``DNA_BASE_DIR``
5
+ (filesystem source), runs one command, exits. No service, no HTTP —
6
+ the kernel IS the backend.
7
+
8
+ Two session patterns:
9
+
10
+ - ``dna_session(scope)`` — context manager that owns ONE event loop
11
+ for the entire command. Use for any command that does writes /
12
+ deletes / async ops.
13
+
14
+ with dna_session(scope) as s:
15
+ s.run(s.kernel.write_document(...))
16
+
17
+ - ``get_holder(scope)`` — sync helper for read-only commands that
18
+ only touch ``query_list`` / ``get_doc`` (no async afterwards).
19
+
20
+ Plus ``dna_client()`` — a LOCAL facade with the resource shape some
21
+ commands consume (``client.docs(scope).get/put/delete/list`` and
22
+ ``client.scopes.list/tree/kinds/kind_schema``), backed by the same
23
+ local kernel. In the upstream platform this surface is an HTTP API
24
+ client; here it resolves in-process.
25
+
26
+ Output helpers: ``print_json``, ``print_table``, ``fail``.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import json
32
+ import os
33
+ import sys
34
+ from contextlib import contextmanager
35
+ from dataclasses import dataclass
36
+ from pathlib import Path
37
+ from typing import Any, Iterator
38
+ from urllib.parse import urlparse
39
+
40
+ import click
41
+
42
+
43
+ # ─── source resolution ────────────────────────────────────────────────
44
+ #
45
+ # Priority: DNA_SOURCE_URL > DNA_BASE_DIR (rewritten to file://<dir>) >
46
+ # ./.dna. This distribution ships the filesystem adapter on the boot
47
+ # path; the sqlite/postgres adapters are importable from dna.adapters
48
+ # and can be wired programmatically (kernel.source(...)) by host code.
49
+
50
+
51
+ def _resolve_source_url(base_dir_override: str | None = None) -> str:
52
+ url = os.getenv("DNA_SOURCE_URL")
53
+ if url:
54
+ return url
55
+ base = base_dir_override or os.getenv("DNA_BASE_DIR")
56
+ if base:
57
+ p = Path(base).resolve()
58
+ # mirror the classic convention: a project dir with a .dna/ child
59
+ if (p / ".dna").is_dir():
60
+ p = p / ".dna"
61
+ return f"file://{p}"
62
+ return f"file://{Path('.dna').resolve()}"
63
+
64
+
65
+ async def build_source_from_env(kernel: Any, *, _source_url: str | None = None) -> Any:
66
+ """Build a writable source from the environment (filesystem-only boot
67
+ path — see module docstring). Async for signature uniformity with the
68
+ upstream factory this replaces."""
69
+ url = _source_url or _resolve_source_url()
70
+ parsed = urlparse(url)
71
+ scheme = parsed.scheme or "file"
72
+ if scheme in ("file", "fs", ""):
73
+ from dna.adapters.filesystem.writable import FilesystemWritableSource
74
+
75
+ # Join netloc + path (same rule as source_cmd._resolve_url_to_path):
76
+ # 'fs://./copy' parses as netloc='.', path='/copy' — dropping the
77
+ # netloc silently resolved to the ABSOLUTE '/copy', so `dna source
78
+ # diff fs://./copy` digested a nonexistent dir as {} and reported a
79
+ # bogus "in sync" (found while fixing i-006).
80
+ if parsed.scheme:
81
+ path = (parsed.netloc + parsed.path) if parsed.netloc else parsed.path
82
+ else:
83
+ path = url
84
+ return FilesystemWritableSource(
85
+ path,
86
+ writers=list(getattr(kernel, "active_writers", []) or []),
87
+ kernel=kernel,
88
+ )
89
+ raise click.ClickException(
90
+ f"unsupported DNA_SOURCE_URL scheme '{scheme}://' — the dna CLI "
91
+ f"boots filesystem sources (file:// or a plain path). The sqlite/"
92
+ f"postgres adapters ship in dna.adapters and can be wired "
93
+ f"programmatically via kernel.source(...)."
94
+ )
95
+
96
+
97
+ async def _build_holder_async(scope: str | None = None):
98
+ """Boot a full kernel (every installed extension via entry-points) on
99
+ the caller's event loop and return a holder for ``scope``."""
100
+ from dna.kernel import Kernel
101
+ from dna.adapters.filesystem import FilesystemCache
102
+
103
+ kernel = Kernel.auto()
104
+ url = _resolve_source_url()
105
+ source = await build_source_from_env(kernel, _source_url=url)
106
+ kernel.source(source)
107
+ try:
108
+ kernel.cache(FilesystemCache(str(getattr(source, "base_dir", ".dna"))))
109
+ except Exception: # noqa: BLE001 — cache is optional for CLI reads
110
+ pass
111
+
112
+ resolved = scope or os.getenv("DNA_SCOPE_DEFAULT")
113
+ if resolved is None:
114
+ scopes = await source.list_scopes()
115
+ if not scopes:
116
+ raise click.ClickException(
117
+ f"No scopes found in source ({url}). Create one with a "
118
+ f"manifest.yaml under <base>/<scope>/ first."
119
+ )
120
+ resolved = scopes[0]
121
+
122
+ mi = await kernel.instance_async(resolved, lazy=True)
123
+ return _Holder(kernel, resolved, mi)
124
+
125
+
126
+ class _Holder:
127
+ """Kernel-local stand-in for the upstream MIHolder: the surface the
128
+ CLI commands actually use (.mi / .kernel / .scope / .query_list /
129
+ .get_doc / .reload)."""
130
+
131
+ def __init__(self, kernel: Any, scope: str, mi: Any = None):
132
+ self._kernel = kernel
133
+ self._scope = scope
134
+ self._mi = mi
135
+
136
+ @property
137
+ def kernel(self) -> Any:
138
+ return self._kernel
139
+
140
+ @property
141
+ def scope(self) -> str:
142
+ return self._scope
143
+
144
+ @property
145
+ def mi(self) -> Any:
146
+ if self._mi is None:
147
+ self._mi = self._kernel.instance(self._scope)
148
+ return self._mi
149
+
150
+ def query_list(
151
+ self, kind: str, *, filter: dict | None = None,
152
+ tenant: str | None = None,
153
+ ) -> list:
154
+ effective = tenant if tenant is not None else (os.getenv("DNA_TENANT") or None)
155
+ return self._kernel.query_list_sync(
156
+ self._scope, kind, filter=filter, tenant=effective,
157
+ )
158
+
159
+ def get_doc(self, kind: str, name: str, *, tenant: str | None = None):
160
+ effective = tenant if tenant is not None else (os.getenv("DNA_TENANT") or None)
161
+ return self._kernel.get_document_sync(
162
+ self._scope, kind, name, tenant=effective,
163
+ )
164
+
165
+ def reload(self) -> None:
166
+ self._mi = None
167
+
168
+
169
+ @dataclass
170
+ class Session:
171
+ """Owns the holder + event loop for one CLI command.
172
+
173
+ Use ``s.run(coro)`` to execute async ops on the same loop the
174
+ kernel was built in. Sync helpers (``s.mi``, ``s.kernel``,
175
+ ``s.holder``, ``s.scope``) are passthroughs.
176
+ """
177
+ holder: Any
178
+ _loop: asyncio.AbstractEventLoop
179
+
180
+ @property
181
+ def mi(self):
182
+ return self.holder.mi
183
+
184
+ @property
185
+ def kernel(self):
186
+ return self.holder.kernel
187
+
188
+ @property
189
+ def scope(self) -> str:
190
+ return self.holder.scope
191
+
192
+ def query_list(
193
+ self, kind: str, *, filter: dict | None = None,
194
+ tenant: str | None = None,
195
+ ) -> list:
196
+ return self.holder.query_list(kind, filter=filter, tenant=tenant)
197
+
198
+ def get_doc(self, kind: str, name: str, *, tenant: str | None = None):
199
+ return self.holder.get_doc(kind, name, tenant=tenant)
200
+
201
+ def run(self, coro):
202
+ """Run an async coroutine on the session's event loop."""
203
+ return self._loop.run_until_complete(coro)
204
+
205
+
206
+ @contextmanager
207
+ def dna_session(scope: str | None = None) -> Iterator[Session]:
208
+ """Open a CLI-scoped kernel session (one loop for the whole block)."""
209
+ loop = asyncio.new_event_loop()
210
+ asyncio.set_event_loop(loop)
211
+ holder = None
212
+ try:
213
+ holder = loop.run_until_complete(_build_holder_async(scope))
214
+ # Register the session's loop as the kernel's main loop so sync
215
+ # helpers (query_list_sync / get_document_sync) dispatch onto it
216
+ # instead of spawning fresh loops.
217
+ kernel = getattr(holder, "kernel", None)
218
+ if kernel is not None and hasattr(kernel, "register_main_loop"):
219
+ try:
220
+ kernel.register_main_loop(loop)
221
+ except Exception: # noqa: BLE001 — never block session boot
222
+ pass
223
+ yield Session(holder=holder, _loop=loop)
224
+ finally:
225
+ try:
226
+ pending = asyncio.all_tasks(loop)
227
+ for t in pending:
228
+ t.cancel()
229
+ if pending:
230
+ loop.run_until_complete(
231
+ asyncio.gather(*pending, return_exceptions=True)
232
+ )
233
+ finally:
234
+ loop.close()
235
+
236
+
237
+ def get_holder(scope: str | None = None):
238
+ """Sync helper for read-only commands (loop closed on return)."""
239
+ return asyncio.run(_build_holder_async(scope))
240
+
241
+
242
+ def run_async(coro):
243
+ """Block until ``coro`` completes — sync wrapper around asyncio.
244
+
245
+ Reuses the current thread's event loop when one is already set
246
+ (typical flow: ``dna_client()`` installs a loop, then every
247
+ ``run_async`` call within the with-block uses that same loop).
248
+ Falls back to ``asyncio.run()`` when no loop is set.
249
+ """
250
+ try:
251
+ loop = asyncio.get_event_loop_policy().get_event_loop()
252
+ except RuntimeError:
253
+ loop = None
254
+ if loop is None or loop.is_closed():
255
+ return asyncio.run(coro)
256
+ if loop.is_running():
257
+ return asyncio.run(coro)
258
+ return loop.run_until_complete(coro)
259
+
260
+
261
+ # ─── dna_client — LOCAL facade with the resource-client shape ─────────
262
+
263
+
264
+ class _LocalDocs:
265
+ """``client.docs(scope)`` — document CRUD against the local kernel.
266
+
267
+ Methods are async and run INSIDE the client's event loop, so they use
268
+ the kernel's async surface (``query`` / ``get_document``) — the sync
269
+ wrappers would trip ``_run_sync_helper`` from a running loop."""
270
+
271
+ def __init__(self, client: "_LocalClient", scope: str):
272
+ self._c = client
273
+ self._scope = scope
274
+
275
+ async def list(self, kind: str | None = None) -> dict:
276
+ if not kind:
277
+ return {"items": []}
278
+ kernel = self._c._holder.kernel
279
+ items: list[dict] = []
280
+ async for row in kernel.query(self._scope, kind, tenant=self._c._tenant):
281
+ meta = row.get("metadata") if isinstance(row, dict) else None
282
+ name = (meta or {}).get("name") if isinstance(meta, dict) else None
283
+ name = name or (row.get("name") if isinstance(row, dict) else None)
284
+ items.append({"kind": kind, "metadata": {"name": name}})
285
+ return {"items": items}
286
+
287
+ async def get(self, kind: str, name: str) -> dict:
288
+ raw = await self._c._holder.kernel.get_document(
289
+ self._scope, kind, name, tenant=self._c._tenant,
290
+ )
291
+ if raw is None:
292
+ raise KeyError(f"404 not found: {kind}/{name}")
293
+ return {"raw": raw}
294
+
295
+ async def put(self, kind: str, name: str, raw: dict) -> Any:
296
+ kernel = self._c._holder.kernel
297
+ if self._c._tenant:
298
+ kernel = kernel.with_tenant(self._c._tenant)
299
+ return await kernel.write_document(self._scope, kind, name, raw)
300
+
301
+ async def delete(self, kind: str, name: str) -> Any:
302
+ kernel = self._c._holder.kernel
303
+ if self._c._tenant:
304
+ kernel = kernel.with_tenant(self._c._tenant)
305
+ return await kernel.delete_document(self._scope, kind, name)
306
+
307
+
308
+ class _LocalScopes:
309
+ """``client.scopes`` — scope/kind introspection against the kernel."""
310
+
311
+ def __init__(self, client: "_LocalClient"):
312
+ self._c = client
313
+
314
+ async def list(self) -> list[str]:
315
+ source = getattr(self._c._holder.kernel, "_source", None)
316
+ if source is None:
317
+ return []
318
+ return list(await source.list_scopes())
319
+
320
+ async def tree(self, scope: str) -> dict[str, list[str]]:
321
+ mi = await self._c._holder.kernel.instance_async(scope)
322
+ by_kind: dict[str, list[str]] = {}
323
+ for d in mi.documents:
324
+ by_kind.setdefault(d.kind, []).append(d.name)
325
+ return {k: sorted(v) for k, v in by_kind.items()}
326
+
327
+ async def kinds(self, scope: str) -> dict[str, list]:
328
+ del scope # kinds are kernel-global in the local facade
329
+ kernel = self._c._holder.kernel
330
+ return {kp.kind: [] for kp in kernel._kinds.values() if getattr(kp, "kind", None)}
331
+
332
+ async def kind_schema(self, scope: str, kind: str) -> dict:
333
+ del scope
334
+ kernel = self._c._holder.kernel
335
+ kp = next(
336
+ (k for k in kernel._kinds.values() if getattr(k, "kind", None) == kind),
337
+ None,
338
+ )
339
+ if kp is None:
340
+ raise KeyError(f"404 not found: kind {kind!r}")
341
+ try:
342
+ schema = kp.schema() or {}
343
+ except Exception: # noqa: BLE001
344
+ schema = {}
345
+ sd = getattr(kp, "storage", None)
346
+ return {
347
+ "kind": kp.kind,
348
+ "alias": getattr(kp, "alias", None),
349
+ "api_version": getattr(kp, "api_version", None),
350
+ "display_label": getattr(kp, "display_label", None),
351
+ "schema": schema,
352
+ "is_root": bool(getattr(kp, "is_root", False)),
353
+ "is_runtime_artifact": bool(getattr(kp, "is_runtime_artifact", False)),
354
+ "storage": {
355
+ "marker": getattr(sd, "marker", None),
356
+ "directory": getattr(sd, "directory", None),
357
+ } if sd is not None else None,
358
+ }
359
+
360
+
361
+ class _LocalClient:
362
+ """Local (in-process) twin of the upstream API client surface."""
363
+
364
+ def __init__(self, holder: Any, tenant: str | None):
365
+ self._holder = holder
366
+ self._tenant = tenant
367
+ self.scopes = _LocalScopes(self)
368
+
369
+ def docs(self, scope: str) -> _LocalDocs:
370
+ return _LocalDocs(self, scope)
371
+
372
+
373
+ @contextmanager
374
+ def dna_client(timeout: float = 30.0, tenant: str | None = None):
375
+ """Yield a local-kernel client facade (see class docstrings).
376
+
377
+ ``timeout`` is accepted for signature compatibility and ignored —
378
+ there is no network here. Installs a dedicated event loop for the
379
+ with-block so every ``run_async`` call reuses the SAME loop.
380
+ """
381
+ del timeout
382
+ effective_tenant = tenant if tenant is not None else os.getenv("DNA_TENANT")
383
+ loop = asyncio.new_event_loop()
384
+ asyncio.set_event_loop(loop)
385
+ try:
386
+ holder = loop.run_until_complete(_build_holder_async(None))
387
+ kernel = getattr(holder, "kernel", None)
388
+ if kernel is not None and hasattr(kernel, "register_main_loop"):
389
+ try:
390
+ kernel.register_main_loop(loop)
391
+ except Exception: # noqa: BLE001
392
+ pass
393
+ yield _LocalClient(holder, effective_tenant)
394
+ finally:
395
+ try:
396
+ pending = asyncio.all_tasks(loop)
397
+ for t in pending:
398
+ t.cancel()
399
+ if pending:
400
+ loop.run_until_complete(
401
+ asyncio.gather(*pending, return_exceptions=True)
402
+ )
403
+ finally:
404
+ loop.close()
405
+ asyncio.set_event_loop(None)
406
+
407
+
408
+ # ─── output helpers ───────────────────────────────────────────────────
409
+
410
+
411
+ def print_json(value: Any) -> None:
412
+ """Serialize to stdout. Used as default for machine-readable output."""
413
+ click.echo(json.dumps(value, default=str, indent=2, ensure_ascii=False))
414
+
415
+
416
+ def print_table(rows: list[dict[str, Any]], columns: list[str]) -> None:
417
+ """Render rows as a borderless aligned table (no rich dependency)."""
418
+ if not rows:
419
+ click.echo("(no rows)", err=True)
420
+ return
421
+ widths = {c: max(len(c), max(len(str(r.get(c, ""))) for r in rows)) for c in columns}
422
+ header = " ".join(c.ljust(widths[c]) for c in columns)
423
+ click.echo(header)
424
+ click.echo(" ".join("-" * widths[c] for c in columns))
425
+ for r in rows:
426
+ click.echo(" ".join(str(r.get(c, "")).ljust(widths[c]) for c in columns))
427
+
428
+
429
+ def fail(message: str, code: int = 1) -> "click.exceptions.Exit":
430
+ """Print an error to stderr and exit. Returns an exception instance
431
+ so callers can `raise fail("...")` for static analyzers."""
432
+ click.secho(message, fg="red", err=True)
433
+ sys.exit(code)
@@ -0,0 +1,167 @@
1
+ """Git ↔ SDLC symbiosis — trailer convention + commit queries.
2
+
3
+ The convention (s-sdlc-git-symbiosis)
4
+ -------------------------------------
5
+ Every commit made while a Story is active (``.dna/active-story.txt``,
6
+ written by ``dna sdlc story start``) is stamped with two trailers by the
7
+ versioned ``prepare-commit-msg`` hook (``scripts/git-hooks/``):
8
+
9
+ Work-Item: Story/<story-name>
10
+ Co-Authored-By: dna-sdlc[bot] <dna-sdlc[bot]@users.noreply.github.com>
11
+
12
+ ``Work-Item: <Kind>/<name>`` is the machine-readable link back to the
13
+ SDLC document. The co-author is **the dna sdlc tool itself** (GitHub
14
+ bot-identity convention) — a provenance seal meaning "this commit was
15
+ born under story governance: it has a plan, a timeline, a test gate".
16
+ It is NOT a human co-author; human/agent authorship stays in the normal
17
+ git author + their own trailers. Override the identity with the
18
+ ``DNA_SDLC_COAUTHOR`` env var.
19
+
20
+ No active story → no stamp. Absence is signal too.
21
+
22
+ The way back: ``git log --grep "Work-Item: Story/<name>"`` materializes
23
+ the commit list for a Story with zero manual bookkeeping — surfaced by
24
+ ``dna sdlc story show`` (Commits section) and ``dna sdlc story commits``.
25
+
26
+ This module is the single home for the constants + the (fail-soft) git
27
+ queries. The hook itself is a standalone python3 script (zero deps — it
28
+ must run without any venv); ``tests/test_git_symbiosis_hook.py`` asserts
29
+ the hook's constants match this module so the two can't drift.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ import subprocess
35
+ from importlib.resources import files as _pkg_files
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ WORK_ITEM_TRAILER = "Work-Item"
40
+ COAUTHOR_TRAILER = "Co-Authored-By"
41
+ DEFAULT_SDLC_COAUTHOR = "dna-sdlc[bot] <dna-sdlc[bot]@users.noreply.github.com>"
42
+ COAUTHOR_ENV = "DNA_SDLC_COAUTHOR"
43
+
44
+ #: PR-side twin of the commit trailer (s-sdlc-pr-attribution): the same way
45
+ #: Claude Code signs its PRs, DNA signs the PRs born from its Stories. The
46
+ #: footer goes at the END of the PR body, after a ``---`` rule, and carries
47
+ #: the machine-readable ``Work-Item: <Kind>/<name>`` reference. Override the
48
+ #: whole line via ``$DNA_SDLC_PR_FOOTER`` — a ``{work_item}`` placeholder in
49
+ #: the override is substituted; a literal string is used as-is.
50
+ DEFAULT_PR_FOOTER_TEMPLATE = (
51
+ "\U0001f9ec Tracked with [DNA SDLC](https://github.com/ruinosus/dna) — "
52
+ f"{WORK_ITEM_TRAILER}: {{work_item}}"
53
+ )
54
+ PR_FOOTER_ENV = "DNA_SDLC_PR_FOOTER"
55
+
56
+ #: Repo-relative hooks dir that ``dna sdlc hooks install`` points
57
+ #: ``core.hooksPath`` at. Versioned in-repo so the hook ships with the clone.
58
+ HOOKS_DIR = "scripts/git-hooks"
59
+ HOOK_NAME = "prepare-commit-msg"
60
+
61
+ _GIT_TIMEOUT = 10 # seconds — every git call here is local + bounded
62
+
63
+
64
+ def sdlc_coauthor() -> str:
65
+ """Effective tool-identity co-author (env override → default bot)."""
66
+ return os.environ.get(COAUTHOR_ENV, "").strip() or DEFAULT_SDLC_COAUTHOR
67
+
68
+
69
+ def work_item_ref(kind: str, name: str) -> str:
70
+ """Canonical work-item reference used in the trailer: ``<Kind>/<name>``."""
71
+ return f"{kind}/{name}"
72
+
73
+
74
+ def trailer_lines(kind: str, name: str) -> list[str]:
75
+ """The exact trailer lines the hook stamps for a work item."""
76
+ return [
77
+ f"{WORK_ITEM_TRAILER}: {work_item_ref(kind, name)}",
78
+ f"{COAUTHOR_TRAILER}: {sdlc_coauthor()}",
79
+ ]
80
+
81
+
82
+ def pr_footer(kind: str, name: str) -> str:
83
+ """The one-line PR attribution footer for a work item.
84
+
85
+ ``$DNA_SDLC_PR_FOOTER`` overrides the template; an override without a
86
+ ``{work_item}`` placeholder is emitted verbatim (some hosts want a
87
+ fixed banner), and a malformed format string falls back to verbatim
88
+ too — the footer must never crash PR creation.
89
+ """
90
+ template = os.environ.get(PR_FOOTER_ENV, "").strip() or DEFAULT_PR_FOOTER_TEMPLATE
91
+ try:
92
+ return template.format(work_item=work_item_ref(kind, name))
93
+ except (KeyError, IndexError, ValueError):
94
+ return template
95
+
96
+
97
+ def pr_footer_block(kind: str, name: str) -> str:
98
+ """The footer as it appears at the end of a PR body: ``---`` + footer."""
99
+ return f"---\n{pr_footer(kind, name)}"
100
+
101
+
102
+ def hook_source_path() -> Path:
103
+ """Path of the canonical hook script shipped inside the package.
104
+
105
+ ``scripts/git-hooks/prepare-commit-msg`` in the repo is a byte-identical
106
+ copy (enforced by test) so clones get the hook without installing dna-cli.
107
+ """
108
+ return Path(str(_pkg_files("dna_cli").joinpath("data/git-hooks/prepare-commit-msg")))
109
+
110
+
111
+ def _run_git(args: list[str], *, cwd: str | Path | None = None) -> str | None:
112
+ """Run a git command; return stdout or ``None`` on any failure.
113
+
114
+ Fail-soft by design — callers treat ``None`` as "no git information
115
+ available" (git missing, not a repo, etc). Never raises.
116
+ """
117
+ try:
118
+ proc = subprocess.run(
119
+ ["git", *args],
120
+ capture_output=True, text=True, timeout=_GIT_TIMEOUT,
121
+ cwd=str(cwd) if cwd else None,
122
+ )
123
+ except Exception: # noqa: BLE001 — git absent / timeout / OS error
124
+ return None
125
+ if proc.returncode != 0:
126
+ return None
127
+ return proc.stdout
128
+
129
+
130
+ def repo_root(*, cwd: str | Path | None = None) -> Path | None:
131
+ """Top-level of the enclosing git working tree, or ``None``."""
132
+ out = _run_git(["rev-parse", "--show-toplevel"], cwd=cwd)
133
+ if not out:
134
+ return None
135
+ top = out.strip()
136
+ return Path(top) if top else None
137
+
138
+
139
+ def commits_for_work_item(
140
+ kind: str, name: str, *, cwd: str | Path | None = None,
141
+ ) -> list[dict[str, Any]] | None:
142
+ """Commits whose message carries ``Work-Item: <Kind>/<name>``.
143
+
144
+ Returns ``None`` when git / a repo isn't available (fail-soft), else a
145
+ list (possibly empty) of ``{sha, full_sha, date, subject}`` dicts,
146
+ newest first — straight from ``git log --grep`` on the trailer.
147
+ """
148
+ ref = f"{WORK_ITEM_TRAILER}: {work_item_ref(kind, name)}"
149
+ out = _run_git(
150
+ [
151
+ "log", "--fixed-strings", f"--grep={ref}",
152
+ "--date=short", "--format=%h%x1f%H%x1f%ad%x1f%s",
153
+ ],
154
+ cwd=cwd,
155
+ )
156
+ if out is None:
157
+ return None
158
+ rows: list[dict[str, Any]] = []
159
+ for line in out.splitlines():
160
+ parts = line.split("\x1f")
161
+ if len(parts) != 4:
162
+ continue
163
+ sha, full_sha, date, subject = parts
164
+ rows.append({
165
+ "sha": sha, "full_sha": full_sha, "date": date, "subject": subject,
166
+ })
167
+ return rows