@davesheffer/hunch 1.39.0 → 1.39.2

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 (48) hide show
  1. package/dist/cli/index.js +193 -65
  2. package/dist/cli/integrations.js +10 -0
  3. package/dist/client/state.d.ts +2 -1
  4. package/dist/client/state.js +1 -0
  5. package/dist/core/agenthook.d.ts +14 -0
  6. package/dist/core/agenthook.js +55 -8
  7. package/dist/core/capturetoken.d.ts +30 -3
  8. package/dist/core/capturetoken.js +29 -3
  9. package/dist/core/changeProof.js +5 -1
  10. package/dist/core/checkreport.d.ts +7 -0
  11. package/dist/core/checkreport.js +20 -3
  12. package/dist/core/compare.js +3 -2
  13. package/dist/core/correction.d.ts +10 -4
  14. package/dist/core/correction.js +7 -4
  15. package/dist/core/countersign.d.ts +28 -0
  16. package/dist/core/countersign.js +50 -0
  17. package/dist/core/reviewqueue.js +6 -1
  18. package/dist/core/spawnCommand.js +41 -9
  19. package/dist/core/stateHttp.d.ts +1 -1
  20. package/dist/core/stateHttp.js +3 -1
  21. package/dist/core/taskReportEvidence.js +31 -11
  22. package/dist/core/topics.js +1 -1
  23. package/dist/core/types.d.ts +1 -0
  24. package/dist/core/workspace.d.ts +23 -1
  25. package/dist/core/workspace.js +31 -7
  26. package/dist/extractors/diff.d.ts +34 -0
  27. package/dist/extractors/diff.js +147 -5
  28. package/dist/extractors/git.d.ts +40 -11
  29. package/dist/extractors/git.js +147 -43
  30. package/dist/extractors/workspaces.d.ts +10 -0
  31. package/dist/extractors/workspaces.js +92 -15
  32. package/dist/integrations/gitignore.d.ts +27 -2
  33. package/dist/integrations/gitignore.js +103 -17
  34. package/dist/integrations/hooks.d.ts +61 -7
  35. package/dist/integrations/hooks.js +330 -43
  36. package/dist/integrations/scaffold.js +1 -1
  37. package/dist/integrations/workspaceLedger.d.ts +23 -3
  38. package/dist/integrations/workspaceLedger.js +114 -8
  39. package/dist/mcp/server.js +115 -56
  40. package/dist/serve/app.d.ts +4 -0
  41. package/dist/serve/app.js +56 -36
  42. package/dist/store/hunchStore.d.ts +4 -2
  43. package/dist/store/hunchStore.js +23 -6
  44. package/dist/store/stateBinding.js +111 -42
  45. package/dist/wiki/wiki.d.ts +7 -0
  46. package/dist/wiki/wiki.js +19 -8
  47. package/package.json +1 -1
  48. package/server.json +2 -2
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
9
9
  import { MEMLOG_FORMAT } from "../core/memorylog.js";
10
10
  import { hunchAttributesAreSafe, hunchTreeAttributesAreSafe, safeOverlayTree } from "../core/overlaySafety.js";
11
11
  import { createRepoFileReader } from "../core/safeRepoFile.js";
12
+ import { DIFF_TRUNCATED_LINE } from "./diff.js";
12
13
  import { initiatorChildEnv } from "../synthesis/initiator.js";
13
14
  // `git` exports these repository-local variables to hooks. They outrank cwd/-C,
14
15
  // so carrying them from the code repository into a command for the memory
@@ -71,6 +72,26 @@ function gitSafe(args, cwd, maxBuffer) {
71
72
  return "";
72
73
  }
73
74
  }
75
+ /** Untrimmed stdout with the same child environment as git(); null on ANY failure
76
+ * (non-zero exit, spawn error, output over maxBuffer). For callers that must tell
77
+ * "git produced nothing" apart from "git could not answer". */
78
+ function gitOutputOrNull(args, cwd, maxBuffer = 64 * 1024 * 1024) {
79
+ try {
80
+ return execFileSync("git", args, {
81
+ cwd, encoding: "utf8", maxBuffer,
82
+ env: initiatorChildEnv(),
83
+ stdio: ["ignore", "pipe", "ignore"],
84
+ });
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ /** Split `-z` path output. NUL-delimited paths are never C-quoted, so a path
91
+ * holding `"`, `\`, a tab or a newline enumerates as its literal spelling. */
92
+ function nulPaths(out) {
93
+ return out ? out.split("\0").filter(Boolean) : [];
94
+ }
74
95
  /** Object-identity reads must not inherit clone-local replacement refs/grafts.
75
96
  * Most Git helpers intentionally preserve ordinary repository behavior; use
76
97
  * this narrower path only where a cross-clone canonical identity is minted. */
@@ -1808,6 +1829,14 @@ export function gitUntrackCached(cwd, paths) {
1808
1829
  }
1809
1830
  catch { /* best-effort: not a repo / nothing tracked */ }
1810
1831
  }
1832
+ /** Tracked files under the given pathspecs (repo-relative, POSIX). Best-effort:
1833
+ * `[]` when not a repository or nothing is tracked there. */
1834
+ export function gitTrackedPaths(cwd, paths) {
1835
+ if (paths.length === 0)
1836
+ return [];
1837
+ const out = gitSafe(["-c", "core.quotePath=false", "ls-files", "--", ...paths], cwd);
1838
+ return out ? out.split("\n").filter(Boolean) : [];
1839
+ }
1811
1840
  /** Resolve any commit-ish (short sha / HEAD / branch) to a canonical full sha.
1812
1841
  * Returns the input unchanged if it can't be resolved (e.g. not a git repo). */
1813
1842
  export function revParse(ref, cwd) {
@@ -1851,8 +1880,7 @@ export function currentBranch(cwd) {
1851
1880
  /** Files changed in a single commit. `--root` makes the initial commit (which
1852
1881
  * has no parent) report its files as additions instead of returning nothing. */
1853
1882
  export function commitFiles(sha, cwd) {
1854
- const out = gitSafe(["-c", "core.quotePath=false", "diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha], cwd);
1855
- return out ? out.split("\n").filter(Boolean) : [];
1883
+ return nulPaths(gitOutputOrNull(["-c", "core.quotePath=false", "diff-tree", "--no-commit-id", "--name-only", "-z", "-r", "--root", sha], cwd));
1856
1884
  }
1857
1885
  /** Raw `git log` over `.hunch/`, paired with parseMemoryLog — the memory-move
1858
1886
  * timeline (each commit that changed the graph). Newest first; empty on any error
@@ -2302,12 +2330,55 @@ const DIFF_NOISE = [
2302
2330
  ":(exclude,glob)**/__snapshots__/**",
2303
2331
  ":(exclude,glob)**/*.generated.*",
2304
2332
  ];
2305
- /** The unified diff for a commit, truncated to keep synthesis prompts bounded.
2333
+ /** Byte budget for a diff embedded in a SYNTHESIS prompt. Gates never use it: a
2334
+ * constraint check over a prefix of a change would read every file past the
2335
+ * cutoff as "no added lines" and pass it. */
2336
+ export const SYNTHESIS_DIFF_BUDGET = 60_000;
2337
+ function capDiff(out, maxBytes) {
2338
+ return out.length > maxBytes ? out.slice(0, maxBytes) + `\n${DIFF_TRUNCATED_LINE}` : out;
2339
+ }
2340
+ /** Output ceiling for a gating diff. Past it git's output cannot be captured; the
2341
+ * diff is then reported incomplete and the gate fails closed (dec_20db57c576). */
2342
+ const GATE_DIFF_MAX_BUFFER = 256 * 1024 * 1024;
2343
+ /** Pinned so user/repo config cannot change what the diff parser sees: literal
2344
+ * UTF-8 paths (issue #50) and a/ b/ prefixes regardless of diff.noprefix or
2345
+ * diff.mnemonicPrefix. */
2346
+ const GATE_DIFF_FLAGS = ["--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", "--src-prefix=a/", "--dst-prefix=b/"];
2347
+ function gateDiff(args, cwd, what) {
2348
+ const out = gitOutputOrNull(["-c", "core.quotePath=false", ...args], cwd, GATE_DIFF_MAX_BUFFER);
2349
+ return out === null
2350
+ ? { diff: "", incomplete: `git could not produce the ${what} diff (git error, or output over ${GATE_DIFF_MAX_BUFFER / (1024 * 1024)} MB)` }
2351
+ : { diff: out };
2352
+ }
2353
+ /** C-quote a path the way git does when it holds `"`, `\`, or a control character,
2354
+ * so a synthetic diff header round-trips through analyzeDiff's unquoting. */
2355
+ function quoteDiffPath(path) {
2356
+ if (!/["\\\x00-\x1f\x7f]/.test(path))
2357
+ return path;
2358
+ const named = { '"': '\\"', "\\": "\\\\", "\t": "\\t", "\n": "\\n", "\r": "\\r" };
2359
+ let out = "";
2360
+ for (const ch of path) {
2361
+ const code = ch.charCodeAt(0);
2362
+ if (named[ch])
2363
+ out += named[ch];
2364
+ else if (code < 0x20 || code === 0x7f)
2365
+ out += "\\" + code.toString(8).padStart(3, "0");
2366
+ else
2367
+ out += ch;
2368
+ }
2369
+ return `"${out}"`;
2370
+ }
2371
+ /** Complete gating diff of one commit (see GateDiff). */
2372
+ export function commitGateDiff(sha, cwd) {
2373
+ return gateDiff(["show", sha, "--format=", ...GATE_DIFF_FLAGS], cwd, `commit ${sha}`);
2374
+ }
2375
+ /** The unified diff for a commit, truncated to keep SYNTHESIS prompts bounded.
2306
2376
  * Machine-generated noise (see DIFF_NOISE) is excluded so the model spends its
2307
- * budget on code that encodes intent, not on regenerated lockfiles/build output. */
2308
- export function commitDiff(sha, cwd, maxBytes = 60_000) {
2309
- const out = gitSafe(["show", sha, "--no-color", "--format=", "--unified=2", "--", ...DIFF_NOISE], cwd);
2310
- return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
2377
+ * budget on code that encodes intent, not on regenerated lockfiles/build output.
2378
+ * Not for gating: use commitGateDiff. */
2379
+ export function commitDiff(sha, cwd, maxBytes = SYNTHESIS_DIFF_BUDGET) {
2380
+ const out = gitSafe(["-c", "core.quotePath=false", "show", sha, "--no-color", "--format=", "--unified=2", "--", ...DIFF_NOISE], cwd);
2381
+ return capDiff(out, maxBytes);
2311
2382
  }
2312
2383
  /** Number of commits touching a file in the last `days` (churn). */
2313
2384
  export function fileChurn(file, cwd, days = 90) {
@@ -2438,15 +2509,14 @@ export function fileGitMetrics(cwd, want, days = 90) {
2438
2509
  * POSIX paths nor constraint scope globs — a blocking constraint over such a
2439
2510
  * file graded as a vacuous PASS, and its churn/last-commit metrics read zero. */
2440
2511
  export function stagedFiles(cwd) {
2441
- const out = gitSafe(["-c", "core.quotePath=false", "diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd);
2442
- return out ? out.split("\n").filter(Boolean) : [];
2512
+ return nulPaths(gitOutputOrNull(["-c", "core.quotePath=false", "diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "-z", "--diff-filter=ACMR"], cwd));
2443
2513
  }
2444
2514
  /** Files changed anywhere in the working tree compared with HEAD: both staged
2445
2515
  * and unstaged tracked files, plus untracked files. This powers the local,
2446
2516
  * pre-commit Change Gate; it never mutates the index or asks an agent/model. */
2447
2517
  export function workingFiles(cwd) {
2448
- const changed = gitSafe(["-c", "core.quotePath=false", "diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
2449
- const untracked = gitSafe(["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter(Boolean);
2518
+ const changed = nulPaths(gitOutputOrNull(["-c", "core.quotePath=false", "diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "-z", "--diff-filter=ACMR"], cwd));
2519
+ const untracked = nulPaths(gitOutputOrNull(["-c", "core.quotePath=false", "ls-files", "-z", "--others", "--exclude-standard"], cwd));
2450
2520
  return [...new Set([...changed, ...untracked])].sort();
2451
2521
  }
2452
2522
  /** Does a ref resolve to a commit in this repo? Lets `--base` fail LOUDLY on an
@@ -2458,8 +2528,7 @@ export function revExists(ref, cwd) {
2458
2528
  /** Files a PR/branch changes vs `base` (3-dot: changes on HEAD since the merge-base,
2459
2529
  * i.e. exactly the PR's own commits — the CI Constraint Guard's surface). */
2460
2530
  export function rangeFiles(base, cwd, head = "HEAD") {
2461
- const out = gitSafe(["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
2462
- return out ? out.split("\n").filter(Boolean) : [];
2531
+ return nulPaths(gitOutputOrNull(["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-textconv", "--name-only", "-z", "--diff-filter=ACMR", `${base}...${head}`], cwd));
2463
2532
  }
2464
2533
  /** Commit subjects on `head` since `base` (2-dot: commits added by the task),
2465
2534
  * oldest-first, for distilling a runbook's ordered steps (roadmap #5). */
@@ -2467,43 +2536,78 @@ export function rangeSubjects(base, cwd, head = "HEAD", max = 50) {
2467
2536
  const out = gitSafe(["log", "--reverse", `-n${max}`, "--format=%s", `${base}..${head}`], cwd);
2468
2537
  return out ? out.split("\n").filter(Boolean) : [];
2469
2538
  }
2470
- /** The PR's unified diff vs `base` (3-dot), for the Regression Guard's structural
2471
- * analysis. Same noise-exclusion + truncation budget as commit/staged diffs. */
2472
- export function rangeDiff(base, cwd, head = "HEAD", maxBytes = 60_000) {
2473
- const out = gitSafe(["diff", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", `${base}...${head}`, "--", ...DIFF_NOISE], cwd);
2474
- return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
2539
+ /** Complete gating diff of a PR vs `base` (3-dot: the CI Constraint Guard's surface). */
2540
+ export function rangeGateDiff(base, cwd, head = "HEAD") {
2541
+ return gateDiff(["diff", ...GATE_DIFF_FLAGS, `${base}...${head}`], cwd, `${base}...${head}`);
2542
+ }
2543
+ /** The PR's unified diff vs `base` (3-dot), noise-excluded and capped at the
2544
+ * synthesis budget. Bounded-prompt use only; gates use rangeGateDiff. */
2545
+ export function rangeDiff(base, cwd, head = "HEAD", maxBytes = SYNTHESIS_DIFF_BUDGET) {
2546
+ const out = gitSafe(["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", `${base}...${head}`, "--", ...DIFF_NOISE], cwd);
2547
+ return capDiff(out, maxBytes);
2475
2548
  }
2476
- /** Unified diff of the staged changes (for the Regression Guard's structural
2477
- * analysis). Excludes machine-generated noise and truncates at the SAME budget as
2478
- * commitDiff, so the staged and `--commit` guard paths can't diverge on big diffs. */
2479
- export function stagedDiff(cwd, maxBytes = 60_000) {
2480
- const out = gitSafe(["diff", "--cached", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
2481
- return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
2549
+ /** Complete gating diff of the staged changes (pre-commit `hunch check`). */
2550
+ export function stagedGateDiff(cwd) {
2551
+ return gateDiff(["diff", "--cached", ...GATE_DIFF_FLAGS], cwd, "staged");
2482
2552
  }
2483
- /** Unified diff of the complete local working tree vs HEAD. Git's normal diff
2553
+ /** Unified diff of the staged changes, noise-excluded and capped at the synthesis
2554
+ * budget. Bounded-prompt use only; gates use stagedGateDiff. */
2555
+ export function stagedDiff(cwd, maxBytes = SYNTHESIS_DIFF_BUDGET) {
2556
+ const out = gitSafe(["-c", "core.quotePath=false", "diff", "--cached", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
2557
+ return capDiff(out, maxBytes);
2558
+ }
2559
+ /** Complete gating diff of the local working tree vs HEAD. Git's normal diff
2484
2560
  * includes both staged and unstaged tracked edits; untracked text files are
2485
- * appended as synthetic additions so guards can also see their added symbols.
2486
- * Binary/unreadable files remain in workingFiles (scope checks still apply) but
2487
- * intentionally contribute no synthetic content to regression analysis. */
2488
- export function workingDiff(cwd, maxBytes = 60_000) {
2489
- let out = gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
2490
- const tracked = new Set(gitSafe(["-c", "core.quotePath=false", "diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean));
2491
- const untracked = gitSafe(["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter((f) => f && !tracked.has(f));
2561
+ * appended as synthetic additions so guards can also see their added lines.
2562
+ * Binary files and non-regular paths (symlinks) contribute no content, as in a
2563
+ * git diff; an untracked regular file that cannot be read is listed in
2564
+ * `unreadFiles`, so content checks over it fail closed. */
2565
+ export function workingGateDiff(cwd) {
2566
+ // An unborn HEAD has no tracked diff (workingFiles enumerates only untracked
2567
+ // files there); any other failure to diff makes the gate input incomplete.
2568
+ const hasHead = revExists("HEAD", cwd);
2569
+ const tracked = hasHead ? gateDiff(["diff", "HEAD", ...GATE_DIFF_FLAGS], cwd, "working-tree") : { diff: "" };
2570
+ const trackedNames = hasHead
2571
+ ? gitOutputOrNull(["-c", "core.quotePath=false", "diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "-z", "--diff-filter=ACMR"], cwd)
2572
+ : "";
2573
+ const untrackedNames = gitOutputOrNull(["-c", "core.quotePath=false", "ls-files", "-z", "--others", "--exclude-standard"], cwd);
2574
+ const incomplete = tracked.incomplete
2575
+ ?? (trackedNames === null || untrackedNames === null ? "git could not enumerate the working-tree changes" : undefined);
2576
+ const trackedSet = new Set(nulPaths(trackedNames));
2577
+ const untracked = nulPaths(untrackedNames).filter((f) => !trackedSet.has(f));
2492
2578
  const readWorkingFile = createRepoFileReader(cwd);
2579
+ const unreadFiles = [];
2580
+ let out = tracked.diff;
2493
2581
  for (const file of untracked) {
2582
+ let regular = false;
2494
2583
  try {
2495
- const text = readWorkingFile(file);
2496
- if (text === null)
2497
- continue;
2498
- if (text.includes("\0"))
2499
- continue;
2500
- const lines = text.split("\n");
2501
- const add = lines.map((line) => `+${line}`).join("\n");
2502
- out += `${out ? "\n" : ""}diff --git a/${file} b/${file}\nnew file mode 100644\n--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n${add}\n`;
2584
+ regular = lstatSync(join(cwd, file)).isFile();
2503
2585
  }
2504
- catch { /* unreadable / directory / binary: scope-only is still safe */ }
2505
- }
2506
- return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
2586
+ catch { /* vanished: nothing to inspect */ }
2587
+ if (!regular)
2588
+ continue; // symlink / directory / gone: no file content to add
2589
+ const text = readWorkingFile(file);
2590
+ if (text === null) {
2591
+ unreadFiles.push(file);
2592
+ continue;
2593
+ }
2594
+ if (text.includes("\0"))
2595
+ continue; // binary: git shows no text hunks for it either
2596
+ const lines = text.split("\n");
2597
+ const add = lines.map((line) => `+${line}`).join("\n");
2598
+ const newPath = quoteDiffPath(`b/${file}`);
2599
+ out += `${out && !out.endsWith("\n") ? "\n" : ""}diff --git ${quoteDiffPath(`a/${file}`)} ${newPath}\nnew file mode 100644\n--- /dev/null\n+++ ${newPath}\n@@ -0,0 +1,${lines.length} @@\n${add}\n`;
2600
+ }
2601
+ return {
2602
+ diff: out,
2603
+ ...(incomplete ? { incomplete } : {}),
2604
+ ...(unreadFiles.length ? { unreadFiles } : {}),
2605
+ };
2606
+ }
2607
+ /** Working-tree diff vs HEAD capped at the synthesis budget. Bounded-prompt use
2608
+ * only; gates and rule verification use workingGateDiff. */
2609
+ export function workingDiff(cwd, maxBytes = SYNTHESIS_DIFF_BUDGET) {
2610
+ return capDiff(workingGateDiff(cwd).diff, maxBytes);
2507
2611
  }
2508
2612
  /** Resolve a time-travel ref (commit / tag / branch / HEAD~n) to the ISO author-
2509
2613
  * date of that commit — the instant valid-time windows are filtered against.
@@ -12,6 +12,16 @@ export interface SnapshotOptions {
12
12
  /** Record bound override (tests). Never above the schema's MAX_BRANCHES. */
13
13
  maxBranches?: number;
14
14
  }
15
+ /** Bound on the ignored paths NAMED in a prune plan; the total is always reported. */
16
+ export declare const MAX_IGNORED_SHOWN = 10;
17
+ /** Ignored files and directories in a worktree — what `git worktree remove` (without
18
+ * `--force`) deletes silently. Not a refusal (every Node worktree has node_modules/), but a
19
+ * prune plan and its confirmation name them. An ignored directory is one entry. Read live
20
+ * for this machine's plan only; never stored in a record. null when git cannot tell. */
21
+ export declare function ignoredPaths(path: string, maxShown?: number): {
22
+ shown: string[];
23
+ total: number;
24
+ } | null;
15
25
  /** Snapshot this machine's workspace for the repository at `root`. Validated against the
16
26
  * strict schema before it is returned, so the extractor can never emit a record the
17
27
  * loader would refuse. */
@@ -13,13 +13,15 @@ import { existsSync, realpathSync, statSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { foreignRepoEnv, gitCommonDir, mainWorktreeRoot, stableRepositoryName } from "./git.js";
15
15
  import { extracted } from "../core/types.js";
16
- import { MAX_BRANCHES, MAX_WORKTREES, WORKSPACE_SCHEMA_VERSION, WorkspaceSchema, isSafeBranchName, workspaceId, worktreeId, } from "../core/workspace.js";
16
+ import { CONTROL_CHARS, MAX_BRANCHES, MAX_WORKTREES, WORKSPACE_SCHEMA_VERSION, WorkspaceSchema, isSafeBranchName, workspaceId, worktreeId, } from "../core/workspace.js";
17
17
  const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
18
18
  const MAX_OUTPUT = 64 * 1024 * 1024;
19
19
  /** How far back in the default branch a squash-merge is searched for. */
20
20
  export const DEFAULT_SQUASH_SEARCH_COMMITS = 2000;
21
21
  function env() {
22
- return foreignRepoEnv(process.env);
22
+ // GIT_OPTIONAL_LOCKS=0: a read-only snapshot (it runs from hooks, in the background) must
23
+ // never take the index lock `git status` would otherwise grab to refresh stat data.
24
+ return { ...foreignRepoEnv(process.env), GIT_OPTIONAL_LOCKS: "0" };
23
25
  }
24
26
  function run(cwd, args, timeout = 10_000) {
25
27
  try {
@@ -112,13 +114,41 @@ function listWorktrees(root) {
112
114
  return items.filter((w) => !w.bare);
113
115
  }
114
116
  /** `git status --porcelain` is non-empty → uncommitted or untracked work that
115
- * `git worktree remove` would refuse to discard. null when the path is gone. */
117
+ * `git worktree remove` would refuse to discard. null when the path is gone.
118
+ * `--untracked-files=all` is explicit: `status.showUntrackedFiles=no` in the user's config
119
+ * would otherwise hide an untracked source file and report the worktree clean. */
116
120
  function isDirty(path) {
117
121
  if (!existsSync(path))
118
122
  return null;
119
- const out = run(path, ["status", "--porcelain", "--ignore-submodules"]);
123
+ const out = run(path, ["status", "--porcelain", "--untracked-files=all"]);
120
124
  return out === null ? null : out.trim().length > 0;
121
125
  }
126
+ /** Bound on the ignored paths NAMED in a prune plan; the total is always reported. */
127
+ export const MAX_IGNORED_SHOWN = 10;
128
+ /** Ignored files and directories in a worktree — what `git worktree remove` (without
129
+ * `--force`) deletes silently. Not a refusal (every Node worktree has node_modules/), but a
130
+ * prune plan and its confirmation name them. An ignored directory is one entry. Read live
131
+ * for this machine's plan only; never stored in a record. null when git cannot tell. */
132
+ export function ignoredPaths(path, maxShown = MAX_IGNORED_SHOWN) {
133
+ if (!existsSync(path))
134
+ return null;
135
+ // `--untracked-files=normal` is explicit: `--ignored=matching` refuses the `no` a user's
136
+ // status.showUntrackedFiles would otherwise supply.
137
+ const out = run(path, ["status", "--porcelain", "-z", "--ignored=matching", "--untracked-files=normal"]);
138
+ if (out === null)
139
+ return null;
140
+ const fields = out.split("\0");
141
+ const entries = [];
142
+ for (let i = 0; i < fields.length; i++) {
143
+ const field = fields[i];
144
+ if (field.startsWith("!! "))
145
+ entries.push(field.slice(3));
146
+ else if (/^(?:[RC].|.[RC]) /.test(field))
147
+ i++; // a rename/copy carries its source path as the next field
148
+ }
149
+ entries.sort();
150
+ return { shown: entries.slice(0, maxShown), total: entries.length };
151
+ }
122
152
  function listBranches(root) {
123
153
  const format = ["%(refname)", "%(objectname)", "%(upstream)", "%(upstream:track,nobracket)", "%(committerdate:iso-strict)", "%(worktreepath)"].join("%00");
124
154
  const out = run(root, ["for-each-ref", `--format=${format}`, "refs/heads/"]) ?? "";
@@ -221,13 +251,30 @@ function prFromSquashCommit(root, commit) {
221
251
  function withPr(verdict, pr) {
222
252
  return pr === undefined ? verdict : { ...verdict, pr, evidence: [...verdict.evidence, `pull request #${pr} (from the local commit subject)`] };
223
253
  }
224
- function mergedVerdict(root, name, head, def, patchIds, searched) {
254
+ /** The default branch's first-parent history: the commits made (or fast-forwarded) directly
255
+ * on it. null when git cannot list it. */
256
+ function firstParentsOf(root, head) {
257
+ const out = run(root, ["rev-list", "--first-parent", "--end-of-options", head, "--"], 60_000);
258
+ if (out === null)
259
+ return null;
260
+ return new Set(out.split("\n").map((l) => l.trim()).filter((l) => SHA.test(l)));
261
+ }
262
+ function mergedVerdict(root, name, head, def, patchIds, firstParents, searched) {
225
263
  if (!def)
226
264
  return { status: "unknown", method: null, evidence: ["no default branch resolved (origin/HEAD, origin/main, origin/master, main, master)"] };
227
265
  const ancestor = predicate(root, ["merge-base", "--is-ancestor", head, def.head]);
228
266
  if (ancestor === null)
229
267
  return { status: "unknown", method: null, evidence: ["git merge-base failed"] };
230
268
  if (ancestor) {
269
+ // A head ON the default branch's first-parent line holds no commits of its own: a branch
270
+ // created and never committed to (or fast-forwarded in). Ancestry would call it merged
271
+ // and prune would delete it with its worktree; it is labeled and kept instead.
272
+ const line = firstParents();
273
+ if (line === null)
274
+ return { status: "unknown", method: null, evidence: [`${head.slice(0, 12)} is an ancestor of ${def.ref}; its first-parent history could not be read`] };
275
+ if (line.has(head)) {
276
+ return { status: "no-commits", method: null, evidence: [`${head.slice(0, 12)} is on ${def.ref}@${def.head.slice(0, 12)} first-parent history: no commits of its own (or fast-forwarded)`] };
277
+ }
231
278
  const verdict = { status: "merged", method: "ancestry", evidence: [`${head.slice(0, 12)} is an ancestor of ${def.ref}@${def.head.slice(0, 12)}`] };
232
279
  return withPr(verdict, prFromMergeCommits(root, name, `${head}..${def.head}`));
233
280
  }
@@ -238,20 +285,46 @@ function mergedVerdict(root, name, head, def, patchIds, searched) {
238
285
  if (!known.ok) {
239
286
  return { status: "unmerged", method: null, evidence: [`not an ancestor of ${def.ref}@${def.head.slice(0, 12)}; squash/rebase search unavailable (default-branch history too large or git failed)`] };
240
287
  }
288
+ // Only default-branch commits AFTER the merge base can have landed this branch — the set
289
+ // `git cherry` compares against. A matching commit already behind the base is the branch's
290
+ // own history: a reland (revert of a revert) or a value flipped back matches the ORIGINAL
291
+ // commit and is not merged. The map keeps the newest commit per patch-id, so when that one
292
+ // is behind the base every older one is too. null → git failed (never "no").
293
+ const landedAfterBase = (commit) => {
294
+ const behind = predicate(root, ["merge-base", "--is-ancestor", commit, base]);
295
+ return behind === null ? null : !behind;
296
+ };
241
297
  const combined = combinedPatchId(root, base, head);
242
298
  if (combined) {
243
299
  const commit = known.map.get(combined);
244
300
  if (commit) {
245
- const verdict = { status: "merged", method: "squash", evidence: [`patch-id of ${base.slice(0, 12)}..${head.slice(0, 12)} equals ${def.ref} commit ${commit.slice(0, 12)}`] };
246
- return withPr(verdict, prFromSquashCommit(root, commit));
301
+ const after = landedAfterBase(commit);
302
+ if (after === null)
303
+ return { status: "unknown", method: null, evidence: ["git merge-base failed"] };
304
+ if (after) {
305
+ const verdict = { status: "merged", method: "squash", evidence: [`patch-id of ${base.slice(0, 12)}..${head.slice(0, 12)} equals ${def.ref} commit ${commit.slice(0, 12)}`] };
306
+ return withPr(verdict, prFromSquashCommit(root, commit));
307
+ }
247
308
  }
248
309
  }
249
310
  // Rebase / cherry-pick: every commit of the branch has a patch-equivalent commit in the
250
- // default branch. Uses the one-time map instead of `git cherry`, whose cost grows with
251
- // the default branch's history for EVERY branch checked.
311
+ // default branch after the merge base. Uses the one-time map instead of `git cherry`, whose
312
+ // cost grows with the default branch's history for EVERY branch checked.
252
313
  const own = patchIdsOf(root, [`${base}..${head}`], null, 30_000);
253
- if (own.ok && own.map.size && [...own.map.keys()].every((id) => known.map.has(id))) {
254
- return { status: "merged", method: "rebase", evidence: [`all ${own.map.size} commit(s) have a patch-equivalent commit in ${def.ref} (last ${searched} searched)`] };
314
+ if (own.ok && own.map.size) {
315
+ let all = true;
316
+ for (const id of own.map.keys()) {
317
+ const commit = known.map.get(id);
318
+ const after = commit ? landedAfterBase(commit) : false;
319
+ if (after === null)
320
+ return { status: "unknown", method: null, evidence: ["git merge-base failed"] };
321
+ if (!after) {
322
+ all = false;
323
+ break;
324
+ }
325
+ }
326
+ if (all)
327
+ return { status: "merged", method: "rebase", evidence: [`all ${own.map.size} commit(s) have a patch-equivalent commit in ${def.ref} after the merge base (last ${searched} searched)`] };
255
328
  }
256
329
  return { status: "unmerged", method: null, evidence: [`not in ${def.ref}@${def.head.slice(0, 12)}; squash/rebase searched last ${searched} commits`] };
257
330
  }
@@ -286,6 +359,8 @@ export function snapshotWorkspace(root, opts) {
286
359
  const searched = opts.squashSearchCommits ?? DEFAULT_SQUASH_SEARCH_COMMITS;
287
360
  let patchIds = null;
288
361
  const lazyPatchIds = () => (patchIds ??= def ? patchIdsOf(main, [def.head], searched, 60_000) : { map: new Map(), ok: true });
362
+ let firstParents;
363
+ const lazyFirstParents = () => (firstParents === undefined ? (firstParents = def ? firstParentsOf(main, def.head) : null) : firstParents);
289
364
  const notes = [];
290
365
  const allWorktrees = listWorktrees(main).map((w) => ({ ...w, date: iso(run(main, ["log", "-1", "--format=%cI", "--end-of-options", w.head, "--"])) }));
291
366
  const rawWorktrees = allWorktrees.length > MAX_WORKTREES ? newestFirst(allWorktrees).slice(0, MAX_WORKTREES) : allWorktrees;
@@ -294,7 +369,9 @@ export function snapshotWorkspace(root, opts) {
294
369
  const mainReal = realpath(main);
295
370
  const worktrees = rawWorktrees.map((w) => ({
296
371
  id: worktreeId(w.path),
297
- path: opts.publish === "full" ? w.path : null,
372
+ // A path with a control character (newline, ESC) is never recorded or printed; without a
373
+ // path `prune --apply` refuses the worktree instead of guessing.
374
+ path: opts.publish === "full" && !CONTROL_CHARS.test(w.path) ? w.path : null,
298
375
  branch: w.branch,
299
376
  head: w.head,
300
377
  is_main: realpath(w.path) === mainReal,
@@ -325,7 +402,7 @@ export function snapshotWorkspace(root, opts) {
325
402
  worktree: (b.worktreePath && worktreeByPath.get(b.worktreePath)) || null,
326
403
  merged: def?.name === b.name
327
404
  ? { status: "unmerged", method: null, evidence: ["default branch"] }
328
- : mergedVerdict(main, b.name, b.head, def, lazyPatchIds, searched),
405
+ : mergedVerdict(main, b.name, b.head, def, lazyPatchIds, lazyFirstParents, searched),
329
406
  };
330
407
  });
331
408
  const record = {
@@ -340,8 +417,8 @@ export function snapshotWorkspace(root, opts) {
340
417
  worktrees,
341
418
  branches,
342
419
  provenance: extracted(1, [
343
- "git worktree list --porcelain", "git for-each-ref refs/heads/", "git status --porcelain",
344
- "git merge-base --is-ancestor", "git diff | git patch-id --stable", "git log -p | git patch-id --stable",
420
+ "git worktree list --porcelain", "git for-each-ref refs/heads/", "git status --porcelain --untracked-files=all",
421
+ "git merge-base --is-ancestor", "git rev-list --first-parent", "git diff | git patch-id --stable", "git log -p | git patch-id --stable",
345
422
  ...notes,
346
423
  ]),
347
424
  };
@@ -1,12 +1,20 @@
1
1
  export interface GitignoreResult {
2
2
  path: string;
3
- action: "created" | "appended" | "unchanged";
3
+ /** `updated` = an existing managed block was brought up to the current entry list. */
4
+ action: "created" | "appended" | "updated" | "unchanged";
5
+ /** Entries this call newly wrote into the managed block (empty when unchanged). */
6
+ added: string[];
4
7
  }
5
8
  /** Defense in depth for integration files written automatically after a clone.
6
9
  * Refuse symlinks, directories/devices, and hard links; require the canonical
7
10
  * target to be the expected top-level file inside the canonical repository root. */
8
11
  export declare function assertSafeTopLevelConfigFile(root: string, name: string): string;
9
- export declare function ensureGitignore(root: string): GitignoreResult;
12
+ /** `upgradeExisting: false` only adds a missing block and never rewrites a present
13
+ * one — for `hunch index`, which also runs in CI and release gates where rewriting
14
+ * a tracked .gitignore would dirty the checkout. Setup and repair commands upgrade. */
15
+ export declare function ensureGitignore(root: string, opts?: {
16
+ upgradeExisting?: boolean;
17
+ }): GitignoreResult;
10
18
  /** Ignore the engineering-memory tree so a private-migrated repo stays code-only.
11
19
  * The kind subdirs the user's records live in (decisions/, bugs/, …) move to the
12
20
  * private overlay; this stops git from re-publishing them. The `.hunch/` dir, its
@@ -14,3 +22,20 @@ export declare function ensureGitignore(root: string): GitignoreResult;
14
22
  export declare function ignoreHunchMemory(root: string): GitignoreResult;
15
23
  /** The .hunch memory subdirs un-published by a private migration (git pathspecs). */
16
24
  export declare const HUNCH_MEMORY_DIRS: string[];
25
+ export interface ManagedGitignoreUpgrade {
26
+ /** The base runtime block, when this repository already has it. */
27
+ base: GitignoreResult | null;
28
+ /** The private-only memory block, when this repository was migrated to an overlay. */
29
+ memory: GitignoreResult | null;
30
+ /** Tracked memory files removed from the git INDEX (the files stay on disk). */
31
+ untracked: string[];
32
+ }
33
+ /** Bring every EXISTING managed block up to the current entry lists without adding
34
+ * a block the repository never had (a repair path, not setup). A private-only
35
+ * block means the repository already declared its memory tree unpublished, so
36
+ * memory directories that a later release added to that block are also removed
37
+ * from the git index — never from disk — as `private --migrate` does for the whole
38
+ * list. */
39
+ export declare function upgradeManagedGitignore(root: string): ManagedGitignoreUpgrade;
40
+ /** Output lines describing what an upgrade changed; empty when nothing did. */
41
+ export declare function describeGitignoreUpgrade(upgrade: ManagedGitignoreUpgrade): string[];