graphite-code 0.3.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 (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
graphite/graph_io.py ADDED
@@ -0,0 +1,188 @@
1
+ """Bounded, validated reads for untrusted Graphite graph artifacts."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import stat
7
+ from pathlib import Path
8
+ from typing import Any, Final
9
+
10
+ from .graph import graph_from_json
11
+ from .validation import validate_graph_bundle
12
+
13
+ MAX_GRAPH_BYTES: Final = 128 * 1024 * 1024
14
+ _REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
15
+ _ERROR_CODES = frozenset(
16
+ {
17
+ "graph_root_invalid",
18
+ "graph_outside_root",
19
+ "graph_missing",
20
+ "graph_reparse",
21
+ "graph_not_regular",
22
+ "graph_too_large",
23
+ "graph_changed",
24
+ "graph_unreadable",
25
+ "graph_invalid_utf8",
26
+ "graph_invalid_json",
27
+ "graph_invalid",
28
+ }
29
+ )
30
+
31
+
32
+ class GraphReadError(RuntimeError):
33
+ """A stable, path-free graph-read failure."""
34
+
35
+ def __init__(self, code: str) -> None:
36
+ if code not in _ERROR_CODES:
37
+ code = "graph_unreadable"
38
+ self.code = code
39
+ super().__init__(code)
40
+
41
+
42
+ def _is_reparse_point(metadata: os.stat_result) -> bool:
43
+ return bool(getattr(metadata, "st_file_attributes", 0) & _REPARSE_POINT)
44
+
45
+
46
+ def _stable_signature(metadata: os.stat_result) -> tuple[int, int, int, int]:
47
+ return (
48
+ int(metadata.st_dev),
49
+ int(metadata.st_ino),
50
+ int(metadata.st_size),
51
+ int(metadata.st_mtime_ns),
52
+ )
53
+
54
+
55
+ def _resolve_root(root: Path) -> Path:
56
+ try:
57
+ metadata = root.lstat()
58
+ except OSError as exc:
59
+ raise GraphReadError("graph_root_invalid") from exc
60
+ if stat.S_ISLNK(metadata.st_mode) or _is_reparse_point(metadata):
61
+ raise GraphReadError("graph_root_invalid")
62
+ if not stat.S_ISDIR(metadata.st_mode):
63
+ raise GraphReadError("graph_root_invalid")
64
+ try:
65
+ return root.resolve(strict=True)
66
+ except OSError as exc:
67
+ raise GraphReadError("graph_root_invalid") from exc
68
+
69
+
70
+ def _resolve_candidate(path: Path, root: Path) -> Path:
71
+ candidate = path if path.is_absolute() else root / path
72
+ try:
73
+ lexical_relative = candidate.absolute().relative_to(root)
74
+ except ValueError as exc:
75
+ raise GraphReadError("graph_outside_root") from exc
76
+ if ".." in lexical_relative.parts:
77
+ raise GraphReadError("graph_outside_root")
78
+ current = root
79
+ for part in lexical_relative.parts[:-1]:
80
+ current /= part
81
+ try:
82
+ parent_metadata = current.lstat()
83
+ except FileNotFoundError as exc:
84
+ raise GraphReadError("graph_missing") from exc
85
+ except OSError as exc:
86
+ raise GraphReadError("graph_unreadable") from exc
87
+ if stat.S_ISLNK(parent_metadata.st_mode) or _is_reparse_point(parent_metadata):
88
+ raise GraphReadError("graph_reparse")
89
+ try:
90
+ lexical_metadata = candidate.lstat()
91
+ except FileNotFoundError as exc:
92
+ raise GraphReadError("graph_missing") from exc
93
+ except OSError as exc:
94
+ raise GraphReadError("graph_unreadable") from exc
95
+ if stat.S_ISLNK(lexical_metadata.st_mode) or _is_reparse_point(lexical_metadata):
96
+ raise GraphReadError("graph_reparse")
97
+ try:
98
+ resolved = candidate.resolve(strict=True)
99
+ resolved.relative_to(root)
100
+ except ValueError as exc:
101
+ raise GraphReadError("graph_outside_root") from exc
102
+ except FileNotFoundError as exc:
103
+ raise GraphReadError("graph_missing") from exc
104
+ except OSError as exc:
105
+ raise GraphReadError("graph_unreadable") from exc
106
+ return resolved
107
+
108
+
109
+ def _read_stable_bytes(path: Path, *, max_bytes: int) -> bytes:
110
+ try:
111
+ before = path.lstat()
112
+ except FileNotFoundError as exc:
113
+ raise GraphReadError("graph_missing") from exc
114
+ except OSError as exc:
115
+ raise GraphReadError("graph_unreadable") from exc
116
+ if stat.S_ISLNK(before.st_mode) or _is_reparse_point(before):
117
+ raise GraphReadError("graph_reparse")
118
+ if not stat.S_ISREG(before.st_mode):
119
+ raise GraphReadError("graph_not_regular")
120
+ if before.st_size > max_bytes:
121
+ raise GraphReadError("graph_too_large")
122
+
123
+ flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
124
+ try:
125
+ descriptor = os.open(path, flags)
126
+ try:
127
+ opened = os.fstat(descriptor)
128
+ if _stable_signature(opened) != _stable_signature(before):
129
+ raise GraphReadError("graph_changed")
130
+ chunks: list[bytes] = []
131
+ remaining = max_bytes + 1
132
+ while remaining > 0:
133
+ chunk = os.read(descriptor, min(64 * 1024, remaining))
134
+ if not chunk:
135
+ break
136
+ chunks.append(chunk)
137
+ remaining -= len(chunk)
138
+ after = os.fstat(descriptor)
139
+ finally:
140
+ os.close(descriptor)
141
+ except GraphReadError:
142
+ raise
143
+ except FileNotFoundError as exc:
144
+ raise GraphReadError("graph_missing") from exc
145
+ except OSError as exc:
146
+ raise GraphReadError("graph_unreadable") from exc
147
+
148
+ data = b"".join(chunks)
149
+ if len(data) > max_bytes:
150
+ raise GraphReadError("graph_too_large")
151
+ if _stable_signature(after) != _stable_signature(opened):
152
+ raise GraphReadError("graph_changed")
153
+ return data
154
+
155
+
156
+ def load_validated_graph_bundle(
157
+ path: Path,
158
+ *,
159
+ root: Path,
160
+ max_bytes: int = MAX_GRAPH_BYTES,
161
+ ) -> tuple[dict[str, Any], Any]:
162
+ """Read, validate, and construct a graph inside the selected root."""
163
+ if isinstance(max_bytes, bool) or max_bytes <= 0 or max_bytes > MAX_GRAPH_BYTES:
164
+ raise GraphReadError("graph_too_large")
165
+ selected_root = _resolve_root(root)
166
+ graph_path = _resolve_candidate(path, selected_root)
167
+ raw = _read_stable_bytes(graph_path, max_bytes=max_bytes)
168
+ try:
169
+ text = raw.decode("utf-8")
170
+ except UnicodeDecodeError as exc:
171
+ raise GraphReadError("graph_invalid_utf8") from exc
172
+ try:
173
+ bundle = json.loads(text)
174
+ except (json.JSONDecodeError, RecursionError) as exc:
175
+ raise GraphReadError("graph_invalid_json") from exc
176
+ if not isinstance(bundle, dict):
177
+ raise GraphReadError("graph_invalid")
178
+ try:
179
+ report = validate_graph_bundle(bundle)
180
+ except (AttributeError, KeyError, RecursionError, TypeError, ValueError) as exc:
181
+ raise GraphReadError("graph_invalid") from exc
182
+ if not report.get("ok"):
183
+ raise GraphReadError("graph_invalid")
184
+ try:
185
+ graph = graph_from_json(bundle)
186
+ except (AttributeError, KeyError, RecursionError, TypeError, ValueError) as exc:
187
+ raise GraphReadError("graph_invalid") from exc
188
+ return bundle, graph
graphite/health.py ADDED
@@ -0,0 +1,147 @@
1
+ """Resolution-health ("trust") signal computed from the canonical graph.
2
+
3
+ Pure arithmetic over the loaded graph — no inference, no I/O in
4
+ resolution_health itself. persisted_resolution is the fail-open reader for
5
+ consumers that must not pay a full graph load (check, strict hook).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path, PurePosixPath
11
+ from typing import Any, Callable, Final
12
+
13
+ import networkx as nx
14
+
15
+ from .graph import edge_relations
16
+
17
+ RESOLUTION_HEALTHY_RATIO: Final = 0.8
18
+
19
+ _COUNTED_RELATIONS: Final = ("calls", "imports")
20
+
21
+ # Per-relation marker for "this edge leaves the repo". An edge counts as
22
+ # external ONLY when it also failed to bind -- externality excuses an unbound
23
+ # edge, it never removes a bound one (spec §5).
24
+ _EXTERNAL_CONFIDENCE: Final = {
25
+ "imports": "EXTERNAL_IMPORT",
26
+ "calls": "EXTERNAL_CALL",
27
+ }
28
+
29
+ _EXTENSION_LANGUAGES: Final = {
30
+ ".py": "python",
31
+ ".ts": "typescript",
32
+ ".tsx": "typescript",
33
+ ".mts": "typescript",
34
+ ".cts": "typescript",
35
+ ".js": "javascript",
36
+ ".jsx": "javascript",
37
+ ".mjs": "javascript",
38
+ ".cjs": "javascript",
39
+ ".go": "go",
40
+ ".rs": "rust",
41
+ }
42
+
43
+ _MAX_ANALYSIS_BYTES: Final = 64 * 1024 * 1024
44
+
45
+
46
+ def _edge_language(source_file: object) -> str:
47
+ if not isinstance(source_file, str) or not source_file:
48
+ return "other"
49
+ suffix = PurePosixPath(source_file).suffix.lower()
50
+ return _EXTENSION_LANGUAGES.get(suffix, "other")
51
+
52
+
53
+ def _cell(bound: int, total: int, external: int) -> dict[str, Any]:
54
+ return {
55
+ "total": total,
56
+ "bound": bound,
57
+ "ratio": None if total == 0 else round(bound / total, 3),
58
+ "external": external,
59
+ }
60
+
61
+
62
+ def resolution_health(g: nx.DiGraph) -> dict[str, Any]:
63
+ """Measured resolver health: bound-edge ratios per relation and language."""
64
+ node_total = g.number_of_nodes()
65
+ unknown_nodes = sum(
66
+ 1 for _n, data in g.nodes(data=True) if data.get("kind", "unknown") == "unknown"
67
+ )
68
+ relation_counts = {rel: [0, 0, 0] for rel in _COUNTED_RELATIONS} # [bound, total, external]
69
+ language_counts: dict[str, dict[str, list[int]]] = {}
70
+ for _u, v, data in g.edges(data=True):
71
+ # A merged edge carries every relation of its node pair; counting only
72
+ # `relation` would drop a measured relation that collided with an
73
+ # earlier-sorting one (#1).
74
+ for relation in edge_relations(data):
75
+ counts = relation_counts.get(relation)
76
+ if counts is None:
77
+ continue
78
+ language = _edge_language(data.get("source_file"))
79
+ buckets = language_counts.setdefault(
80
+ language, {rel: [0, 0, 0] for rel in _COUNTED_RELATIONS}
81
+ )
82
+ bound = int(g.nodes[v].get("kind", "unknown") != "unknown")
83
+ if not bound and data.get("confidence") == _EXTERNAL_CONFIDENCE.get(relation):
84
+ counts[2] += 1
85
+ buckets[relation][2] += 1
86
+ continue
87
+ counts[0] += bound
88
+ counts[1] += 1
89
+ buckets[relation][0] += bound
90
+ buckets[relation][1] += 1
91
+ by_relation = {rel: _cell(c[0], c[1], c[2]) for rel, c in relation_counts.items()}
92
+ by_language = {
93
+ language: {rel: _cell(c[0], c[1], c[2]) for rel, c in buckets.items()}
94
+ for language, buckets in sorted(language_counts.items())
95
+ }
96
+ ratios = [cell["ratio"] for cell in by_relation.values() if cell["ratio"] is not None]
97
+ return {
98
+ "schema": 3,
99
+ "placeholder_nodes": {
100
+ "total": node_total,
101
+ "unknown": unknown_nodes,
102
+ "share": None if node_total == 0 else round(unknown_nodes / node_total, 3),
103
+ },
104
+ "by_relation": by_relation,
105
+ "by_language": by_language,
106
+ "healthy": all(ratio >= RESOLUTION_HEALTHY_RATIO for ratio in ratios),
107
+ "threshold": RESOLUTION_HEALTHY_RATIO,
108
+ }
109
+
110
+
111
+ def ratio_percent(block: dict[str, Any], relation: str) -> str:
112
+ """Human rendering of one relation's bound ratio: '4.6%' or 'n/a'."""
113
+ try:
114
+ ratio = block["by_relation"][relation]["ratio"]
115
+ except (KeyError, TypeError):
116
+ return "n/a"
117
+ if not isinstance(ratio, (int, float)):
118
+ return "n/a"
119
+ return f"{ratio * 100:.1f}%"
120
+
121
+
122
+ def persisted_resolution(
123
+ root: Path, on_error: Callable[[Exception], None] | None = None
124
+ ) -> dict[str, Any] | None:
125
+ """Fail-open read of the persisted block from graph-out/.graphite_analysis.json.
126
+
127
+ ``on_error`` fires only for MALFORMED content (ValueError/RecursionError) —
128
+ absence or unreadability is not malformation and stays silent.
129
+ """
130
+ path = root / "graph-out" / ".graphite_analysis.json"
131
+ try:
132
+ if not path.is_file() or path.stat().st_size > _MAX_ANALYSIS_BYTES:
133
+ return None
134
+ data = json.loads(path.read_text(encoding="utf-8"))
135
+ except OSError:
136
+ return None
137
+ except (ValueError, RecursionError) as exc:
138
+ if on_error is not None:
139
+ try:
140
+ on_error(exc)
141
+ except Exception:
142
+ pass
143
+ return None
144
+ if not isinstance(data, dict):
145
+ return None
146
+ block = data.get("resolution_health")
147
+ return block if isinstance(block, dict) else None
graphite/hook_entry.py ADDED
@@ -0,0 +1,68 @@
1
+ """The fast path a git-hook trampoline execs.
2
+
3
+ **This module MUST NOT import `graphite.cli`.** Measured on Windows, best of 3:
4
+
5
+ bare python 79 ms
6
+ import graphite 86 ms (__init__ is nearly free)
7
+ import graphite.detach 137 ms
8
+ import graphite.cli 1281 ms
9
+
10
+ A trampoline that reached the CLI would put ~1.2s on *every commit* purely to
11
+ spawn a background build. The cost is specifically `cli.py`, not "importing
12
+ graphite at all", which is what makes a Python hook entry viable in the first
13
+ place. `tests/test_hook_entry.py` enforces this from a subprocess -- an
14
+ in-process check would false-pass, since pytest imports the CLI elsewhere.
15
+
16
+ Deliberately does NOT take the build lock. `cmd_build` already acquires it in
17
+ the child; holding it here would mean the detached child finds the lock held by
18
+ its own parent and exits with "build skipped", so no build would ever run.
19
+ """
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from .detach import spawn_detached
24
+
25
+ # The committed marker that means "this repo is onboarded to graphite". Chosen
26
+ # over `.git` on purpose: the machine-wide `init.templateDir` shim runs in every
27
+ # new clone on the machine, so the guard must be something a repo opts into.
28
+ MARKER = "GRAPHITE.md"
29
+
30
+
31
+ def find_repo_root(start: Path) -> Path | None:
32
+ """Nearest ancestor of `start` (inclusive) carrying the marker."""
33
+ for directory in (start, *start.parents):
34
+ if (directory / MARKER).exists():
35
+ return directory
36
+ return None
37
+
38
+
39
+ def main(argv: list[str] | None = None) -> int:
40
+ """Spawn a detached graph build for the repo containing `argv[0]`.
41
+
42
+ Always returns 0. Git ignores `post-*` exit codes anyway, but the shim also
43
+ ends with an explicit `exit 0`, and a graph refresh must never be able to
44
+ fail a developer's commit.
45
+ """
46
+ args = list(sys.argv[1:] if argv is None else argv)
47
+ start = Path(args[0]) if args else Path.cwd()
48
+ try:
49
+ start = start.resolve()
50
+ except OSError:
51
+ return 0
52
+
53
+ root = find_repo_root(start)
54
+ if root is None:
55
+ return 0 # not an onboarded repo -- fail open, silently
56
+
57
+ # The root is passed explicitly AND used as cwd. Both are load-bearing:
58
+ # `Config.output_dir` / `cache_dir` default to RELATIVE paths that
59
+ # `cmd_build` resolves against the process CWD, so a child launched from
60
+ # elsewhere would write the graph into the wrong directory.
61
+ spawn_detached(
62
+ [sys.executable, "-B", "-P", "-m", "graphite", "build", str(root)], root
63
+ )
64
+ return 0
65
+
66
+
67
+ if __name__ == "__main__":
68
+ raise SystemExit(main())
@@ -0,0 +1,224 @@
1
+ """Installing, migrating and removing graphite's git hooks.
2
+
3
+ All filesystem and git side effects live here; `hookshim` stays pure.
4
+
5
+ **The interop rule this module exists to honour:** a hook graphite does not
6
+ trigger on is *relocated byte-identically* -- moved, unchanged, with no
7
+ graphite marker and no `.local` sibling. It is never wrapped in a pass-through
8
+ trampoline.
9
+
10
+ An earlier design did wrap them. aramid's `install()` treats any hook carrying
11
+ another tool's `# >>> <tool> managed >>>` marker as foreign-managed and refuses
12
+ it outright (aramid `0f24609`), skipping its own gate for that hook. Since the
13
+ old design stamped graphite's marker onto *every* migrated hook including
14
+ `pre-commit`/`pre-push`, `graphite init` would have left aramid's gates
15
+ un-refreshable in every repo it touched. Relocation avoids this entirely:
16
+ aramid's shim keeps its own marker, so `_is_aramid_shim` is true, its
17
+ foreign-hook branch never runs, and it regenerates in place. aramid's agent
18
+ independently confirmed this against `hooks.py:333,338-342` and
19
+ `init.py:198-206` before it shipped.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import stat
24
+ import subprocess
25
+ from pathlib import Path
26
+
27
+ from .config import default_projects_root
28
+ from .hookshim import CHAINED_SUFFIX, MARKER_START, TRIGGERS, render_trigger_shim
29
+
30
+ DEFAULT_HOOKS_DIRNAME = ".githooks"
31
+
32
+ # Distinct from the unrelated "template" used for GRAPHITE.md/instruction-doc
33
+ # versioning (`init.py`'s DOC_VERSION) -- "hooks" is spelled out so the two
34
+ # concepts never look like the same thing on disk.
35
+ DEFAULT_TEMPLATE_DIRNAME = ".graphite-hooks-template"
36
+
37
+
38
+ def _git(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
39
+ # Explicit codec and error handler: `text=True` alone decodes with the
40
+ # locale codec, and git echoes repository paths and branch names that are
41
+ # not required to be Latin-1. A failure decodes on subprocess's reader
42
+ # thread, so it yields `stdout is None` rather than raising.
43
+ return subprocess.run(
44
+ ["git", "-C", str(root), *args],
45
+ capture_output=True,
46
+ text=True,
47
+ encoding="utf-8",
48
+ errors="replace",
49
+ )
50
+
51
+
52
+ def hooks_dir(root: Path) -> Path:
53
+ """Where hooks live for this repo.
54
+
55
+ Honours an existing `core.hooksPath` rather than taking it over: if husky
56
+ (or anything else) already owns hook policy here, graphite installs into
57
+ *their* directory. Relative values resolve against the repo root, matching
58
+ git's own rule and aramid's `hooks_dir`.
59
+ """
60
+ configured = _git(root, "config", "--get", "core.hooksPath").stdout.strip()
61
+ if not configured:
62
+ return root / DEFAULT_HOOKS_DIRNAME
63
+ candidate = Path(configured)
64
+ return candidate if candidate.is_absolute() else root / candidate
65
+
66
+
67
+ def hook_shim_present(path: Path) -> bool:
68
+ """Did graphite write this hook file? Decided by the in-file marker.
69
+
70
+ The single marker predicate for the whole codebase. It lived in `doctor`
71
+ until 2026-07-31; two copies in two modules is how the relocated-hook rule
72
+ below drifts apart from the code that enforces it.
73
+ """
74
+ if not path.is_file():
75
+ return False
76
+ try:
77
+ return MARKER_START.encode() in path.read_bytes()
78
+ except OSError:
79
+ return False
80
+
81
+
82
+ def _make_executable(path: Path) -> None:
83
+ """Best-effort on a bare Windows filesystem, but it matters the moment the
84
+ repo is cloned onto WSL or a Linux CI runner."""
85
+ try:
86
+ path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
87
+ except OSError:
88
+ pass
89
+
90
+
91
+ def _is_graphite_shim(path: Path) -> bool:
92
+ if not path.exists():
93
+ return False
94
+ try:
95
+ return MARKER_START.encode() in path.read_bytes()
96
+ except OSError:
97
+ return False
98
+
99
+
100
+ def _legacy_hooks(root: Path) -> list[Path]:
101
+ legacy = root / ".git" / "hooks"
102
+ if not legacy.is_dir():
103
+ return []
104
+ return [p for p in sorted(legacy.iterdir()) if p.is_file() and p.suffix != ".sample"]
105
+
106
+
107
+ def install_hooks(root: Path, interpreter: Path) -> list[str]:
108
+ """Install graphite's trampolines, migrating whatever was already there.
109
+
110
+ Returns the names of hooks that were relocated (not chained), so callers
111
+ can report them -- a silent migration of someone else's hook is exactly the
112
+ kind of surprise this design is trying to avoid.
113
+
114
+ Ordering is load-bearing: `core.hooksPath` is written **last**, so no
115
+ window exists in which it redirects to a directory that has no hooks yet.
116
+ """
117
+ hdir = hooks_dir(root)
118
+ hdir.mkdir(parents=True, exist_ok=True)
119
+ already_configured = bool(
120
+ _git(root, "config", "--get", "core.hooksPath").stdout.strip()
121
+ )
122
+
123
+ relocated: list[str] = []
124
+ for legacy in _legacy_hooks(root):
125
+ name = legacy.name
126
+ target = hdir / (f"{name}{CHAINED_SUFFIX}" if name in TRIGGERS else name)
127
+ if target.exists() or (name not in TRIGGERS and (hdir / name).exists()):
128
+ # Already migrated by a previous run; leave both copies alone
129
+ # rather than clobbering a live hook.
130
+ continue
131
+ target.write_bytes(legacy.read_bytes())
132
+ _make_executable(target)
133
+ legacy.unlink()
134
+ if name not in TRIGGERS:
135
+ relocated.append(name)
136
+
137
+ for hook in TRIGGERS:
138
+ slot = hdir / hook
139
+ # A pre-existing NON-graphite file in a trigger slot (e.g. one written
140
+ # straight into .githooks/) becomes the chained original. A graphite
141
+ # shim is simply regenerated -- never chained to itself.
142
+ if slot.exists() and not _is_graphite_shim(slot):
143
+ chained = hdir / f"{hook}{CHAINED_SUFFIX}"
144
+ if not chained.exists():
145
+ slot.replace(chained)
146
+ _make_executable(chained)
147
+ else:
148
+ slot.unlink()
149
+ slot.write_bytes(render_trigger_shim(hook, interpreter))
150
+ _make_executable(slot)
151
+
152
+ if not already_configured:
153
+ _git(root, "config", "core.hooksPath", DEFAULT_HOOKS_DIRNAME)
154
+ return relocated
155
+
156
+
157
+ def uninstall_hooks(root: Path) -> list[str]:
158
+ """Remove graphite's trampolines, restoring anything they chained.
159
+
160
+ Only graphite's own shims are touched. A relocated foreign hook is left
161
+ exactly where it is: graphite moved it but never owned it, and deleting it
162
+ would take out another tool's live gate.
163
+ """
164
+ hdir = hooks_dir(root)
165
+ removed: list[str] = []
166
+ for hook in TRIGGERS:
167
+ slot = hdir / hook
168
+ if not _is_graphite_shim(slot):
169
+ continue
170
+ chained = hdir / f"{hook}{CHAINED_SUFFIX}"
171
+ slot.unlink()
172
+ if chained.exists():
173
+ chained.replace(slot)
174
+ _make_executable(slot)
175
+ removed.append(hook)
176
+ return removed
177
+
178
+
179
+ def default_template_root() -> Path:
180
+ """Where `graphite hooks --install-template` writes by default.
181
+
182
+ Mirrors the machine-state convention already used for daemon state --
183
+ `<default_projects_root>/.graphite-daemon` (see `config.default_projects_root`,
184
+ `cli._incidents_ledger_dir`) -- rather than inventing a new location.
185
+ Never reads or writes real global git config; that stays the human's step.
186
+ """
187
+ return default_projects_root() / DEFAULT_TEMPLATE_DIRNAME
188
+
189
+
190
+ def install_template(template_root: Path, interpreter: Path) -> list[Path]:
191
+ """Write graphite's trigger shims into a git `init.templateDir` layout.
192
+
193
+ Git copies `<templateDir>/hooks/<name>` into `.git/hooks/<name>` on every
194
+ future `git init`/`git clone` on this machine once a human points
195
+ `init.templateDir` at `template_root` -- the `hooks/` subdirectory is
196
+ required, since that is what git actually copies from. This function
197
+ itself never touches git config, real or otherwise, and only ever writes
198
+ under `template_root`.
199
+
200
+ Unlike `install_hooks`, there is no relocation, no `.local` chaining and
201
+ no `core.hooksPath` write: there is nothing pre-existing to migrate in a
202
+ template directory, and git's own template-copy mechanism is what wires a
203
+ fresh repo up. The written bytes are exactly `render_trigger_shim`'s --
204
+ no separate rendering path for the template case.
205
+
206
+ This shim runs in *every* new clone on the machine, onboarded with
207
+ `GRAPHITE.md` or not, so it must fail open. `hook_entry.main()` already
208
+ does: it returns 0 silently when no `GRAPHITE.md` is found walking up
209
+ from cwd. That existing behaviour is what makes reusing the same shim
210
+ here safe -- a machine-wide template hook that errors would break every
211
+ unrelated repo on the machine.
212
+
213
+ Regeneration is idempotent for the same `(template_root, interpreter)`:
214
+ `render_trigger_shim` is pure and there is no chaining state to disturb.
215
+ """
216
+ hooks_subdir = template_root / "hooks"
217
+ hooks_subdir.mkdir(parents=True, exist_ok=True)
218
+ written: list[Path] = []
219
+ for hook in TRIGGERS:
220
+ path = hooks_subdir / hook
221
+ path.write_bytes(render_trigger_shim(hook, interpreter))
222
+ _make_executable(path)
223
+ written.append(path)
224
+ return written