@davesheffer/hunch 1.45.0 → 1.45.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 +41 -16
- package/dist/cli/serve.d.ts +2 -0
- package/dist/cli/serve.js +2 -1
- package/dist/core/agenthook.d.ts +1 -0
- package/dist/core/agenthook.js +56 -20
- package/dist/core/delivery.d.ts +5 -0
- package/dist/core/delivery.js +46 -4
- package/dist/core/pipeline.d.ts +9 -2
- package/dist/core/pipeline.js +13 -3
- package/dist/core/procstart.d.ts +17 -3
- package/dist/core/procstart.js +26 -5
- package/dist/core/taskSelection.d.ts +1 -0
- package/dist/core/taskSelection.js +90 -11
- package/dist/core/whyRecord.d.ts +47 -0
- package/dist/core/whyRecord.js +105 -0
- package/dist/extractors/git.d.ts +32 -0
- package/dist/extractors/git.js +177 -66
- package/dist/integrations/hooks.d.ts +20 -0
- package/dist/integrations/hooks.js +13 -6
- package/dist/integrations/providers.js +42 -7
- package/dist/integrations/team.js +2 -2
- package/dist/integrations/uninstall.d.ts +9 -0
- package/dist/integrations/uninstall.js +90 -11
- package/dist/mcp/server.js +136 -26
- package/dist/serve/writelock.d.ts +3 -4
- package/dist/serve/writelock.js +9 -18
- package/dist/store/hunchStore.d.ts +10 -0
- package/dist/store/hunchStore.js +80 -1
- package/dist/store/jsonStore.d.ts +11 -1
- package/dist/store/jsonStore.js +38 -13
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/extractors/git.js
CHANGED
|
@@ -154,10 +154,11 @@ function headShaWithEnv(cwd, env) {
|
|
|
154
154
|
function gitSafeWithEnv(args, cwd, env) {
|
|
155
155
|
try {
|
|
156
156
|
return execFileSync("git", ["-C", cwd, ...args], {
|
|
157
|
-
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout:
|
|
157
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: pullLocalGitTimeoutMs(),
|
|
158
158
|
}).trim();
|
|
159
159
|
}
|
|
160
|
-
catch {
|
|
160
|
+
catch (error) {
|
|
161
|
+
notePullGitError(error);
|
|
161
162
|
return "";
|
|
162
163
|
}
|
|
163
164
|
}
|
|
@@ -223,6 +224,60 @@ function clearStrandedIndexLock(repoDir, env, sinceMs, error) {
|
|
|
223
224
|
return false; // no lock present, or git itself unavailable — nothing to heal
|
|
224
225
|
}
|
|
225
226
|
}
|
|
227
|
+
/** Why the last pull-path git call failed (issue #482). The bounded helpers swallow
|
|
228
|
+
* their errors into booleans; they also note the error here so mergeRemote can attach
|
|
229
|
+
* it to the failing step. Synchronous code only, so a module slot is race-free. */
|
|
230
|
+
let lastPullGitError;
|
|
231
|
+
function notePullGitError(error) { lastPullGitError = error; }
|
|
232
|
+
/** Captured git stderr can echo a remote URL with embedded credentials; strip the
|
|
233
|
+
* userinfo (`://user:pass@`, `://token@`) before it is stored or printed. */
|
|
234
|
+
export function redactUrlUserinfo(text) {
|
|
235
|
+
return text.replace(/:\/\/[^\s/@'"]+@/g, "://***@");
|
|
236
|
+
}
|
|
237
|
+
function pullFailureDetail(step, error) {
|
|
238
|
+
const detail = { step };
|
|
239
|
+
if (!error || typeof error !== "object")
|
|
240
|
+
return detail;
|
|
241
|
+
const err = error;
|
|
242
|
+
if (typeof err.code === "string" && err.code)
|
|
243
|
+
detail.code = err.code;
|
|
244
|
+
else if (typeof err.signal === "string" && err.signal)
|
|
245
|
+
detail.code = err.signal;
|
|
246
|
+
else if (typeof err.status === "number")
|
|
247
|
+
detail.code = `exit ${err.status}`;
|
|
248
|
+
const stderr = (typeof err.stderr === "string" ? err.stderr : Buffer.isBuffer(err.stderr) ? err.stderr.toString("utf8") : "").trim();
|
|
249
|
+
if (stderr)
|
|
250
|
+
detail.stderr = redactUrlUserinfo(stderr).slice(-400);
|
|
251
|
+
return detail;
|
|
252
|
+
}
|
|
253
|
+
/** One-line rendering for logs and refusal messages: `step: fetch, ETIMEDOUT, fatal: ...`. */
|
|
254
|
+
export function describePullFailure(detail) {
|
|
255
|
+
if (!detail)
|
|
256
|
+
return "";
|
|
257
|
+
const firstLine = detail.stderr?.split(/\r?\n/).find((line) => line.trim())?.trim();
|
|
258
|
+
return [`step: ${detail.step}`, detail.code, firstLine].filter(Boolean).join(", ");
|
|
259
|
+
}
|
|
260
|
+
/** HUNCH_PULL_GIT_TIMEOUT_MS: per-process override of the read-side pull's DEFAULT
|
|
261
|
+
* remote budget (ls-remote/fetch/merge) for slow runners — a caller's explicit
|
|
262
|
+
* timeoutMs still wins; ignored unless a positive integer. */
|
|
263
|
+
export function pullGitTimeoutOverride() {
|
|
264
|
+
const raw = process.env.HUNCH_PULL_GIT_TIMEOUT_MS?.trim();
|
|
265
|
+
if (!raw || !/^\d+$/.test(raw))
|
|
266
|
+
return undefined;
|
|
267
|
+
const value = Number(raw);
|
|
268
|
+
return value > 0 ? Math.min(600_000, value) : undefined;
|
|
269
|
+
}
|
|
270
|
+
/** Remote budget for one read-side pull: an explicit caller budget always wins;
|
|
271
|
+
* HUNCH_PULL_GIT_TIMEOUT_MS only replaces the 5s default. */
|
|
272
|
+
export function pullRemoteTimeoutMs(callerMs) {
|
|
273
|
+
return callerMs ?? pullGitTimeoutOverride() ?? DEFAULT_READ_REMOTE_TIMEOUT_MS;
|
|
274
|
+
}
|
|
275
|
+
/** Budget for the bounded local git helpers (status, rev-parse, ls-tree): 2s by
|
|
276
|
+
* default, scaled with HUNCH_PULL_GIT_TIMEOUT_MS to max(2s, override / 2.5). */
|
|
277
|
+
export function pullLocalGitTimeoutMs() {
|
|
278
|
+
const override = pullGitTimeoutOverride();
|
|
279
|
+
return override === undefined ? 2_000 : Math.max(2_000, Math.round(override / 2.5));
|
|
280
|
+
}
|
|
226
281
|
/** Compare physical directory identity before path text. Git for Windows can
|
|
227
282
|
* return an 8.3/short or differently-cased spelling for the same top-level
|
|
228
283
|
* directory that Node reached through its long path. A nonzero file ID keeps
|
|
@@ -525,7 +580,7 @@ function observeGit(observer, event) {
|
|
|
525
580
|
catch { /* observation cannot change a Git outcome */ }
|
|
526
581
|
}
|
|
527
582
|
const CAPTURE_REMOTE_TIMEOUT_MS = 15_000;
|
|
528
|
-
const
|
|
583
|
+
export const DEFAULT_READ_REMOTE_TIMEOUT_MS = 5_000;
|
|
529
584
|
// A capture can spend roughly 90s in commit + bounded merge/push/retry seams.
|
|
530
585
|
// A contending writer waits beyond that proven ceiling, then takes the lock and
|
|
531
586
|
// drains every already-durable JSON write itself. This removes the old "maybe a
|
|
@@ -950,10 +1005,11 @@ function hunchWorktreeClean(hunchDir, env) {
|
|
|
950
1005
|
encoding: "utf8",
|
|
951
1006
|
stdio: ["ignore", "pipe", "ignore"],
|
|
952
1007
|
env,
|
|
953
|
-
timeout:
|
|
1008
|
+
timeout: pullLocalGitTimeoutMs(),
|
|
954
1009
|
}).trim() === "";
|
|
955
1010
|
}
|
|
956
|
-
catch {
|
|
1011
|
+
catch (error) {
|
|
1012
|
+
notePullGitError(error);
|
|
957
1013
|
return false;
|
|
958
1014
|
}
|
|
959
1015
|
}
|
|
@@ -965,7 +1021,7 @@ function overlayGitTreeIsSafe(hunchDir, revision, env) {
|
|
|
965
1021
|
encoding: "utf8",
|
|
966
1022
|
stdio: ["ignore", "pipe", "ignore"],
|
|
967
1023
|
env,
|
|
968
|
-
timeout:
|
|
1024
|
+
timeout: pullLocalGitTimeoutMs(),
|
|
969
1025
|
maxBuffer: 64 * 1024 * 1024,
|
|
970
1026
|
});
|
|
971
1027
|
return hunchTreeAttributesAreSafe(listing, (oid) => {
|
|
@@ -974,16 +1030,18 @@ function overlayGitTreeIsSafe(hunchDir, revision, env) {
|
|
|
974
1030
|
encoding: "utf8",
|
|
975
1031
|
stdio: ["ignore", "pipe", "ignore"],
|
|
976
1032
|
env,
|
|
977
|
-
timeout:
|
|
1033
|
+
timeout: pullLocalGitTimeoutMs(),
|
|
978
1034
|
maxBuffer: 4 * 1024 * 1024,
|
|
979
1035
|
});
|
|
980
1036
|
}
|
|
981
|
-
catch {
|
|
1037
|
+
catch (error) {
|
|
1038
|
+
notePullGitError(error);
|
|
982
1039
|
return null;
|
|
983
1040
|
}
|
|
984
1041
|
});
|
|
985
1042
|
}
|
|
986
|
-
catch {
|
|
1043
|
+
catch (error) {
|
|
1044
|
+
notePullGitError(error);
|
|
987
1045
|
return false;
|
|
988
1046
|
}
|
|
989
1047
|
}
|
|
@@ -1141,7 +1199,7 @@ function contractRemoteHeads(hunchDir, contract, direction, env, timeoutMs) {
|
|
|
1141
1199
|
"ls-remote", "--refs", "--heads", "--upload-pack=git-upload-pack", url,
|
|
1142
1200
|
], {
|
|
1143
1201
|
encoding: "utf8",
|
|
1144
|
-
stdio: ["ignore", "pipe", "
|
|
1202
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1145
1203
|
env: boundedTeamEnv(env),
|
|
1146
1204
|
timeout: timeoutMs,
|
|
1147
1205
|
});
|
|
@@ -1156,7 +1214,8 @@ function contractRemoteHeads(hunchDir, contract, direction, env, timeoutMs) {
|
|
|
1156
1214
|
}
|
|
1157
1215
|
return heads;
|
|
1158
1216
|
}
|
|
1159
|
-
catch {
|
|
1217
|
+
catch (error) {
|
|
1218
|
+
notePullGitError(error);
|
|
1160
1219
|
return null;
|
|
1161
1220
|
}
|
|
1162
1221
|
}
|
|
@@ -1172,22 +1231,23 @@ function fetchContractRef(hunchDir, contract, env, timeoutMs) {
|
|
|
1172
1231
|
"-c", `core.hooksPath=${hooksDir}`,
|
|
1173
1232
|
"fetch", "--no-tags", "--no-write-fetch-head", "--upload-pack=git-upload-pack",
|
|
1174
1233
|
contract.fetchUrl, `+${contract.ref}:${TEAM_FETCH_REF}`,
|
|
1175
|
-
], { stdio: "ignore", env: boundedTeamEnv(env), timeout: timeoutMs });
|
|
1234
|
+
], { stdio: ["ignore", "ignore", "pipe"], env: boundedTeamEnv(env), timeout: timeoutMs });
|
|
1176
1235
|
if (!contractReady(contract))
|
|
1177
1236
|
return "";
|
|
1178
1237
|
const oid = execFileSync("git", ["-C", hunchDir, "rev-parse", "--verify", TEAM_FETCH_REF], {
|
|
1179
|
-
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout:
|
|
1238
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: pullLocalGitTimeoutMs(),
|
|
1180
1239
|
}).trim();
|
|
1181
1240
|
return /^[0-9a-f]{40,64}$/i.test(oid) ? oid : "";
|
|
1182
1241
|
}
|
|
1183
|
-
catch {
|
|
1242
|
+
catch (error) {
|
|
1243
|
+
notePullGitError(error);
|
|
1184
1244
|
return "";
|
|
1185
1245
|
}
|
|
1186
1246
|
}
|
|
1187
1247
|
function setContractUpstream(hunchDir, contract, env) {
|
|
1188
1248
|
try {
|
|
1189
1249
|
const branch = execFileSync("git", ["-C", hunchDir, "symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
1190
|
-
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout:
|
|
1250
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: pullLocalGitTimeoutMs(),
|
|
1191
1251
|
}).trim();
|
|
1192
1252
|
if (!branch)
|
|
1193
1253
|
return false;
|
|
@@ -1198,13 +1258,14 @@ function setContractUpstream(hunchDir, contract, env) {
|
|
|
1198
1258
|
if (!oid || !remoteBranch)
|
|
1199
1259
|
return false;
|
|
1200
1260
|
execFileSync("git", ["-C", hunchDir, "update-ref", `refs/remotes/origin/${remoteBranch}`, oid], {
|
|
1201
|
-
stdio: "ignore", env, timeout:
|
|
1261
|
+
stdio: "ignore", env, timeout: pullLocalGitTimeoutMs(),
|
|
1202
1262
|
});
|
|
1203
|
-
execFileSync("git", ["-C", hunchDir, "config", `branch.${branch}.remote`, "origin"], { stdio: "ignore", env, timeout:
|
|
1204
|
-
execFileSync("git", ["-C", hunchDir, "config", `branch.${branch}.merge`, contract.ref], { stdio: "ignore", env, timeout:
|
|
1263
|
+
execFileSync("git", ["-C", hunchDir, "config", `branch.${branch}.remote`, "origin"], { stdio: "ignore", env, timeout: pullLocalGitTimeoutMs() });
|
|
1264
|
+
execFileSync("git", ["-C", hunchDir, "config", `branch.${branch}.merge`, contract.ref], { stdio: "ignore", env, timeout: pullLocalGitTimeoutMs() });
|
|
1205
1265
|
return contractReady(contract);
|
|
1206
1266
|
}
|
|
1207
|
-
catch {
|
|
1267
|
+
catch (error) {
|
|
1268
|
+
notePullGitError(error);
|
|
1208
1269
|
return false;
|
|
1209
1270
|
}
|
|
1210
1271
|
}
|
|
@@ -1215,7 +1276,7 @@ function setContractUpstream(hunchDir, contract, env) {
|
|
|
1215
1276
|
function unbornBootstrapFingerprint(hunchDir, env) {
|
|
1216
1277
|
try {
|
|
1217
1278
|
const status = execFileSync("git", ["-C", hunchDir, "status", "--porcelain=v1", "--untracked-files=all", "--", "."], {
|
|
1218
|
-
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout:
|
|
1279
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: pullLocalGitTimeoutMs(),
|
|
1219
1280
|
}).trim();
|
|
1220
1281
|
const lines = status ? status.split(/\r?\n/) : [];
|
|
1221
1282
|
if (lines.some((line) => !/^\?\? (?:\.hunch\/)?manifest\.json$/.test(line.trim())))
|
|
@@ -1235,7 +1296,8 @@ function unbornBootstrapFingerprint(hunchDir, env) {
|
|
|
1235
1296
|
}
|
|
1236
1297
|
return createHash("sha256").update(status).update("\0").update(manifest).digest("hex");
|
|
1237
1298
|
}
|
|
1238
|
-
catch {
|
|
1299
|
+
catch (error) {
|
|
1300
|
+
notePullGitError(error);
|
|
1239
1301
|
return null;
|
|
1240
1302
|
}
|
|
1241
1303
|
}
|
|
@@ -1261,12 +1323,13 @@ function adoptContractHead(hunchDir, fetchedHead, contract, env, fingerprint) {
|
|
|
1261
1323
|
"-c", "core.autocrlf=false",
|
|
1262
1324
|
"reset", "--hard", fetchedHead,
|
|
1263
1325
|
], {
|
|
1264
|
-
stdio: "ignore", env, timeout: 5_000,
|
|
1326
|
+
stdio: ["ignore", "ignore", "pipe"], env, timeout: 5_000,
|
|
1265
1327
|
});
|
|
1266
1328
|
return overlayGitTreeIsSafe(hunchDir, headShaWithEnv(hunchDir, env), env)
|
|
1267
1329
|
&& overlayAttributeSourcesAreSafe(hunchDir, env);
|
|
1268
1330
|
}
|
|
1269
|
-
catch {
|
|
1331
|
+
catch (error) {
|
|
1332
|
+
notePullGitError(error);
|
|
1270
1333
|
if (moved) {
|
|
1271
1334
|
try {
|
|
1272
1335
|
renameSync(backup, manifest);
|
|
@@ -1283,29 +1346,51 @@ function adoptContractHead(hunchDir, fetchedHead, contract, env, fingerprint) {
|
|
|
1283
1346
|
catch { /* lock cleanup is the final backstop */ }
|
|
1284
1347
|
}
|
|
1285
1348
|
}
|
|
1286
|
-
function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories = false) {
|
|
1349
|
+
function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories = false, diag) {
|
|
1287
1350
|
env = machineCommitEnv(hunchDir, env);
|
|
1288
1351
|
env.GIT_ATTR_NOSYSTEM = "1";
|
|
1352
|
+
const failed = (step, error = lastPullGitError) => {
|
|
1353
|
+
if (diag)
|
|
1354
|
+
diag.detail = pullFailureDetail(step, error);
|
|
1355
|
+
return "failed";
|
|
1356
|
+
};
|
|
1357
|
+
// First failing step of an ordered conjunction; each check starts from a clean
|
|
1358
|
+
// error slot so the noted git error belongs to the step that failed.
|
|
1359
|
+
const firstFailure = (checks) => {
|
|
1360
|
+
for (const [step, ok] of checks) {
|
|
1361
|
+
lastPullGitError = undefined;
|
|
1362
|
+
if (!ok())
|
|
1363
|
+
return step;
|
|
1364
|
+
}
|
|
1365
|
+
return null;
|
|
1366
|
+
};
|
|
1289
1367
|
let overlayRoot = "";
|
|
1290
1368
|
try {
|
|
1291
1369
|
overlayRoot = dirname(realpathSync(hunchDir));
|
|
1292
1370
|
}
|
|
1293
|
-
catch {
|
|
1294
|
-
return
|
|
1295
|
-
}
|
|
1296
|
-
if (!isGitRepoRoot(overlayRoot) || !safeOverlayTree(overlayRoot)
|
|
1297
|
-
|| !overlayAttributeSourcesAreSafe(hunchDir, env) || !contractReady(contract)) {
|
|
1298
|
-
return "failed";
|
|
1371
|
+
catch (error) {
|
|
1372
|
+
return failed("resolve-overlay", error);
|
|
1299
1373
|
}
|
|
1374
|
+
let bad = firstFailure([
|
|
1375
|
+
["overlay-repo-root", () => isGitRepoRoot(overlayRoot)],
|
|
1376
|
+
["overlay-tree-safe", () => safeOverlayTree(overlayRoot)],
|
|
1377
|
+
["overlay-attributes-safe", () => overlayAttributeSourcesAreSafe(hunchDir, env)],
|
|
1378
|
+
["remote-contract", () => contractReady(contract)],
|
|
1379
|
+
]);
|
|
1380
|
+
if (bad)
|
|
1381
|
+
return failed(bad);
|
|
1382
|
+
lastPullGitError = undefined;
|
|
1300
1383
|
const localHead = headShaWithEnv(hunchDir, env);
|
|
1301
1384
|
const unbornFingerprint = localHead ? null : unbornBootstrapFingerprint(hunchDir, env);
|
|
1302
|
-
if (localHead ? !hunchWorktreeClean(hunchDir, env) : !unbornFingerprint)
|
|
1303
|
-
return
|
|
1385
|
+
if (localHead ? !hunchWorktreeClean(hunchDir, env) : !unbornFingerprint) {
|
|
1386
|
+
return failed(localHead ? "worktree-clean" : "unborn-bootstrap");
|
|
1387
|
+
}
|
|
1304
1388
|
const hooksDir = disabledHooksDir(hunchDir);
|
|
1305
1389
|
if (!hooksDir)
|
|
1306
|
-
return
|
|
1390
|
+
return failed("hooks-dir");
|
|
1307
1391
|
const tryGit = (args, timeout = timeoutMs) => {
|
|
1308
1392
|
const startedAt = Date.now();
|
|
1393
|
+
lastPullGitError = undefined;
|
|
1309
1394
|
try {
|
|
1310
1395
|
execFileSync("git", [
|
|
1311
1396
|
"-C", hunchDir,
|
|
@@ -1315,11 +1400,12 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
|
|
|
1315
1400
|
"-c", "commit.gpgsign=false",
|
|
1316
1401
|
...args,
|
|
1317
1402
|
], {
|
|
1318
|
-
stdio: "ignore", env, timeout,
|
|
1403
|
+
stdio: ["ignore", "ignore", "pipe"], env, timeout,
|
|
1319
1404
|
});
|
|
1320
1405
|
return true;
|
|
1321
1406
|
}
|
|
1322
1407
|
catch (error) {
|
|
1408
|
+
notePullGitError(error);
|
|
1323
1409
|
// Same stranding class as the commit path: a timeout-killed merge/fetch
|
|
1324
1410
|
// leaves index.lock behind and wedges every later sync (issue #53).
|
|
1325
1411
|
clearStrandedIndexLock(hunchDir, env, startedAt, error);
|
|
@@ -1328,51 +1414,67 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
|
|
|
1328
1414
|
};
|
|
1329
1415
|
let fetchedHead = "";
|
|
1330
1416
|
if (contract) {
|
|
1417
|
+
lastPullGitError = undefined;
|
|
1331
1418
|
const heads = contractRemoteHeads(hunchDir, contract, "fetch", env, timeoutMs);
|
|
1332
1419
|
if (!heads)
|
|
1333
|
-
return
|
|
1420
|
+
return failed("ls-remote");
|
|
1334
1421
|
if (heads.length === 0)
|
|
1335
1422
|
return "unconfigured";
|
|
1336
1423
|
if (heads.length !== 1 || heads[0].ref !== contract.ref)
|
|
1337
|
-
return
|
|
1424
|
+
return failed("remote-heads", null);
|
|
1425
|
+
lastPullGitError = undefined;
|
|
1338
1426
|
fetchedHead = fetchContractRef(hunchDir, contract, env, timeoutMs);
|
|
1427
|
+
if (!fetchedHead)
|
|
1428
|
+
return failed("fetch");
|
|
1339
1429
|
}
|
|
1340
1430
|
else {
|
|
1341
|
-
if (!tryGit(["rev-parse", "--abbrev-ref", "@{upstream}"],
|
|
1431
|
+
if (!tryGit(["rev-parse", "--abbrev-ref", "@{upstream}"], pullLocalGitTimeoutMs()))
|
|
1342
1432
|
return "unconfigured";
|
|
1343
1433
|
if (!tryGit(["fetch", "--no-tags"]))
|
|
1344
|
-
return
|
|
1434
|
+
return failed("fetch");
|
|
1435
|
+
lastPullGitError = undefined;
|
|
1345
1436
|
fetchedHead = upstreamSha(hunchDir, env);
|
|
1437
|
+
if (!fetchedHead)
|
|
1438
|
+
return failed("upstream-sha");
|
|
1346
1439
|
}
|
|
1347
|
-
|
|
1348
|
-
|
|
1440
|
+
bad = firstFailure([["fetched-tree-safe", () => overlayGitTreeIsSafe(hunchDir, fetchedHead, env)]]);
|
|
1441
|
+
if (bad)
|
|
1442
|
+
return failed(bad);
|
|
1349
1443
|
// Another process need not honor Hunch's lock. Re-prove both the exact local
|
|
1350
1444
|
// revision and the filesystem boundary after the network operation, before
|
|
1351
1445
|
// Git is permitted to materialize the already-validated fetched tree.
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1446
|
+
bad = firstFailure([
|
|
1447
|
+
["local-head-unchanged", () => headShaWithEnv(hunchDir, env) === localHead],
|
|
1448
|
+
["overlay-tree-safe-after-fetch", () => safeOverlayTree(overlayRoot)],
|
|
1449
|
+
["overlay-attributes-safe-after-fetch", () => overlayAttributeSourcesAreSafe(hunchDir, env)],
|
|
1450
|
+
[localHead ? "worktree-clean-after-fetch" : "unborn-bootstrap-after-fetch",
|
|
1451
|
+
() => (localHead ? hunchWorktreeClean(hunchDir, env) : unbornBootstrapFingerprint(hunchDir, env) === unbornFingerprint)],
|
|
1452
|
+
["local-tree-safe", () => !localHead || overlayGitTreeIsSafe(hunchDir, localHead, env)],
|
|
1453
|
+
["remote-contract-after-fetch", () => contractReady(contract)],
|
|
1454
|
+
]);
|
|
1455
|
+
if (bad)
|
|
1456
|
+
return failed(bad);
|
|
1359
1457
|
// Canonicalize local branch/upstream metadata before the worktree mutation.
|
|
1360
1458
|
// If this fails, the exact fetched tree has not been materialized yet.
|
|
1459
|
+
lastPullGitError = undefined;
|
|
1361
1460
|
if (contract && !setContractUpstream(hunchDir, contract, env))
|
|
1362
|
-
return
|
|
1461
|
+
return failed("set-upstream");
|
|
1363
1462
|
if (!localHead && contract && unbornFingerprint) {
|
|
1364
|
-
|
|
1463
|
+
lastPullGitError = undefined;
|
|
1464
|
+
return adoptContractHead(hunchDir, fetchedHead, contract, env, unbornFingerprint) ? "merged" : failed("adopt-remote-head");
|
|
1365
1465
|
}
|
|
1366
1466
|
if (tryGit(["merge", "--no-edit", ...(allowUnrelatedHistories ? ["--allow-unrelated-histories"] : []), fetchedHead])) {
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1467
|
+
bad = firstFailure([
|
|
1468
|
+
["overlay-tree-safe-after-merge", () => safeOverlayTree(overlayRoot)],
|
|
1469
|
+
["overlay-attributes-safe-after-merge", () => overlayAttributeSourcesAreSafe(hunchDir, env)],
|
|
1470
|
+
["remote-contract-after-merge", () => contractReady(contract)],
|
|
1471
|
+
["merged-tree-safe", () => overlayGitTreeIsSafe(hunchDir, headShaWithEnv(hunchDir, env), env)],
|
|
1472
|
+
]);
|
|
1473
|
+
return bad ? failed(bad) : "merged";
|
|
1474
|
+
}
|
|
1475
|
+
const mergeError = lastPullGitError;
|
|
1476
|
+
tryGit(["merge", "--abort"], pullLocalGitTimeoutMs()); // conflict/timeout → restore a clean tree
|
|
1477
|
+
return failed("merge", mergeError);
|
|
1376
1478
|
}
|
|
1377
1479
|
function upstreamSha(hunchDir, env) {
|
|
1378
1480
|
try {
|
|
@@ -1380,10 +1482,11 @@ function upstreamSha(hunchDir, env) {
|
|
|
1380
1482
|
encoding: "utf8",
|
|
1381
1483
|
stdio: ["ignore", "pipe", "ignore"],
|
|
1382
1484
|
env,
|
|
1383
|
-
timeout:
|
|
1485
|
+
timeout: pullLocalGitTimeoutMs(),
|
|
1384
1486
|
}).trim();
|
|
1385
1487
|
}
|
|
1386
|
-
catch {
|
|
1488
|
+
catch (error) {
|
|
1489
|
+
notePullGitError(error);
|
|
1387
1490
|
return "";
|
|
1388
1491
|
}
|
|
1389
1492
|
}
|
|
@@ -1614,6 +1717,11 @@ export function teamPullIsFresh(status) {
|
|
|
1614
1717
|
return status === "updated" || status === "current" || status === "empty";
|
|
1615
1718
|
}
|
|
1616
1719
|
export function pullHunchStatus(hunchDir, opts = {}) {
|
|
1720
|
+
return pullHunchStatusDetailed(hunchDir, opts).status;
|
|
1721
|
+
}
|
|
1722
|
+
/** pullHunchStatus plus a diagnosable reason for a "failed" pull. The remote budget is
|
|
1723
|
+
* the caller's (default 5s) unless HUNCH_PULL_GIT_TIMEOUT_MS overrides it. */
|
|
1724
|
+
export function pullHunchStatusDetailed(hunchDir, opts = {}) {
|
|
1617
1725
|
const env = foreignRepoEnv({
|
|
1618
1726
|
...process.env,
|
|
1619
1727
|
...opts.env,
|
|
@@ -1624,19 +1732,22 @@ export function pullHunchStatus(hunchDir, opts = {}) {
|
|
|
1624
1732
|
const before = headShaWithEnv(hunchDir, env);
|
|
1625
1733
|
const lock = join(hunchDir, ".hunch-commit.lock");
|
|
1626
1734
|
if (acquireCommitLock(lock).state !== "acquired")
|
|
1627
|
-
return "busy";
|
|
1735
|
+
return { status: "busy" };
|
|
1736
|
+
const diag = {};
|
|
1628
1737
|
try {
|
|
1629
|
-
const
|
|
1738
|
+
const timeoutMs = pullRemoteTimeoutMs(opts.timeoutMs);
|
|
1739
|
+
const merged = mergeRemote(hunchDir, env, timeoutMs, opts.remote, opts.allowUnrelatedHistories ?? false, diag);
|
|
1630
1740
|
if (merged === "failed" || merged === "unconfigured" || !contractReady(opts.remote)) {
|
|
1631
|
-
if (merged !== "unconfigured" || !contractReady(opts.remote))
|
|
1632
|
-
return "failed";
|
|
1741
|
+
if (merged !== "unconfigured" || !contractReady(opts.remote)) {
|
|
1742
|
+
return { status: "failed", detail: merged === "failed" ? diag.detail ?? { step: "merge-remote" } : { step: "remote-contract-after-pull" } };
|
|
1743
|
+
}
|
|
1633
1744
|
// The advertised remote has no branch yet AND this clone never committed memory:
|
|
1634
1745
|
// both sides are empty, so there is nothing to be stale against (#427). A clone
|
|
1635
1746
|
// WITH memory against an empty remote stays "unconfigured" (unpublished, or the
|
|
1636
1747
|
// remote lost its history) so strict guards keep failing closed on it.
|
|
1637
|
-
return opts.remote && !before && !headShaWithEnv(hunchDir, env) ? "empty" : merged;
|
|
1748
|
+
return { status: opts.remote && !before && !headShaWithEnv(hunchDir, env) ? "empty" : merged };
|
|
1638
1749
|
}
|
|
1639
|
-
return headShaWithEnv(hunchDir, env) !== before ? "updated" : "current";
|
|
1750
|
+
return { status: headShaWithEnv(hunchDir, env) !== before ? "updated" : "current" };
|
|
1640
1751
|
}
|
|
1641
1752
|
finally {
|
|
1642
1753
|
try {
|
|
@@ -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 };
|
|
@@ -12,7 +12,7 @@ import { writeFileAtomic } from "../core/io.js";
|
|
|
12
12
|
import { machineFile } from "../core/machine.js";
|
|
13
13
|
import { hunchTreeAttributesAreSafe, safeOverlayGitTreeListing, safeOverlayTree } from "../core/overlaySafety.js";
|
|
14
14
|
import { hunchPaths, hunchPathsForDir } from "../core/paths.js";
|
|
15
|
-
import { canonicalRemoteUrl, checkoutCommonDir, gitNullDevice, mainWorktreeRoot, sameFilesystemEntry, sameRemoteUrl } from "../extractors/git.js";
|
|
15
|
+
import { canonicalRemoteUrl, checkoutCommonDir, gitNullDevice, mainWorktreeRoot, pullRemoteTimeoutMs, sameFilesystemEntry, sameRemoteUrl } from "../extractors/git.js";
|
|
16
16
|
import { HunchStore } from "../store/hunchStore.js";
|
|
17
17
|
import { JsonStore } from "../store/jsonStore.js";
|
|
18
18
|
import { ensureSharedOverlayPointer } from "./worktree.js";
|
|
@@ -854,7 +854,7 @@ export function ensureTeamOverlay(root) {
|
|
|
854
854
|
if (!existsSync(dest)) {
|
|
855
855
|
const cloned = cloneValidatedTeamOverlay(team.shared_repo, root, dest, {
|
|
856
856
|
sharedRef: team.shared_ref,
|
|
857
|
-
timeoutMs:
|
|
857
|
+
timeoutMs: pullRemoteTimeoutMs(),
|
|
858
858
|
});
|
|
859
859
|
if (!cloned || !overlayMatchesTeamRemote(root, dest)) {
|
|
860
860
|
if (cloned)
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/** The opt-out marker, at the repository root. Commit it so every clone stays opted out. */
|
|
2
2
|
export declare const OPT_OUT_FILE = ".hunch-removed";
|
|
3
3
|
export declare function isOptedOut(root: string): boolean;
|
|
4
|
+
/** Whether `start`, or the repository containing it, opted out: walks up through
|
|
5
|
+
* `start`'s ancestors (which need not exist yet), stopping after the first one holding
|
|
6
|
+
* `.git` or at the filesystem root, so a path inside an opted-out repository (`serve
|
|
7
|
+
* init --root ./partitions/x`) is gated like the repository itself (#458). A `.hunch`
|
|
8
|
+
* store is deliberately not a boundary: a partition store nested in an opted-out
|
|
9
|
+
* repository stays gated (fail closed). */
|
|
10
|
+
export declare function optedOutAt(start: string): boolean;
|
|
4
11
|
export declare function optOutMessage(): string;
|
|
5
12
|
export interface UninstallStep {
|
|
6
13
|
/** Repo-relative (POSIX) where possible, else absolute. */
|
|
@@ -17,6 +24,8 @@ export interface UninstallPlan {
|
|
|
17
24
|
path: string;
|
|
18
25
|
reason: string;
|
|
19
26
|
}>;
|
|
27
|
+
/** Consequences beyond this checkout (other worktrees, other branches); printed, never refused over. */
|
|
28
|
+
warnings?: string[];
|
|
20
29
|
}
|
|
21
30
|
/** Read-only: everything `hunch uninstall` would do in `root`. */
|
|
22
31
|
export declare function planUninstall(root: string, opts?: {
|