@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.
- package/dist/cli/index.js +193 -65
- package/dist/cli/integrations.js +10 -0
- package/dist/client/state.d.ts +2 -1
- package/dist/client/state.js +1 -0
- package/dist/core/agenthook.d.ts +14 -0
- package/dist/core/agenthook.js +55 -8
- package/dist/core/capturetoken.d.ts +30 -3
- package/dist/core/capturetoken.js +29 -3
- package/dist/core/changeProof.js +5 -1
- package/dist/core/checkreport.d.ts +7 -0
- package/dist/core/checkreport.js +20 -3
- package/dist/core/compare.js +3 -2
- package/dist/core/correction.d.ts +10 -4
- package/dist/core/correction.js +7 -4
- package/dist/core/countersign.d.ts +28 -0
- package/dist/core/countersign.js +50 -0
- package/dist/core/reviewqueue.js +6 -1
- package/dist/core/spawnCommand.js +41 -9
- package/dist/core/stateHttp.d.ts +1 -1
- package/dist/core/stateHttp.js +3 -1
- package/dist/core/taskReportEvidence.js +31 -11
- package/dist/core/topics.js +1 -1
- package/dist/core/types.d.ts +1 -0
- package/dist/core/workspace.d.ts +23 -1
- package/dist/core/workspace.js +31 -7
- package/dist/extractors/diff.d.ts +34 -0
- package/dist/extractors/diff.js +147 -5
- package/dist/extractors/git.d.ts +40 -11
- package/dist/extractors/git.js +147 -43
- package/dist/extractors/workspaces.d.ts +10 -0
- package/dist/extractors/workspaces.js +92 -15
- package/dist/integrations/gitignore.d.ts +27 -2
- package/dist/integrations/gitignore.js +103 -17
- package/dist/integrations/hooks.d.ts +61 -7
- package/dist/integrations/hooks.js +330 -43
- package/dist/integrations/scaffold.js +1 -1
- package/dist/integrations/workspaceLedger.d.ts +23 -3
- package/dist/integrations/workspaceLedger.js +114 -8
- package/dist/mcp/server.js +115 -56
- package/dist/serve/app.d.ts +4 -0
- package/dist/serve/app.js +56 -36
- package/dist/store/hunchStore.d.ts +4 -2
- package/dist/store/hunchStore.js +23 -6
- package/dist/store/stateBinding.js +111 -42
- package/dist/wiki/wiki.d.ts +7 -0
- package/dist/wiki/wiki.js +19 -8
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/extractors/git.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
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
|
-
|
|
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 =
|
|
2449
|
-
const untracked =
|
|
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
|
-
|
|
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
|
-
/**
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
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
|
-
/**
|
|
2477
|
-
|
|
2478
|
-
|
|
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
|
|
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
|
|
2486
|
-
* Binary
|
|
2487
|
-
*
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
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
|
-
|
|
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 { /*
|
|
2505
|
-
|
|
2506
|
-
|
|
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
|
-
|
|
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", "--
|
|
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
|
-
|
|
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
|
|
246
|
-
|
|
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
|
|
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
|
|
254
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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[];
|