@davesheffer/hunch 1.45.0 → 1.45.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.
|
@@ -61,6 +61,11 @@ export declare const PORTABLE_HOOK_INVOCATION: string;
|
|
|
61
61
|
* or a trailing top-level `if … else … fi` whose every branch ends in
|
|
62
62
|
* `exec`/`exit` — the shape of the pre-commit framework's generated hook. */
|
|
63
63
|
export declare function terminalExitLine(content: string): string | null;
|
|
64
|
+
export declare function isInside(parent: string, child: string): boolean;
|
|
65
|
+
export declare function gitRun(args: string[], cwd: string): {
|
|
66
|
+
status: number | null;
|
|
67
|
+
stdout: string;
|
|
68
|
+
};
|
|
64
69
|
type BlockState = "installed" | "unreachable" | "stale" | "missing";
|
|
65
70
|
/** The result of inspecting the command a managed block actually runs (issue #315). */
|
|
66
71
|
export type HookInvocationHealth = {
|
|
@@ -78,6 +83,21 @@ export type HookInvocationHealth = {
|
|
|
78
83
|
* the subcommand; env assignments and a leading `(` belong to the block's own
|
|
79
84
|
* shape and are stripped. Never spawns anything, never touches the network. */
|
|
80
85
|
export declare function hookInvocationHealth(command: string, root: string): HookInvocationHealth;
|
|
86
|
+
/** The repo's worktree tops as `git worktree list --porcelain` reports them:
|
|
87
|
+
* `linked` is every entry AFTER the first (the first is always the main
|
|
88
|
+
* worktree, or the bare repo) that is not the bare entry, and `count` is how
|
|
89
|
+
* many non-bare worktrees the repo has — the number the shared-hooks note
|
|
90
|
+
* quotes, which must not include a bare repo's phantom entry. `main` is the
|
|
91
|
+
* first entry when it is a checkout, null when the common dir is bare (no
|
|
92
|
+
* main checkout exists). Entries are
|
|
93
|
+
* separated by blank lines and each starts with a `worktree <path>` line.
|
|
94
|
+
* Read straight from git rather than the workspace ledger: this runs during
|
|
95
|
+
* `hunch init`, before any ledger exists. */
|
|
96
|
+
export declare function worktreeTops(root: string): {
|
|
97
|
+
linked: string[];
|
|
98
|
+
count: number;
|
|
99
|
+
main: string | null;
|
|
100
|
+
};
|
|
81
101
|
export declare function installPostCommitHook(root: string, invocation: string, opts?: {
|
|
82
102
|
private?: boolean;
|
|
83
103
|
commit?: boolean;
|
|
@@ -139,12 +139,12 @@ function realish(p) {
|
|
|
139
139
|
return resolve(p);
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
-
function isInside(parent, child) {
|
|
142
|
+
export function isInside(parent, child) {
|
|
143
143
|
const norm = (p) => (process.platform === "win32" ? realish(p).toLowerCase() : realish(p));
|
|
144
144
|
const rel = relative(norm(parent), norm(child));
|
|
145
145
|
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
146
146
|
}
|
|
147
|
-
function gitRun(args, cwd) {
|
|
147
|
+
export function gitRun(args, cwd) {
|
|
148
148
|
const p = spawnSync("git", args, { cwd, encoding: "utf8", env: initiatorChildEnv(), stdio: ["ignore", "pipe", "ignore"] });
|
|
149
149
|
return { status: p.status, stdout: (p.stdout ?? "").trim() };
|
|
150
150
|
}
|
|
@@ -504,14 +504,16 @@ function snippetFor(t, mark, build, localInvocation) {
|
|
|
504
504
|
* `linked` is every entry AFTER the first (the first is always the main
|
|
505
505
|
* worktree, or the bare repo) that is not the bare entry, and `count` is how
|
|
506
506
|
* many non-bare worktrees the repo has — the number the shared-hooks note
|
|
507
|
-
* quotes, which must not include a bare repo's phantom entry.
|
|
507
|
+
* quotes, which must not include a bare repo's phantom entry. `main` is the
|
|
508
|
+
* first entry when it is a checkout, null when the common dir is bare (no
|
|
509
|
+
* main checkout exists). Entries are
|
|
508
510
|
* separated by blank lines and each starts with a `worktree <path>` line.
|
|
509
511
|
* Read straight from git rather than the workspace ledger: this runs during
|
|
510
512
|
* `hunch init`, before any ledger exists. */
|
|
511
|
-
function worktreeTops(root) {
|
|
513
|
+
export function worktreeTops(root) {
|
|
512
514
|
const out = gitRun(["worktree", "list", "--porcelain"], root).stdout;
|
|
513
515
|
if (!out)
|
|
514
|
-
return { linked: [], count: 0 };
|
|
516
|
+
return { linked: [], count: 0, main: null };
|
|
515
517
|
const entries = normalizeEol(out).split(/\n\s*\n/).map((e) => e.split("\n"));
|
|
516
518
|
const parsed = entries
|
|
517
519
|
.map((lines) => ({
|
|
@@ -519,7 +521,12 @@ function worktreeTops(root) {
|
|
|
519
521
|
bare: lines.some((l) => l.trim() === "bare"),
|
|
520
522
|
}))
|
|
521
523
|
.filter((e) => e.path !== "");
|
|
522
|
-
|
|
524
|
+
const first = parsed[0];
|
|
525
|
+
return {
|
|
526
|
+
linked: parsed.slice(1).filter((e) => !e.bare).map((e) => e.path),
|
|
527
|
+
count: parsed.filter((e) => !e.bare).length,
|
|
528
|
+
main: first && !first.bare ? first.path : null,
|
|
529
|
+
};
|
|
523
530
|
}
|
|
524
531
|
/** The LINKED worktree an invocation is bound to — one it can only work from
|
|
525
532
|
* inside, because some token is an absolute path under it (that worktree's own
|
|
@@ -26,6 +26,7 @@ import { renderHunchSection, stripManagedSection, upsertSection, updateClaudeMd,
|
|
|
26
26
|
import { headFileContent, isGitCleanPath } from "../extractors/git.js";
|
|
27
27
|
import { parseJsonc } from "../core/jsonc.js";
|
|
28
28
|
import { parse as parseToml } from "smol-toml";
|
|
29
|
+
import { publishedMcpInvocation } from "../cli/invocation.js";
|
|
29
30
|
/** JSONC comments are user prose. A parsed object cannot retain their position,
|
|
30
31
|
* so refuse a write rather than reserialize the file without them. */
|
|
31
32
|
function hasJsoncComments(raw) {
|
|
@@ -388,18 +389,52 @@ export function writeVscodeHooks(root, inv) {
|
|
|
388
389
|
Stop: [{ type: "command", command }],
|
|
389
390
|
});
|
|
390
391
|
}
|
|
392
|
+
const WINDSURF_EVENTS = ["pre_user_prompt", "pre_write_code", "post_write_code", "post_run_command"];
|
|
393
|
+
/** The published npx pin (any version), in the current spelling and in the fully
|
|
394
|
+
* quoted one written before v1.21.1 (`"npx" "-y" "--package=…" …`). */
|
|
395
|
+
const PUBLISHED_PIN = /^\s*"?npx(?:\.cmd)?"?\s+"?-y"?\s+"?--package=(?:hunch-exact@npm:)?@davesheffer\/hunch/;
|
|
396
|
+
/** The events of `file` holding a Hunch hook entry, when it has at least one and
|
|
397
|
+
* every one of them runs the published pin; null otherwise. Throws, like every
|
|
398
|
+
* writer, on a file it cannot parse. */
|
|
399
|
+
function publishedPinEvents(file) {
|
|
400
|
+
const hooks = objectField(readJsonObj(file), "hooks", file);
|
|
401
|
+
const events = new Set();
|
|
402
|
+
let onlyPins = true;
|
|
403
|
+
const collect = (event) => (entry) => {
|
|
404
|
+
const e = entry && typeof entry === "object" ? entry : null;
|
|
405
|
+
if (Array.isArray(e?.hooks) && typeof e.command !== "string")
|
|
406
|
+
e.hooks.forEach(collect(event));
|
|
407
|
+
else if (typeof e?.command === "string") {
|
|
408
|
+
if (PUBLISHED_PIN.test(e.command))
|
|
409
|
+
events.add(event);
|
|
410
|
+
else
|
|
411
|
+
onlyPins = false;
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
for (const [event, entries] of Object.entries(hooks)) {
|
|
415
|
+
if (Array.isArray(entries))
|
|
416
|
+
entries.filter(isHunchProviderHook).forEach(collect(event));
|
|
417
|
+
}
|
|
418
|
+
return onlyPins && events.size > 0 ? events : null;
|
|
419
|
+
}
|
|
391
420
|
/** Windsurf's documented workspace hooks. It only supports deterministic
|
|
392
421
|
* pre-hook blocking via exit code 2, so Hunch uses rules + MCP for context and
|
|
393
422
|
* reserves the hook for strict edit protection and pipeline observation. */
|
|
394
423
|
export function writeWindsurfHooks(root, inv) {
|
|
395
424
|
const file = join(root, ".windsurf", "hooks.json");
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
425
|
+
const published = hookCommand(publishedMcpInvocation(), "windsurf");
|
|
426
|
+
let command = hookCommand(inv, "windsurf");
|
|
427
|
+
// A source-checkout run (tsx / local dist) must never replace a tracked
|
|
428
|
+
// published pin with a machine-local path (#466): a complete pinned file is
|
|
429
|
+
// kept as is, and a partial one is completed with the published pin.
|
|
430
|
+
if (command !== published) {
|
|
431
|
+
const pinned = publishedPinEvents(file);
|
|
432
|
+
if (pinned && WINDSURF_EVENTS.every((event) => pinned.has(event)))
|
|
433
|
+
return file;
|
|
434
|
+
if (pinned)
|
|
435
|
+
command = published;
|
|
436
|
+
}
|
|
437
|
+
return writeHookConfig(file, Object.fromEntries(WINDSURF_EVENTS.map((event) => [event, [{ command, show_output: false }]])));
|
|
403
438
|
}
|
|
404
439
|
function antigravityHandler(command) {
|
|
405
440
|
return { type: "command", command, timeout: 15 };
|
|
@@ -17,6 +17,8 @@ export interface UninstallPlan {
|
|
|
17
17
|
path: string;
|
|
18
18
|
reason: string;
|
|
19
19
|
}>;
|
|
20
|
+
/** Consequences beyond this checkout (other worktrees, other branches); printed, never refused over. */
|
|
21
|
+
warnings?: string[];
|
|
20
22
|
}
|
|
21
23
|
/** Read-only: everything `hunch uninstall` would do in `root`. */
|
|
22
24
|
export declare function planUninstall(root: string, opts?: {
|
|
@@ -14,15 +14,15 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync, lstatSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, writeFileSync, chmodSync } from "node:fs";
|
|
16
16
|
import { spawnSync } from "node:child_process";
|
|
17
|
-
import { basename, join, relative } from "node:path";
|
|
17
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
18
18
|
import { parse as parseToml } from "smol-toml";
|
|
19
19
|
import { writeFileAtomic } from "../core/io.js";
|
|
20
|
-
import { gitCommonDir, isGitRepo, separateGitDirLink } from "../extractors/git.js";
|
|
20
|
+
import { currentBranch, gitCommonDir, isGitRepo, isLinkedWorktree, separateGitDirLink } from "../extractors/git.js";
|
|
21
21
|
import { HUNCH_END, HUNCH_START } from "./claudemd.js";
|
|
22
22
|
import { ciWorkflowYaml } from "./ciAction.js";
|
|
23
23
|
import { assertSafeTopLevelConfigFile, stripManagedGitignore } from "./gitignore.js";
|
|
24
24
|
import { isHunchHookCommand } from "./hookmatch.js";
|
|
25
|
-
import { planHookRemoval } from "./hooks.js";
|
|
25
|
+
import { gitRun, isInside, planHookRemoval, worktreeTops } from "./hooks.js";
|
|
26
26
|
import { ATTR_LINES, targetRepositoryEnv } from "./mergeDriver.js";
|
|
27
27
|
import { antigravityMcpFile, isHunchProviderHook, readJsonObj, TOML_END, TOML_START, windsurfMcpFile } from "./providers.js";
|
|
28
28
|
import { SLASH_COMMAND_FILES } from "./scaffold.js";
|
|
@@ -195,6 +195,31 @@ function gitConfigHasDrivers(root) {
|
|
|
195
195
|
const r = spawnSync("git", ["config", "--local", "--get-regexp", "^merge\\.hunch(-grounding)?\\."], { cwd: root, env: targetRepositoryEnv(), encoding: "utf8" });
|
|
196
196
|
return r.status === 0 && r.stdout.trim() !== "";
|
|
197
197
|
}
|
|
198
|
+
/** The branch the clone treats as default: origin's HEAD, else a local main, else
|
|
199
|
+
* master; "" when none can be named. */
|
|
200
|
+
function defaultBranch(root) {
|
|
201
|
+
const head = gitRun(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], root);
|
|
202
|
+
if (head.status === 0 && head.stdout)
|
|
203
|
+
return head.stdout.replace(/^origin\//, "");
|
|
204
|
+
for (const b of ["main", "master"]) {
|
|
205
|
+
if (gitRun(["show-ref", "--verify", "--quiet", `refs/heads/${b}`], root).status === 0)
|
|
206
|
+
return b;
|
|
207
|
+
}
|
|
208
|
+
return "";
|
|
209
|
+
}
|
|
210
|
+
/** The marker is committed per branch: off the default branch, say the rest of the clone still has Hunch. */
|
|
211
|
+
function branchWarning(root) {
|
|
212
|
+
try {
|
|
213
|
+
const branch = currentBranch(root);
|
|
214
|
+
const def = branch ? defaultBranch(root) : "";
|
|
215
|
+
if (!branch || !def || branch === def)
|
|
216
|
+
return null;
|
|
217
|
+
return `the ${OPT_OUT_FILE} marker is only on branch ${branch}; other branches of this clone still have Hunch's instruction blocks and config — commit and merge it, or run uninstall there too`;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
198
223
|
/** The overlay clone a pointer names, for the "kept" note. Never deleted: it is
|
|
199
224
|
* its own repository holding memory that may not be pushed yet. */
|
|
200
225
|
function pointerOverlay(file) {
|
|
@@ -295,22 +320,49 @@ export function planUninstall(root, opts = {}) {
|
|
|
295
320
|
p.steps.push({ path: ciRel, kind: "keep", note: "it differs from the workflow Hunch generates (edited?); delete it yourself if you no longer want the Hunch Guard check" });
|
|
296
321
|
}
|
|
297
322
|
}
|
|
298
|
-
// Git hooks + merge driver.
|
|
323
|
+
// Git hooks + merge driver. Both live in the clone's common git dir, so they
|
|
324
|
+
// serve every worktree and every branch (#459): a linked worktree leaves them to
|
|
325
|
+
// the main checkout, and the main checkout says which worktrees lose them too.
|
|
326
|
+
// A bare common dir has no main checkout, so any worktree removes them and warns.
|
|
327
|
+
const warnings = [];
|
|
299
328
|
if (isGitRepo(root)) {
|
|
329
|
+
const commonDir = gitCommonDir(root);
|
|
330
|
+
const top = gitRun(["rev-parse", "--show-toplevel"], root).stdout;
|
|
331
|
+
const here = top || root;
|
|
332
|
+
const same = (a, b) => isInside(a, b) && isInside(b, a);
|
|
333
|
+
// The main checkout as git lists it, not the common dir's parent, which in a
|
|
334
|
+
// bare clone or a --separate-git-dir checkout is no checkout at all.
|
|
335
|
+
const tops = worktreeTops(root);
|
|
336
|
+
const mainCheckout = tops.main;
|
|
337
|
+
const linked = commonDir !== "" && mainCheckout !== null && !same(mainCheckout, here) && isLinkedWorktree(root);
|
|
338
|
+
// The same test `hunch init` uses: in the common dir, or outside this checkout.
|
|
339
|
+
const shared = (file) => (commonDir !== "" && isInside(commonDir, file)) || (top !== "" && !isInside(top, file));
|
|
340
|
+
const elsewhere = (what, it) => `${what} shared by every worktree of this clone — run \`hunch uninstall\` from the main checkout (${mainCheckout}) to remove ${it}`;
|
|
341
|
+
let removesShared = false;
|
|
300
342
|
for (const h of planHookRemoval(root)) {
|
|
301
343
|
const path = p.rel(h.path);
|
|
302
344
|
if (h.reason)
|
|
303
345
|
p.steps.push({ path, kind: "keep", note: h.reason });
|
|
304
|
-
else if (h.
|
|
305
|
-
p.steps.push({ path, kind: "
|
|
346
|
+
else if (linked && shared(h.path))
|
|
347
|
+
p.steps.push({ path, kind: "keep", note: elsewhere(`Hunch's ${h.hookName} git hook is`, "it") });
|
|
306
348
|
else {
|
|
307
|
-
|
|
308
|
-
|
|
349
|
+
if (shared(h.path))
|
|
350
|
+
removesShared = true;
|
|
351
|
+
if (h.next === null)
|
|
352
|
+
p.steps.push({ path, kind: "delete", note: `Hunch's ${h.hookName} git hook`, run: () => rmSync(h.path, { force: true }) });
|
|
353
|
+
else {
|
|
354
|
+
const next = h.next;
|
|
355
|
+
p.steps.push({ path, kind: "edit", note: `removed Hunch's block from the ${h.hookName} git hook (your own lines are kept)`, run: () => { writeFileSync(h.path, next); chmodSync(h.path, 0o755); } });
|
|
356
|
+
}
|
|
309
357
|
}
|
|
310
358
|
}
|
|
311
|
-
if (gitConfigHasDrivers(root)) {
|
|
359
|
+
if (gitConfigHasDrivers(root) && linked) {
|
|
360
|
+
p.steps.push({ path: p.rel(join(commonDir, "config")), kind: "keep", note: elsewhere("the merge.hunch and merge.hunch-grounding drivers are", "them") });
|
|
361
|
+
}
|
|
362
|
+
else if (gitConfigHasDrivers(root)) {
|
|
363
|
+
removesShared = true;
|
|
312
364
|
p.steps.push({
|
|
313
|
-
path: ".git/config", kind: "edit", note: "unregistered the merge.hunch and merge.hunch-grounding drivers",
|
|
365
|
+
path: commonDir ? p.rel(join(commonDir, "config")) : ".git/config", kind: "edit", note: "unregistered the merge.hunch and merge.hunch-grounding drivers",
|
|
314
366
|
run: () => {
|
|
315
367
|
for (const section of ["merge.hunch", "merge.hunch-grounding"]) {
|
|
316
368
|
spawnSync("git", ["config", "--local", "--remove-section", section], { cwd: root, env: targetRepositoryEnv(), stdio: "ignore" });
|
|
@@ -318,6 +370,13 @@ export function planUninstall(root, opts = {}) {
|
|
|
318
370
|
},
|
|
319
371
|
});
|
|
320
372
|
}
|
|
373
|
+
const others = linked ? [] : tops.linked.filter((w) => !same(w, here)).map((w) => resolve(w));
|
|
374
|
+
if (removesShared && others.length) {
|
|
375
|
+
warnings.push(`the git hooks and merge drivers are shared with ${others.length === 1 ? "the linked worktree" : `${others.length} linked worktrees`} ${others.join(", ")}, so they are removed there too, though ${others.length === 1 ? "it still has" : "those still have"} Hunch and no ${OPT_OUT_FILE} marker`);
|
|
376
|
+
}
|
|
377
|
+
const onBranch = branchWarning(root);
|
|
378
|
+
if (onBranch)
|
|
379
|
+
warnings.push(onBranch);
|
|
321
380
|
}
|
|
322
381
|
const attrs = p.text(".gitattributes");
|
|
323
382
|
if (attrs !== null) {
|
|
@@ -378,7 +437,7 @@ export function planUninstall(root, opts = {}) {
|
|
|
378
437
|
for (const dir of overlays) {
|
|
379
438
|
p.steps.push({ path: p.rel(dir), kind: "keep", note: "a private/shared overlay clone: its own repository with memory that may not be pushed; never deleted here" });
|
|
380
439
|
}
|
|
381
|
-
return { root, purge, steps: p.steps, refused: p.refused };
|
|
440
|
+
return { root, purge, steps: p.steps, refused: p.refused, warnings };
|
|
382
441
|
}
|
|
383
442
|
/** Directories Hunch's writers create; removed only when left empty. Deepest first. */
|
|
384
443
|
const PRUNE_DIRS = [
|
|
@@ -430,6 +489,8 @@ export function formatUninstallPlan(plan, dryRun) {
|
|
|
430
489
|
out.push(` ✗ ${r.path}: ${r.reason}`);
|
|
431
490
|
for (const s of plan.steps)
|
|
432
491
|
out.push(` ${icon(s.kind)} ${verb(s.kind)} ${s.path} — ${s.note}`);
|
|
492
|
+
for (const w of plan.warnings ?? [])
|
|
493
|
+
out.push(` ⚠ ${w}`);
|
|
433
494
|
return out;
|
|
434
495
|
}
|
|
435
496
|
//# sourceMappingURL=uninstall.js.map
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://www.hunchmemory.com",
|
|
10
|
-
"version": "1.45.
|
|
10
|
+
"version": "1.45.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@davesheffer/hunch",
|
|
16
|
-
"version": "1.45.
|
|
16
|
+
"version": "1.45.1",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|