sidegraph 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.
sidegraph/gitio.py ADDED
@@ -0,0 +1,572 @@
1
+ """Git bindings seam (design/superpowers/specs/2026-08-07-git-bindings-design.md) --
2
+ commit trailers (П1, ``sidegraph-prepare-commit-msg``) + line-level blame (П2,
3
+ ``sidegraph-blame``).
4
+
5
+ The ONLY module that runs git subprocesses FOR THESE TWO FEATURES (``capture.
6
+ _capture_commit`` and ``verify._find_repo_root`` already shell out for their own needs --
7
+ review M9; this module never reaches into either). Portable core (``schema``), ``engine/``,
8
+ and ``host/`` stay untouched -- CLI entry points live in ``cli.py``.
9
+
10
+ **Read-only over RECORDS** (§0): neither feature ever creates, mutates, or supersedes a
11
+ store record. Opening a :class:`~sidegraph.store.Store` is NOT filesystem-read-only --
12
+ its ``__init__`` can run an index rebuild (``_refresh_freshness`` ->
13
+ ``_reload_index_from_canonical``, a real write) -- so this module never constructs one.
14
+ Instead :func:`open_index_ro` opens ``index.db`` directly, read-only, via a
15
+ ``file:...?mode=ro&immutable=0`` URI with a short busy-timeout fallback (``timeout=0.5``,
16
+ vs. Python's ``sqlite3`` default of 5.0s against a concurrent writer's lock -- a 5-second
17
+ ``git commit`` stall is the uninstall failure mode as surely as an error). No index
18
+ rebuild ever happens here: if the index is absent or the ro-open fails (including timing
19
+ out), every caller degrades to writing/resolving nothing rather than raising.
20
+
21
+ The prepare-commit-msg hook additionally bounds itself to a hard wall-clock budget (2s,
22
+ :data:`HOOK_WALL_CLOCK_BUDGET_SECONDS`) -- see :func:`collect_prepare_commit_candidates`
23
+ and :func:`apply_prepare_commit_msg`, both of which re-check the deadline between stages
24
+ and give up (write nothing) rather than run over. ``sidegraph-blame`` (П2) is an explicit,
25
+ interactive user command, not a commit-path hook, so it carries no such budget -- but it
26
+ still never opens a writable ``Store()``, for the same read-only-over-records reason.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import re
33
+ import sqlite3
34
+ import subprocess
35
+ import time
36
+ from dataclasses import dataclass
37
+ from datetime import datetime
38
+ from pathlib import Path
39
+ from urllib.parse import quote
40
+
41
+ # Per-subprocess ceiling (several git calls run per hook/blame invocation) -- never the
42
+ # unbounded default.
43
+ _GIT_TIMEOUT_SECONDS = 1.0
44
+
45
+ # §0: sqlite ro-open busy-timeout fallback -- default Python sqlite3 timeout is 5.0s.
46
+ RO_OPEN_TIMEOUT_SECONDS = 0.5
47
+
48
+ # §0/§1 step 3: the hook's hard wall-clock budget -- after this, write nothing, exit 0.
49
+ HOOK_WALL_CLOCK_BUDGET_SECONDS = 2.0
50
+
51
+ # §1 step 1(b): "cap at the 5 strongest by weight."
52
+ STAGED_CANDIDATE_CAP = 5
53
+
54
+ # §1 step 2: candidate title/statement truncation.
55
+ LABEL_MAX_CHARS = 60
56
+
57
+ # §1 step 2: the fixed trailer key П2's blame join literal resolves against.
58
+ TRAILER_KEY = "Sidegraph-Decision"
59
+
60
+ _HEADER_LINE = "Sidegraph: uncomment the trailers this commit actually implements"
61
+
62
+ # П2 output cap (review M8): 50 hunk rows / 6000 chars, whichever comes first.
63
+ BLAME_ROW_CAP = 50
64
+ BLAME_CHAR_CAP = 6000
65
+
66
+
67
+ def _run_git(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str] | None:
68
+ """Run one git subprocess, never raising. ``None`` on any failure (git missing, cwd
69
+ unusable, timeout) -- every caller treats that the same as a clean "nothing to
70
+ report" degrade (G9)."""
71
+ try:
72
+ return subprocess.run(
73
+ ["git", *args],
74
+ cwd=cwd,
75
+ capture_output=True,
76
+ text=True,
77
+ timeout=_GIT_TIMEOUT_SECONDS,
78
+ )
79
+ except (OSError, subprocess.SubprocessError):
80
+ return None
81
+
82
+
83
+ def head_commit(cwd: Path) -> str | None:
84
+ """``git rev-parse HEAD`` -- ``None`` on an empty repo (no commits yet) or any
85
+ failure (review M7: rule (a) and its fallback are skipped entirely when this is
86
+ ``None``)."""
87
+ result = _run_git(["rev-parse", "HEAD"], cwd)
88
+ if result is None or result.returncode != 0:
89
+ return None
90
+ sha = result.stdout.strip()
91
+ return sha or None
92
+
93
+
94
+ def head_commit_time(cwd: Path) -> datetime | None:
95
+ """HEAD's committer timestamp, for rule (a)'s pre-П0 fallback (records with
96
+ ``provenance.commit is None`` created after this count as captured-since-last-commit
97
+ too)."""
98
+ result = _run_git(["log", "-1", "--format=%cI", "HEAD"], cwd)
99
+ if result is None or result.returncode != 0:
100
+ return None
101
+ raw = result.stdout.strip()
102
+ if not raw:
103
+ return None
104
+ try:
105
+ return datetime.fromisoformat(raw)
106
+ except ValueError:
107
+ return None
108
+
109
+
110
+ def staged_files(cwd: Path) -> list[str]:
111
+ """``git diff --cached --name-only`` -- repo-relative paths, deduped order-preserving."""
112
+ result = _run_git(["diff", "--cached", "--name-only"], cwd)
113
+ if result is None or result.returncode != 0:
114
+ return []
115
+ seen: dict[str, None] = {}
116
+ for line in result.stdout.splitlines():
117
+ line = line.strip()
118
+ if line:
119
+ seen.setdefault(line, None)
120
+ return list(seen)
121
+
122
+
123
+ def resolved_comment_char(cwd: Path) -> str | None:
124
+ """Resolve the repo's OWN comment char (§1 step 2, review Major 3). Default ``#``
125
+ when ``core.commentChar`` is unset; a custom char (e.g. ``;``) is used as-is.
126
+
127
+ ``auto`` is special-cased to ``None`` (round-2 corrected rationale): git resolves
128
+ ``auto`` to a real character BEFORE this hook ever runs, but ``git config --get
129
+ core.commentChar`` still echoes back the literal string ``"auto"`` -- the actually
130
+ resolved character is UNKNOWABLE from here, and guessing risks unchosen candidate
131
+ lines surviving as permanent commit content (measured: a ``commit.template`` with a
132
+ leading ``#`` line makes git pick a non-``#`` char, so a ``#``-writing hook's lines
133
+ survive uncommented). Degrade beats guessing -- callers must write nothing when this
134
+ returns ``None``."""
135
+ result = _run_git(["config", "--get", "core.commentChar"], cwd)
136
+ value = result.stdout.strip() if result is not None and result.returncode == 0 else ""
137
+ if not value:
138
+ return "#"
139
+ if value == "auto":
140
+ return None
141
+ return value
142
+
143
+
144
+ def open_index_ro(store_dir: Path) -> sqlite3.Connection | None:
145
+ """Open ``<store_dir>/index.db`` strictly read-only, URI ``mode=ro&immutable=0``,
146
+ with :data:`RO_OPEN_TIMEOUT_SECONDS` as the busy-timeout fallback (§0). ``None`` on
147
+ ANY failure -- index absent, ro-open denied, or a concurrent writer's lock not
148
+ released inside the timeout -- so every caller degrades to writing/resolving
149
+ nothing rather than blocking or raising. Never rebuilds the index; never write-opens
150
+ the store."""
151
+ index_path = Path(store_dir) / "index.db"
152
+ uri = f"file:{quote(str(index_path))}?mode=ro&immutable=0"
153
+ try:
154
+ conn = sqlite3.connect(uri, uri=True, timeout=RO_OPEN_TIMEOUT_SECONDS)
155
+ conn.row_factory = sqlite3.Row
156
+ # Force the lock check now (connect() itself doesn't touch the file), inside the
157
+ # timeout window above, rather than deferring it silently to the caller's first
158
+ # query.
159
+ conn.execute("SELECT 1")
160
+ except sqlite3.Error:
161
+ return None
162
+ return conn
163
+
164
+
165
+ # ============================================================================
166
+ # П1 -- sidegraph-prepare-commit-msg
167
+ # ============================================================================
168
+
169
+
170
+ @dataclass
171
+ class RecordCandidate:
172
+ id: str
173
+ kind: str # "decision" | "fact"
174
+ label: str # title (decision) or statement (fact), already truncated
175
+
176
+
177
+ def _truncate(text: str, limit: int = LABEL_MAX_CHARS) -> str:
178
+ text = " ".join(text.split())
179
+ if len(text) <= limit:
180
+ return text
181
+ return text[: max(limit - 1, 0)].rstrip() + "…"
182
+
183
+
184
+ def _load_json(raw: str) -> dict | None:
185
+ try:
186
+ return json.loads(raw)
187
+ except (TypeError, ValueError):
188
+ return None
189
+
190
+
191
+ def _rule_a_candidates(conn: sqlite3.Connection, cwd: Path) -> dict[str, RecordCandidate]:
192
+ """Rule (a): records (decisions AND facts -- П0) whose ``provenance.commit == HEAD``,
193
+ plus the pre-П0 timestamp fallback for records with no commit stamp at all. Skipped
194
+ entirely when there is no HEAD yet (empty repo, review M7)."""
195
+ out: dict[str, RecordCandidate] = {}
196
+ head = head_commit(cwd)
197
+ if head is None:
198
+ return out
199
+ head_time = head_commit_time(cwd)
200
+ for table, kind, label_field in (
201
+ ("decisions", "decision", "title"),
202
+ ("facts", "fact", "statement"),
203
+ ):
204
+ for row in conn.execute(f"SELECT data FROM {table}"):
205
+ rec = _load_json(row["data"])
206
+ if rec is None:
207
+ continue
208
+ provenance = rec.get("provenance") or {}
209
+ commit = provenance.get("commit")
210
+ matched = False
211
+ if commit == head:
212
+ matched = True
213
+ elif commit is None and head_time is not None:
214
+ valid_from_raw = rec.get("valid_from")
215
+ if valid_from_raw:
216
+ try:
217
+ valid_from = datetime.fromisoformat(valid_from_raw)
218
+ except ValueError:
219
+ valid_from = None
220
+ if valid_from is not None and valid_from > head_time:
221
+ matched = True
222
+ if matched:
223
+ rid = rec.get("id")
224
+ if rid:
225
+ out[rid] = RecordCandidate(
226
+ id=rid, kind=kind, label=_truncate(rec.get(label_field) or "")
227
+ )
228
+ return out
229
+
230
+
231
+ def _rule_b_candidates(conn: sqlite3.Connection, cwd: Path) -> dict[str, RecordCandidate]:
232
+ """Rule (b): staged-file bindings (review Major 4). The entity scan runs ONCE,
233
+ bucketed by path -- never per staged file. Entities with no ``descriptor.file_path``
234
+ (Tier-0/Tier-1, abstract/domain) never enter the bucket map at all, so their
235
+ bindings are never even looked up -- they cannot consume the cap. Decisions-only
236
+ (rule (b) never offers a fact): a matched binding whose ``record_id`` isn't in the
237
+ ``decisions`` table is silently skipped."""
238
+ out: dict[str, RecordCandidate] = {}
239
+ paths = staged_files(cwd)
240
+ if not paths:
241
+ return out
242
+ staged = set(paths)
243
+
244
+ # ONE scan, bucketed by path -- filter to path-carrying entities FIRST.
245
+ by_path: dict[str, list[str]] = {}
246
+ for row in conn.execute("SELECT data FROM entities"):
247
+ entity = _load_json(row["data"])
248
+ if entity is None:
249
+ continue
250
+ descriptor = entity.get("descriptor") or {}
251
+ file_path = descriptor.get("file_path")
252
+ if not file_path:
253
+ continue # path-less (Tier-0/Tier-1) -- never enters the map
254
+ entity_id = entity.get("entity_id")
255
+ if entity_id:
256
+ by_path.setdefault(file_path, []).append(entity_id)
257
+
258
+ matched_entity_ids: set[str] = set()
259
+ for p in staged:
260
+ matched_entity_ids.update(by_path.get(p, []))
261
+ if not matched_entity_ids:
262
+ return out
263
+
264
+ # Gather bindings ONLY from matched (path-carrying, staged) entities -- a Tier-0/1
265
+ # binding was never fetched in the first place, so it can never consume the cap.
266
+ best_weight: dict[str, float] = {}
267
+ for entity_id in matched_entity_ids:
268
+ for row in conn.execute(
269
+ "SELECT data FROM anchor_bindings WHERE entity_id = ?", (entity_id,)
270
+ ):
271
+ binding = _load_json(row["data"])
272
+ if binding is None:
273
+ continue
274
+ record_id = binding.get("record_id")
275
+ if not record_id:
276
+ continue
277
+ weight = binding.get("weight", 1.0)
278
+ if record_id not in best_weight or weight > best_weight[record_id]:
279
+ best_weight[record_id] = weight
280
+
281
+ # THEN cap -- filtering already happened above; this must never run before it.
282
+ top_ids = sorted(best_weight, key=lambda rid: best_weight[rid], reverse=True)[
283
+ :STAGED_CANDIDATE_CAP
284
+ ]
285
+ for record_id in top_ids:
286
+ row = conn.execute("SELECT data FROM decisions WHERE id = ?", (record_id,)).fetchone()
287
+ if row is None:
288
+ continue # rule (b) is decisions-only
289
+ decision = _load_json(row["data"])
290
+ if decision is None:
291
+ continue
292
+ out[record_id] = RecordCandidate(
293
+ id=record_id, kind="decision", label=_truncate(decision.get("title") or "")
294
+ )
295
+ return out
296
+
297
+
298
+ def collect_prepare_commit_candidates(
299
+ store_dir: Path, cwd: Path, deadline: float
300
+ ) -> list[RecordCandidate]:
301
+ """Rules (a) + (b), deduped by id. Returns ``[]`` on every degrade condition (index
302
+ absent/locked, no HEAD, no staged files, deadline exceeded) -- never raises."""
303
+ conn = open_index_ro(store_dir)
304
+ if conn is None:
305
+ return []
306
+ try:
307
+ candidates: dict[str, RecordCandidate] = {}
308
+ if time.monotonic() < deadline:
309
+ candidates.update(_rule_a_candidates(conn, cwd))
310
+ if time.monotonic() < deadline:
311
+ for rid, cand in _rule_b_candidates(conn, cwd).items():
312
+ candidates.setdefault(rid, cand)
313
+ return list(candidates.values())
314
+ finally:
315
+ conn.close()
316
+
317
+
318
+ def render_candidate_block(candidates: list[RecordCandidate], char: str) -> str:
319
+ lines = [f"{char} {_HEADER_LINE}"]
320
+ for c in candidates:
321
+ lines.append(f"{char} {TRAILER_KEY}: {c.id} — {c.label}")
322
+ return "\n".join(lines) + "\n"
323
+
324
+
325
+ def apply_prepare_commit_msg(message_file: str, cwd: Path, store_dir: Path) -> None:
326
+ """Write commented candidate trailers into ``message_file`` (§1). No-op (never
327
+ raises, never blocks past :data:`HOOK_WALL_CLOCK_BUDGET_SECONDS`) when there is
328
+ nothing to offer, the comment char can't be resolved (``auto``), or the deadline is
329
+ already spent.
330
+
331
+ The block is APPENDED, never prepended (review Major 1, measured against real git
332
+ 2.54, ``commit.cleanup=strip`` -- the default for an editor-invoked commit, exactly
333
+ what a ``prepare-commit-msg`` hook firing with ``source`` absent implies). A
334
+ prepended block puts the post-uncomment trailer line directly above the subject
335
+ with no blank line between them; git's cleanup folds the two into ONE paragraph
336
+ (the subject's own), and trailers are only ever recognized in the message's LAST
337
+ paragraph -- so the documented "uncomment the line" gesture would silently produce
338
+ a commit with no real trailer at all. Appending after the original content puts the
339
+ (blank-line-separated) trailer paragraph last, where git actually looks."""
340
+ deadline = time.monotonic() + HOOK_WALL_CLOCK_BUDGET_SECONDS
341
+ candidates = collect_prepare_commit_candidates(store_dir, cwd, deadline)
342
+ if not candidates or time.monotonic() >= deadline:
343
+ return
344
+ char = resolved_comment_char(cwd)
345
+ if char is None:
346
+ return # "auto" -- unresolvable, never guess (see resolved_comment_char)
347
+ if time.monotonic() >= deadline:
348
+ return
349
+ try:
350
+ original = Path(message_file).read_text(encoding="utf-8")
351
+ except OSError:
352
+ return
353
+ block = render_candidate_block(candidates, char)
354
+ try:
355
+ Path(message_file).write_text(original.rstrip("\n") + "\n\n" + block, encoding="utf-8")
356
+ except OSError:
357
+ return
358
+
359
+
360
+ # ============================================================================
361
+ # П2 -- sidegraph-blame
362
+ # ============================================================================
363
+
364
+
365
+ @dataclass
366
+ class Hunk:
367
+ start: int
368
+ end: int
369
+ sha: str
370
+
371
+
372
+ _BLAME_HEADER_RE = re.compile(r"^([0-9a-f]{40}) \d+ (\d+)(?: \d+)?$")
373
+
374
+
375
+ def blame_hunks(
376
+ path: str, cwd: Path, line_range: tuple[int, int] | None = None
377
+ ) -> list[Hunk] | None:
378
+ """``git blame --porcelain [-L A,B] -- <path>`` -> merged contiguous-sha hunks.
379
+ ``None`` on any git failure (not a repo, unknown file, git absent -- G9's CLI error
380
+ surface)."""
381
+ args = ["blame", "--porcelain"]
382
+ if line_range is not None:
383
+ args += ["-L", f"{line_range[0]},{line_range[1]}"]
384
+ args += ["--", path]
385
+ result = _run_git(args, cwd)
386
+ if result is None or result.returncode != 0:
387
+ return None
388
+ entries: list[tuple[int, str]] = []
389
+ for line in result.stdout.splitlines():
390
+ m = _BLAME_HEADER_RE.match(line)
391
+ if m:
392
+ entries.append((int(m.group(2)), m.group(1)))
393
+ entries.sort(key=lambda e: e[0])
394
+ hunks: list[Hunk] = []
395
+ for final_line, sha in entries:
396
+ if hunks and hunks[-1].sha == sha and hunks[-1].end == final_line - 1:
397
+ hunks[-1].end = final_line
398
+ else:
399
+ hunks.append(Hunk(start=final_line, end=final_line, sha=sha))
400
+ return hunks
401
+
402
+
403
+ def commit_date(sha: str, cwd: Path) -> str | None:
404
+ result = _run_git(["log", "-1", "--format=%cI", sha], cwd)
405
+ if result is None or result.returncode != 0:
406
+ return None
407
+ return result.stdout.strip() or None
408
+
409
+
410
+ def trailer_decision_ids(sha: str, cwd: Path) -> list[str]:
411
+ """§2 step 2, trailer join -- corrected literal (review Major 2, verified against
412
+ git 2.54): ``valueonly`` singular. The spec-rev-1 ``valuesonly`` is not a recognized
413
+ git option and git echoes the literal placeholder string back instead of erroring --
414
+ a silent-failure literal this exact spelling avoids.
415
+
416
+ §1's rendered candidate line is ``Sidegraph-Decision: <id> — <label>`` -- the label
417
+ is there to help a human pick which candidate to keep, not part of the trailer's
418
+ real value, but a straight "uncomment the line" (the documented gesture) makes the
419
+ literal trailer value ``"<id> — <label>"``, decorative suffix included (measured,
420
+ end-to-end smoke). Each comma-separated value is trimmed to its leading token up to
421
+ the first `` — `` / `` -- `` so the join still resolves whether or not a human
422
+ bothered to hand-trim the label first."""
423
+ result = _run_git(
424
+ [
425
+ "log",
426
+ "-1",
427
+ f"--format=%(trailers:key={TRAILER_KEY},valueonly,separator=%x2C)",
428
+ sha,
429
+ ],
430
+ cwd,
431
+ )
432
+ if result is None or result.returncode != 0:
433
+ return []
434
+ raw = result.stdout.strip()
435
+ if not raw:
436
+ return []
437
+ ids: list[str] = []
438
+ for part in raw.split(","):
439
+ part = part.strip()
440
+ if not part:
441
+ continue
442
+ part = re.split(r"\s+—\s+|\s+--\s+", part, maxsplit=1)[0].strip()
443
+ if part:
444
+ ids.append(part)
445
+ return ids
446
+
447
+
448
+ @dataclass
449
+ class ResolvedRecord:
450
+ id: str
451
+ kind: str | None # "decision" | "fact" | None (unresolved)
452
+ label: str = ""
453
+ superseded_by: str | None = None
454
+ resolved: bool = True
455
+
456
+
457
+ def _all_records(conn: sqlite3.Connection) -> dict[str, dict]:
458
+ """Every decision + fact row, keyed by id, parsed once."""
459
+ out: dict[str, dict] = {}
460
+ for table in ("decisions", "facts"):
461
+ for row in conn.execute(f"SELECT data FROM {table}"):
462
+ rec = _load_json(row["data"])
463
+ if rec is not None and rec.get("id"):
464
+ rec["_kind"] = "decision" if table == "decisions" else "fact"
465
+ out[rec["id"]] = rec
466
+ return out
467
+
468
+
469
+ def _supersessions(records: dict[str, dict]) -> dict[str, str]:
470
+ """predecessor id -> successor id, from every record's own ``supersedes`` field."""
471
+ out: dict[str, str] = {}
472
+ for rec in records.values():
473
+ pred = rec.get("supersedes")
474
+ if pred:
475
+ out[pred] = rec["id"]
476
+ return out
477
+
478
+
479
+ def records_by_commit(conn: sqlite3.Connection) -> dict[str, list[dict]]:
480
+ """§2 step 2, provenance join -- decisions AND facts (post-П0) whose
481
+ ``provenance.commit == sha``, bucketed once (not per hunk/sha)."""
482
+ out: dict[str, list[dict]] = {}
483
+ records = _all_records(conn)
484
+ for rec in records.values():
485
+ commit = (rec.get("provenance") or {}).get("commit")
486
+ if commit:
487
+ out.setdefault(commit, []).append(rec)
488
+ return out
489
+
490
+
491
+ def resolve_sha(
492
+ sha: str,
493
+ cwd: Path,
494
+ records: dict[str, dict],
495
+ supersessions: dict[str, str],
496
+ by_commit: dict[str, list[dict]],
497
+ ) -> list[ResolvedRecord]:
498
+ """Both join paths, deduped by id (§2 step 2/4): trailers first, then provenance-only
499
+ additions. A resolved record whose id is now superseded prints its ``superseded_by``
500
+ pointer (§2 step 4) -- blame surfaces history, the chain does the rest."""
501
+ ids: dict[str, None] = {}
502
+ for rid in trailer_decision_ids(sha, cwd):
503
+ ids.setdefault(rid, None)
504
+ for rec in by_commit.get(sha, []):
505
+ ids.setdefault(rec["id"], None)
506
+
507
+ out: list[ResolvedRecord] = []
508
+ for rid in ids:
509
+ resolved_rec = records.get(rid)
510
+ if resolved_rec is None:
511
+ out.append(ResolvedRecord(id=rid, kind=None, resolved=False))
512
+ continue
513
+ label_field = "title" if resolved_rec["_kind"] == "decision" else "statement"
514
+ out.append(
515
+ ResolvedRecord(
516
+ id=rid,
517
+ kind=resolved_rec["_kind"],
518
+ label=_truncate(resolved_rec.get(label_field) or "", limit=80),
519
+ superseded_by=supersessions.get(rid),
520
+ )
521
+ )
522
+ return out
523
+
524
+
525
+ @dataclass
526
+ class BlameHunkResult:
527
+ start: int
528
+ end: int
529
+ sha: str
530
+ date: str | None
531
+ records: list[ResolvedRecord]
532
+
533
+
534
+ def blame_report(
535
+ path: str, cwd: Path, store_dir: Path, line_range: tuple[int, int] | None = None
536
+ ) -> list[BlameHunkResult] | None:
537
+ """The full П2 pipeline: git blame -> per-distinct-sha join (trailers + provenance)
538
+ -> resolved records, in hunk (line-range) order -- the single entry point
539
+ ``cli.blame_main`` calls; formatting/capping/JSON stay in ``cli.py``.
540
+
541
+ ``None`` only when git blame itself fails (not a repo, unknown file/range, git
542
+ missing -- G9's CLI error surface). A missing or lock-timed-out index degrades to
543
+ every hunk resolving to nothing (``ResolvedRecord.resolved=False`` is never used
544
+ here for that case -- an index-less run simply can't join at all, so every hunk's
545
+ ``records`` list comes back empty) rather than a hard failure: blame without a store
546
+ is still useful blame."""
547
+ hunks = blame_hunks(path, cwd, line_range)
548
+ if hunks is None:
549
+ return None
550
+ conn = open_index_ro(store_dir)
551
+ records: dict[str, dict] = {}
552
+ supersessions: dict[str, str] = {}
553
+ by_commit: dict[str, list[dict]] = {}
554
+ if conn is not None:
555
+ try:
556
+ records = _all_records(conn)
557
+ supersessions = _supersessions(records)
558
+ by_commit = records_by_commit(conn)
559
+ finally:
560
+ conn.close()
561
+ date_cache: dict[str, str | None] = {}
562
+ out: list[BlameHunkResult] = []
563
+ for h in hunks:
564
+ if h.sha not in date_cache:
565
+ date_cache[h.sha] = commit_date(h.sha, cwd)
566
+ resolved = resolve_sha(h.sha, cwd, records, supersessions, by_commit)
567
+ out.append(
568
+ BlameHunkResult(
569
+ start=h.start, end=h.end, sha=h.sha, date=date_cache[h.sha], records=resolved
570
+ )
571
+ )
572
+ return out
@@ -0,0 +1,7 @@
1
+ """Host seam — Claude Code integration, isolated from the portable core.
2
+
3
+ v1 ships Claude-Code-only. The core (store + retrieval + MCP) never imports from here; this
4
+ package holds the host-specific hooks (SessionStart / Stop / PreToolUse) so a second host
5
+ (Cursor, Cline, plain MCP) is *possible* later without touching the core. Multi-host support
6
+ is explicitly deferred past v1.
7
+ """