@flyingrobots/graft 0.3.1

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 (50) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +4 -0
  4. package/README.md +119 -0
  5. package/bin/graft.js +11 -0
  6. package/docs/GUIDE.md +374 -0
  7. package/package.json +76 -0
  8. package/src/adapters/canonical-json.ts +56 -0
  9. package/src/adapters/node-fs.ts +39 -0
  10. package/src/git/diff.ts +96 -0
  11. package/src/guards/stream-boundary.ts +110 -0
  12. package/src/hooks/posttooluse-read.ts +107 -0
  13. package/src/hooks/pretooluse-read.ts +88 -0
  14. package/src/hooks/shared.ts +168 -0
  15. package/src/mcp/cache.ts +94 -0
  16. package/src/mcp/cached-file.ts +38 -0
  17. package/src/mcp/context.ts +52 -0
  18. package/src/mcp/metrics.ts +53 -0
  19. package/src/mcp/receipt.ts +83 -0
  20. package/src/mcp/server.ts +166 -0
  21. package/src/mcp/stdio.ts +6 -0
  22. package/src/mcp/tools/budget.ts +20 -0
  23. package/src/mcp/tools/changed-since.ts +68 -0
  24. package/src/mcp/tools/doctor.ts +20 -0
  25. package/src/mcp/tools/explain.ts +80 -0
  26. package/src/mcp/tools/file-outline.ts +57 -0
  27. package/src/mcp/tools/graft-diff.ts +24 -0
  28. package/src/mcp/tools/read-range.ts +21 -0
  29. package/src/mcp/tools/run-capture.ts +67 -0
  30. package/src/mcp/tools/safe-read.ts +135 -0
  31. package/src/mcp/tools/state.ts +30 -0
  32. package/src/mcp/tools/stats.ts +20 -0
  33. package/src/metrics/logger.ts +69 -0
  34. package/src/metrics/types.ts +12 -0
  35. package/src/operations/file-outline.ts +38 -0
  36. package/src/operations/graft-diff.ts +117 -0
  37. package/src/operations/read-range.ts +65 -0
  38. package/src/operations/safe-read.ts +96 -0
  39. package/src/operations/state.ts +33 -0
  40. package/src/parser/diff.ts +142 -0
  41. package/src/parser/lang.ts +12 -0
  42. package/src/parser/outline.ts +327 -0
  43. package/src/parser/types.ts +67 -0
  44. package/src/policy/evaluate.ts +178 -0
  45. package/src/policy/graftignore.ts +6 -0
  46. package/src/policy/types.ts +86 -0
  47. package/src/ports/codec.ts +13 -0
  48. package/src/ports/filesystem.ts +17 -0
  49. package/src/session/tracker.ts +114 -0
  50. package/src/session/types.ts +20 -0
package/docs/GUIDE.md ADDED
@@ -0,0 +1,374 @@
1
+ # Graft Setup Guide
2
+
3
+ ## Install
4
+
5
+ ```bash
6
+ npm install -g @flyingrobots/graft
7
+ ```
8
+
9
+ Or run without installing:
10
+
11
+ ```bash
12
+ npx @flyingrobots/graft
13
+ ```
14
+
15
+ ## MCP Configuration
16
+
17
+ Graft runs as an MCP server over stdio. Add it to your editor or
18
+ agent's MCP configuration.
19
+
20
+ ### Claude Code
21
+
22
+ Add to `.mcp.json` in your project root (per-project) or
23
+ `~/.claude.json` (global):
24
+
25
+ ```json
26
+ {
27
+ "mcpServers": {
28
+ "graft": {
29
+ "command": "npx",
30
+ "args": ["-y", "@flyingrobots/graft"]
31
+ }
32
+ }
33
+ }
34
+ ```
35
+
36
+ ### Cursor
37
+
38
+ Add to Cursor's MCP settings (Settings → MCP Servers → Add):
39
+
40
+ ```json
41
+ {
42
+ "mcpServers": {
43
+ "graft": {
44
+ "command": "npx",
45
+ "args": ["-y", "@flyingrobots/graft"]
46
+ }
47
+ }
48
+ }
49
+ ```
50
+
51
+ ### Windsurf
52
+
53
+ Add to `~/.codeium/windsurf/mcp_config.json`:
54
+
55
+ ```json
56
+ {
57
+ "mcpServers": {
58
+ "graft": {
59
+ "command": "npx",
60
+ "args": ["-y", "@flyingrobots/graft"]
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ ### VS Code + Continue
67
+
68
+ Add to `.continue/config.json`:
69
+
70
+ ```json
71
+ {
72
+ "mcpServers": [
73
+ {
74
+ "name": "graft",
75
+ "command": "npx",
76
+ "args": ["-y", "@flyingrobots/graft"]
77
+ }
78
+ ]
79
+ }
80
+ ```
81
+
82
+ ### Cline
83
+
84
+ Add via Cline's MCP settings UI, or in
85
+ `.vscode/cline_mcp_settings.json`:
86
+
87
+ ```json
88
+ {
89
+ "mcpServers": {
90
+ "graft": {
91
+ "command": "npx",
92
+ "args": ["-y", "@flyingrobots/graft"]
93
+ }
94
+ }
95
+ }
96
+ ```
97
+
98
+ ### Any MCP-compatible client
99
+
100
+ The pattern is the same everywhere:
101
+
102
+ - **Command**: `npx`
103
+ - **Args**: `["-y", "@flyingrobots/graft"]`
104
+ - **Transport**: stdio (the default for most clients)
105
+
106
+ If your client doesn't support `npx`, install globally and use:
107
+
108
+ - **Command**: `graft`
109
+ - **Args**: (none)
110
+
111
+ ## Claude Code Hooks
112
+
113
+ Two hooks work together to govern agent reads:
114
+
115
+ - **PreToolUse** — blocks banned files before the read happens
116
+ - **PostToolUse** — educates the agent on context cost after large
117
+ file reads complete
118
+
119
+ ### Setup
120
+
121
+ Add to `.claude/settings.json` in your project root:
122
+
123
+ ```json
124
+ {
125
+ "hooks": {
126
+ "PreToolUse": [
127
+ {
128
+ "matcher": "Read",
129
+ "hooks": [
130
+ {
131
+ "type": "command",
132
+ "command": "node --import tsx node_modules/@flyingrobots/graft/src/hooks/pretooluse-read.ts"
133
+ }
134
+ ]
135
+ }
136
+ ],
137
+ "PostToolUse": [
138
+ {
139
+ "matcher": "Read",
140
+ "hooks": [
141
+ {
142
+ "type": "command",
143
+ "command": "node --import tsx node_modules/@flyingrobots/graft/src/hooks/posttooluse-read.ts"
144
+ }
145
+ ]
146
+ }
147
+ ]
148
+ }
149
+ }
150
+ ```
151
+
152
+ If developing graft itself, replace the `node_modules/...` paths
153
+ with local paths:
154
+
155
+ ```
156
+ src/hooks/pretooluse-read.ts (PreToolUse)
157
+ src/hooks/posttooluse-read.ts (PostToolUse)
158
+ ```
159
+
160
+ ### PreToolUse — ban enforcement
161
+
162
+ When the agent calls `Read(file_path)`, the PreToolUse hook:
163
+
164
+ 1. Evaluates the file against graft's policy
165
+ 2. **Banned files** (binaries, lockfiles, secrets, `.graftignore`
166
+ matches): exits 2 (block) with refusal reason and next steps
167
+ 3. **Everything else**: exits 0 — lets native Read proceed
168
+
169
+ The PreToolUse hook does NOT block large files. It only enforces
170
+ hard bans. Large file governance is handled by the PostToolUse hook.
171
+
172
+ ### PostToolUse — context cost education
173
+
174
+ After a Read completes, the PostToolUse hook evaluates what
175
+ `safe_read` would have done and tells the agent the cost:
176
+
177
+ ```
178
+ [graft] You just read 450 lines (18KB) into context.
179
+ safe_read would have returned a structural outline (2048 bytes),
180
+ saving 16.0KB of context. Threshold: 150 lines / 12KB.
181
+ ```
182
+
183
+ This feedback appears for large JS/TS files where an outline was
184
+ available. Small files, non-JS/TS files, and nonexistent files
185
+ produce no feedback. The hook always exits 0 — it never blocks.
186
+
187
+ ### Limitations
188
+
189
+ Both hooks run as standalone processes — they do not share state
190
+ with the MCP server. This means:
191
+
192
+ - No session-depth dynamic caps (early/mid/late)
193
+ - No re-read suppression or cache hits
194
+ - No structural diffs on changed files
195
+ - No metrics tracking or receipts
196
+
197
+ For the full experience, agents should use graft's MCP tools
198
+ (`safe_read`, `file_outline`, `read_range`) directly. The hooks
199
+ are a safety net; the MCP server is the full governor.
200
+
201
+ ### Disabling
202
+
203
+ To disable hooks locally without removing the project config,
204
+ add to `.claude/settings.local.json`:
205
+
206
+ ```json
207
+ {
208
+ "hooks": {}
209
+ }
210
+ ```
211
+
212
+ ## Tool Reference
213
+
214
+ | Tool | Description |
215
+ |------|-------------|
216
+ | `safe_read` | Policy-enforced file read. Returns full content for small files, structural outline with jump table for large files, or refusal with reason code for banned files. Detects re-reads and returns cached outlines or structural diffs. |
217
+ | `file_outline` | Structural skeleton of a file — function signatures, class shapes, exports. Includes a jump table mapping each symbol to its line range for targeted `read_range` follow-ups. |
218
+ | `read_range` | Read a bounded range of lines from a file. Maximum 250 lines. Use jump table entries from `file_outline` or `safe_read` to target specific symbols. |
219
+ | `changed_since` | Check if a file changed since it was last read. Returns structural diff (added/removed/changed symbols) or "unchanged". Peek mode by default; pass `consume: true` to update the observation cache. |
220
+ | `graft_diff` | Structural diff between two git refs. Shows added, removed, and changed symbols per file — not line hunks. Defaults to working tree vs HEAD. |
221
+ | `run_capture` | Execute a shell command and return the last N lines of output (default 60). Full output saved to `.graft/logs/capture.log` for follow-up `read_range` calls. |
222
+ | `state_save` | Save session working state (max 8 KB). Use for session bookmarks: current task, files modified, next planned actions. |
223
+ | `state_load` | Load previously saved session state. Returns null if no state has been saved. |
224
+ | `doctor` | Runtime health check. Shows project root, parser status, active thresholds, session depth, and message count. |
225
+ | `set_budget` | Declare a session byte budget. Graft tightens read thresholds as the budget drains — no single read may consume more than 5% of remaining budget. Call once at session start. |
226
+ | `explain` | Explain a graft reason code. Returns human-readable meaning and recommended next action for any code (e.g., `BINARY`, `BUDGET_CAP`). Case-insensitive. |
227
+ | `stats` | Decision metrics for the current session. Total reads, outlines, refusals, cache hits, and bytes avoided. |
228
+
229
+ ## What the agent sees
230
+
231
+ Once configured, the agent gains 12 new tools. Here's what
232
+ happens when it uses them:
233
+
234
+ ### Reading files
235
+
236
+ The agent calls `safe_read` instead of reading files directly.
237
+ Graft decides what to return:
238
+
239
+ - **Small files** (< 150 lines, < 12 KB): full content, as normal.
240
+ - **Large files**: a structural outline showing function signatures,
241
+ class shapes, and exports — with a jump table mapping each symbol
242
+ to its line range. The agent can then use `read_range` to read
243
+ specific sections.
244
+ - **Banned files** (binaries, lockfiles, `.env`, minified bundles,
245
+ build output): refused with a reason code and suggested
246
+ alternatives.
247
+ - **Re-reads**: if the agent reads the same unchanged file twice,
248
+ graft returns the cached outline instead of the full content.
249
+ If the file changed, it returns a structural diff (added/removed/
250
+ changed symbols).
251
+
252
+ ### Structural navigation
253
+
254
+ `file_outline` returns the structural skeleton of any file —
255
+ function signatures, class members, exports — without the bodies.
256
+ Each symbol has a line range so the agent can follow up with
257
+ `read_range` for the specific code it needs.
258
+
259
+ ### Git diffs
260
+
261
+ `graft_diff` shows what changed between git refs at the symbol
262
+ level: "function `foo` gained a parameter" instead of line hunks.
263
+
264
+ ### Budget governor
265
+
266
+ If the agent calls `set_budget(bytes)` at session start, graft
267
+ tracks cumulative bytes consumed and tightens thresholds as the
268
+ budget drains. No single read may consume more than 5% of remaining
269
+ budget. When the budget is exhausted, all reads return outlines.
270
+
271
+ Budget status appears in every receipt:
272
+ ```json
273
+ "budget": { "total": 500000, "consumed": 14345, "remaining": 485655, "fraction": 0.029 }
274
+ ```
275
+
276
+ ### Session awareness
277
+
278
+ Graft tracks the session and tightens policy as context pressure
279
+ grows:
280
+
281
+ | Session stage | Max read size |
282
+ |---------------|---------------|
283
+ | Early (< 100 messages) | 20 KB |
284
+ | Mid (100–500 messages) | 10 KB |
285
+ | Late (> 500 messages) | 4 KB |
286
+
287
+ Tripwires warn when sessions are going off the rails (> 500
288
+ messages, edit-bash loops, runaway tool calls).
289
+
290
+ ### Receipts
291
+
292
+ Every response includes a `_receipt` block with session ID,
293
+ sequence number, projection type, bytes returned, and cumulative
294
+ counters. This is for usage analysis — you can ignore it.
295
+
296
+ ## Configuration
297
+
298
+ ### .graftignore
299
+
300
+ Create a `.graftignore` file in your project root to ban
301
+ additional file patterns:
302
+
303
+ ```text
304
+ # Generated files
305
+ *.generated.ts
306
+ *.snap
307
+
308
+ # Vendor code
309
+ vendor/**
310
+
311
+ # Large data files
312
+ data/**/*.json
313
+ ```
314
+
315
+ Patterns follow the same syntax as `.gitignore` (glob matching
316
+ via picomatch).
317
+
318
+ ### Policy defaults
319
+
320
+ | Setting | Default | Description |
321
+ |---------|---------|-------------|
322
+ | Line threshold | 150 | Files over this → outline |
323
+ | Byte threshold | 12 KB | Files over this → outline |
324
+ | Max range | 250 lines | read_range cap |
325
+ | State cap | 8 KB | state_save max size |
326
+ | Capture tail | 60 lines | run_capture default |
327
+
328
+ These are not yet configurable at runtime (planned for a future
329
+ release).
330
+
331
+ ## Troubleshooting
332
+
333
+ ### "Tool not found" or no graft tools visible
334
+
335
+ - Verify graft is installed: `npx @flyingrobots/graft --help`
336
+ (should start the server; Ctrl+C to stop)
337
+ - Check your MCP config syntax — JSON must be valid
338
+ - Restart your editor/agent after adding MCP config
339
+ - Some clients cache tool lists — try reopening the project
340
+
341
+ ### Agent keeps getting outlines instead of content
342
+
343
+ Your files are over 150 lines or 12 KB. This is intentional.
344
+ The agent should use the jump table from the outline to
345
+ `read_range` the specific section it needs.
346
+
347
+ ### Agent can't read a file (refused)
348
+
349
+ Check the reason code in the response:
350
+ - `BINARY` — binary file, use `ls -lh` or `file` for metadata
351
+ - `LOCKFILE` — read `package.json` instead
352
+ - `SECRET` — `.env` files are banned for safety
353
+ - `BUILD_OUTPUT` — read the source file, not `dist/`
354
+ - `GRAFTIGNORE` — file matches a `.graftignore` pattern
355
+
356
+ ### graft is slow on first call
357
+
358
+ Tree-sitter WASM grammars load on first parse (~200ms). Subsequent
359
+ calls are fast.
360
+
361
+ ## Verify it works
362
+
363
+ After setup, ask your agent to read a large file in your project.
364
+ Instead of dumping the entire file, it should return an outline
365
+ with a jump table. That's graft working.
366
+
367
+ You can also ask the agent to call `doctor` to verify:
368
+
369
+ ```
370
+ Use the doctor tool to check graft's health.
371
+ ```
372
+
373
+ This returns the project root, parser status, active thresholds,
374
+ and session depth.
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@flyingrobots/graft",
3
+ "version": "0.3.1",
4
+ "description": "Context governor for coding agents — MCP server with policy-enforced reads, structural outlines, and session tracking",
5
+ "type": "module",
6
+ "bin": {
7
+ "graft": "./bin/graft.js",
8
+ "git-graft": "./bin/graft.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "docs/GUIDE.md",
14
+ "LICENSE",
15
+ "NOTICE",
16
+ "README.md",
17
+ "CHANGELOG.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20.11.0"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "registry": "https://registry.npmjs.org/"
25
+ },
26
+ "homepage": "https://github.com/flyingrobots/graft#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/flyingrobots/graft/issues"
29
+ },
30
+ "keywords": [
31
+ "mcp",
32
+ "mcp-server",
33
+ "model-context-protocol",
34
+ "claude-code",
35
+ "cursor",
36
+ "coding-agent",
37
+ "ai-agent",
38
+ "context-window",
39
+ "code-navigation",
40
+ "structural-diff",
41
+ "tree-sitter",
42
+ "developer-tools"
43
+ ],
44
+ "author": "James Ross <james@flyingrobots.dev>",
45
+ "license": "Apache-2.0",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/flyingrobots/graft.git"
49
+ },
50
+ "dependencies": {
51
+ "@modelcontextprotocol/sdk": "^1.29.0",
52
+ "picomatch": "^4.0.4",
53
+ "tree-sitter-wasms": "0.1.13",
54
+ "tsx": "^4.21.0",
55
+ "web-tree-sitter": "^0.20.8",
56
+ "zod": "4.3.6"
57
+ },
58
+ "devDependencies": {
59
+ "@eslint/js": "^10.0.1",
60
+ "@types/node": "^25.5.0",
61
+ "@types/picomatch": "^4.0.2",
62
+ "eslint": "^10.1.0",
63
+ "globals": "^17.4.0",
64
+ "typescript": "^6.0.2",
65
+ "typescript-eslint": "^8.58.0",
66
+ "vitest": "^4.1.2"
67
+ },
68
+ "scripts": {
69
+ "build": "tsc -p tsconfig.build.json",
70
+ "test": "vitest run",
71
+ "test:watch": "vitest",
72
+ "lint": "eslint .",
73
+ "typecheck": "tsc --noEmit",
74
+ "pack:check": "pnpm pack --dry-run"
75
+ }
76
+ }
@@ -0,0 +1,56 @@
1
+ // ---------------------------------------------------------------------------
2
+ // CanonicalJsonCodec — deterministic JSON with sorted keys
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // Subset of RFC 8785 (JSON Canonicalization Scheme):
6
+ // - Object keys sorted lexicographically at every nesting level
7
+ // - Compact output (no whitespace)
8
+ // - Deterministic: same data always produces the same string
9
+ //
10
+ // Enables stable hashes, diffable logs, reproducible receipts.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import type { JsonCodec } from "../ports/codec.js";
14
+
15
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
16
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
17
+ return false;
18
+ }
19
+ const proto = Object.getPrototypeOf(value) as unknown;
20
+ return proto === Object.prototype || proto === null;
21
+ }
22
+
23
+ function sortDeep(value: unknown, seen = new WeakSet()): unknown {
24
+ if (value === null || typeof value !== "object") {
25
+ return value;
26
+ }
27
+ if (!isPlainObject(value) && !Array.isArray(value)) {
28
+ return value;
29
+ }
30
+ if (seen.has(value)) {
31
+ throw new TypeError("Converting circular structure to JSON");
32
+ }
33
+ seen.add(value);
34
+ try {
35
+ if (Array.isArray(value)) {
36
+ return value.map((v) => sortDeep(v, seen));
37
+ }
38
+ const sorted: Record<string, unknown> = {};
39
+ for (const key of Object.keys(value).sort()) {
40
+ sorted[key] = sortDeep(value[key], seen);
41
+ }
42
+ return sorted;
43
+ } finally {
44
+ seen.delete(value);
45
+ }
46
+ }
47
+
48
+ export class CanonicalJsonCodec implements JsonCodec {
49
+ encode(value: unknown): string {
50
+ return JSON.stringify(sortDeep(value));
51
+ }
52
+
53
+ decode(data: string): unknown {
54
+ return JSON.parse(data) as unknown;
55
+ }
56
+ }
@@ -0,0 +1,39 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Node.js filesystem adapter — implements the FileSystem port
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import * as fs from "node:fs";
6
+ import * as fsp from "node:fs/promises";
7
+ import type { FileSystem } from "../ports/filesystem.js";
8
+
9
+ class NodeFileSystem implements FileSystem {
10
+ readFile(path: string, encoding: "utf-8"): Promise<string>;
11
+ readFile(path: string): Promise<Buffer>;
12
+ readFile(path: string, encoding?: "utf-8"): Promise<string | Buffer> {
13
+ if (encoding !== undefined) return fsp.readFile(path, encoding);
14
+ return fsp.readFile(path);
15
+ }
16
+
17
+ writeFile(path: string, data: string, encoding: "utf-8"): Promise<void> {
18
+ return fsp.writeFile(path, data, encoding);
19
+ }
20
+
21
+ appendFile(path: string, data: string, encoding: "utf-8"): Promise<void> {
22
+ return fsp.appendFile(path, data, encoding);
23
+ }
24
+
25
+ async mkdir(path: string, options: { recursive: true }): Promise<void> {
26
+ await fsp.mkdir(path, options);
27
+ }
28
+
29
+ async stat(path: string): Promise<{ size: number }> {
30
+ const s = await fsp.stat(path);
31
+ return { size: s.size };
32
+ }
33
+
34
+ readFileSync(path: string, encoding: "utf-8"): string {
35
+ return fs.readFileSync(path, encoding);
36
+ }
37
+ }
38
+
39
+ export const nodeFs: FileSystem = new NodeFileSystem();
@@ -0,0 +1,96 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ export interface ChangedFilesOptions {
4
+ cwd: string;
5
+ base?: string | undefined;
6
+ head?: string | undefined;
7
+ }
8
+
9
+ export class GitError extends Error {
10
+ constructor(message: string) {
11
+ super(message);
12
+ this.name = "GitError";
13
+ }
14
+ }
15
+
16
+ function git(args: string[], cwd: string): string {
17
+ return execFileSync("git", args, {
18
+ cwd,
19
+ encoding: "utf-8",
20
+ stdio: ["pipe", "pipe", "pipe"],
21
+ });
22
+ }
23
+
24
+ function refExists(ref: string, cwd: string): boolean {
25
+ try {
26
+ git(["rev-parse", "--verify", ref], cwd);
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ function objectExists(ref: string, filePath: string, cwd: string): boolean {
34
+ try {
35
+ git(["cat-file", "-e", `${ref}:${filePath}`], cwd);
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * List files changed between two refs, or between a ref and the working tree.
44
+ * If head is omitted, diffs against the working tree.
45
+ * If both base and head are omitted, diffs HEAD against the working tree.
46
+ *
47
+ * Throws GitError for invalid refs or non-git directories.
48
+ * Returns empty array only when there are genuinely no changes.
49
+ */
50
+ export function getChangedFiles(opts: ChangedFilesOptions): string[] {
51
+ const base = opts.base ?? "HEAD";
52
+ const args = opts.head !== undefined
53
+ ? ["diff", "--name-only", base, opts.head]
54
+ : ["diff", "--name-only", base];
55
+
56
+ try {
57
+ const output = git(args, opts.cwd).trim();
58
+ if (output === "") return [];
59
+ return output.split("\n");
60
+ } catch (err: unknown) {
61
+ const msg = err instanceof Error ? err.message : String(err);
62
+ throw new GitError(`git diff failed: ${msg}`);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Get the content of a file at a specific git ref.
68
+ * Returns null if the file doesn't exist at that ref (clean absence).
69
+ * Throws GitError for invalid refs or git failures.
70
+ *
71
+ * Uses `git rev-parse --verify` and `git cat-file -e` for stable
72
+ * detection — no error message parsing.
73
+ */
74
+ export function getFileAtRef(
75
+ ref: string,
76
+ filePath: string,
77
+ cwd: string,
78
+ ): string | null {
79
+ // Validate the ref exists (stable probe, no message parsing)
80
+ if (!refExists(ref, cwd)) {
81
+ throw new GitError(`ref does not exist: ${ref}`);
82
+ }
83
+
84
+ // Check if the object exists at this ref (stable probe)
85
+ if (!objectExists(ref, filePath, cwd)) {
86
+ return null; // Clean absence — file not in this ref
87
+ }
88
+
89
+ // Object exists — read it
90
+ try {
91
+ return git(["show", `${ref}:${filePath}`], cwd);
92
+ } catch (err: unknown) {
93
+ const msg = err instanceof Error ? err.message : String(err);
94
+ throw new GitError(`git show ${ref}:${filePath} failed: ${msg}`);
95
+ }
96
+ }