@davesheffer/hunch 1.45.1 → 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.
@@ -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: 2_000,
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 READ_REMOTE_TIMEOUT_MS = 5_000;
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: 2_000,
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: 2_000,
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: 2_000,
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", "ignore"],
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: 2_000,
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: 2_000,
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: 2_000,
1261
+ stdio: "ignore", env, timeout: pullLocalGitTimeoutMs(),
1202
1262
  });
1203
- execFileSync("git", ["-C", hunchDir, "config", `branch.${branch}.remote`, "origin"], { stdio: "ignore", env, timeout: 2_000 });
1204
- execFileSync("git", ["-C", hunchDir, "config", `branch.${branch}.merge`, contract.ref], { stdio: "ignore", env, timeout: 2_000 });
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: 2_000,
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 "failed";
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 "failed";
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 "failed";
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 "failed";
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 "failed";
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}"], 2_000))
1431
+ if (!tryGit(["rev-parse", "--abbrev-ref", "@{upstream}"], pullLocalGitTimeoutMs()))
1342
1432
  return "unconfigured";
1343
1433
  if (!tryGit(["fetch", "--no-tags"]))
1344
- return "failed";
1434
+ return failed("fetch");
1435
+ lastPullGitError = undefined;
1345
1436
  fetchedHead = upstreamSha(hunchDir, env);
1437
+ if (!fetchedHead)
1438
+ return failed("upstream-sha");
1346
1439
  }
1347
- if (!fetchedHead || !overlayGitTreeIsSafe(hunchDir, fetchedHead, env))
1348
- return "failed";
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
- if (headShaWithEnv(hunchDir, env) !== localHead
1353
- || !safeOverlayTree(overlayRoot)
1354
- || !overlayAttributeSourcesAreSafe(hunchDir, env)
1355
- || (localHead ? !hunchWorktreeClean(hunchDir, env) : unbornBootstrapFingerprint(hunchDir, env) !== unbornFingerprint)
1356
- || (localHead && !overlayGitTreeIsSafe(hunchDir, localHead, env))
1357
- || !contractReady(contract))
1358
- return "failed";
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 "failed";
1461
+ return failed("set-upstream");
1363
1462
  if (!localHead && contract && unbornFingerprint) {
1364
- return adoptContractHead(hunchDir, fetchedHead, contract, env, unbornFingerprint) ? "merged" : "failed";
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
- return safeOverlayTree(overlayRoot)
1368
- && overlayAttributeSourcesAreSafe(hunchDir, env)
1369
- && contractReady(contract)
1370
- && overlayGitTreeIsSafe(hunchDir, headShaWithEnv(hunchDir, env), env)
1371
- ? "merged"
1372
- : "failed";
1373
- }
1374
- tryGit(["merge", "--abort"], 2_000); // conflict/timeout → restore a clean tree
1375
- return "failed";
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: 2_000,
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 merged = mergeRemote(hunchDir, env, opts.timeoutMs ?? READ_REMOTE_TIMEOUT_MS, opts.remote, opts.allowUnrelatedHistories ?? false);
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 {
@@ -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: 5_000,
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. */
@@ -14,7 +14,7 @@
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, resolve } from "node:path";
17
+ import { basename, dirname, join, relative, resolve } from "node:path";
18
18
  import { parse as parseToml } from "smol-toml";
19
19
  import { writeFileAtomic } from "../core/io.js";
20
20
  import { currentBranch, gitCommonDir, isGitRepo, isLinkedWorktree, separateGitDirLink } from "../extractors/git.js";
@@ -36,6 +36,24 @@ export function isOptedOut(root) {
36
36
  return false;
37
37
  }
38
38
  }
39
+ /** Whether `start`, or the repository containing it, opted out: walks up through
40
+ * `start`'s ancestors (which need not exist yet), stopping after the first one holding
41
+ * `.git` or at the filesystem root, so a path inside an opted-out repository (`serve
42
+ * init --root ./partitions/x`) is gated like the repository itself (#458). A `.hunch`
43
+ * store is deliberately not a boundary: a partition store nested in an opted-out
44
+ * repository stays gated (fail closed). */
45
+ export function optedOutAt(start) {
46
+ for (let dir = resolve(start);;) {
47
+ if (isOptedOut(dir))
48
+ return true;
49
+ if (existsSync(join(dir, ".git")))
50
+ return false;
51
+ const parent = dirname(dir);
52
+ if (parent === dir)
53
+ return false;
54
+ dir = parent;
55
+ }
56
+ }
39
57
  export function optOutMessage() {
40
58
  return `Hunch was removed from this repository on purpose (${OPT_OUT_FILE}), so it will not set itself up here again. To reinstall, delete ${OPT_OUT_FILE} and run \`hunch init\`.`;
41
59
  }