@davesheffer/hunch 1.45.1 → 1.46.0
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 +165 -33
- package/dist/cli/invocation.d.ts +14 -0
- package/dist/cli/invocation.js +23 -0
- package/dist/cli/serve.d.ts +2 -0
- package/dist/cli/serve.js +2 -1
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/experiment.d.ts +1 -1
- package/dist/core/agenthook.d.ts +1 -0
- package/dist/core/agenthook.js +82 -26
- package/dist/core/boundedSpawn.d.ts +57 -0
- package/dist/core/boundedSpawn.js +321 -0
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/delivery.d.ts +10 -0
- package/dist/core/delivery.js +117 -9
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- 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/projectDna.js +11 -1
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/taskSelection.d.ts +1 -0
- package/dist/core/taskSelection.js +132 -11
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- 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 +206 -94
- package/dist/extractors/indexer.js +42 -6
- package/dist/extractors/languages.d.ts +9 -0
- package/dist/extractors/languages.js +28 -0
- package/dist/extractors/parse.d.ts +20 -0
- package/dist/extractors/parse.js +81 -5
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +4 -2
- package/dist/integrations/hooks.js +12 -2
- package/dist/integrations/mergeDriver.js +5 -3
- package/dist/integrations/optout.d.ts +3 -0
- package/dist/integrations/optout.js +17 -0
- package/dist/integrations/providers.d.ts +6 -2
- package/dist/integrations/providers.js +19 -2
- package/dist/integrations/team.js +6 -4
- package/dist/integrations/uninstall.d.ts +17 -4
- package/dist/integrations/uninstall.js +35 -13
- package/dist/mcp/server.js +179 -30
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +6 -5
- package/dist/serve/writelock.js +26 -21
- package/dist/store/hunchStore.d.ts +12 -1
- package/dist/store/hunchStore.js +86 -4
- package/dist/store/jsonStore.d.ts +24 -4
- package/dist/store/jsonStore.js +75 -26
- package/dist/store/stateBinding.js +17 -4
- package/dist/synthesis/provider.js +26 -78
- package/dist/synthesis/synthesize.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/extractors/git.js
CHANGED
|
@@ -7,6 +7,7 @@ import { isAbsolute, resolve, join, basename, dirname, relative, sep, posix } fr
|
|
|
7
7
|
import { mkdtempSync, openSync, closeSync, readSync, mkdirSync, rmSync, statSync, lstatSync, realpathSync, readFileSync, renameSync, readdirSync, existsSync } from "node:fs";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { MEMLOG_FORMAT } from "../core/memorylog.js";
|
|
10
|
+
import { boundedExecFileSync } from "../core/boundedSpawn.js";
|
|
10
11
|
import { writeFileAtomic } from "../core/io.js";
|
|
11
12
|
import { hunchAttributesAreSafe, hunchTreeAttributesAreSafe, safeOverlayTree } from "../core/overlaySafety.js";
|
|
12
13
|
import { createRepoFileReader } from "../core/safeRepoFile.js";
|
|
@@ -154,10 +155,11 @@ function headShaWithEnv(cwd, env) {
|
|
|
154
155
|
function gitSafeWithEnv(args, cwd, env) {
|
|
155
156
|
try {
|
|
156
157
|
return execFileSync("git", ["-C", cwd, ...args], {
|
|
157
|
-
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout:
|
|
158
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: pullLocalGitTimeoutMs(),
|
|
158
159
|
}).trim();
|
|
159
160
|
}
|
|
160
|
-
catch {
|
|
161
|
+
catch (error) {
|
|
162
|
+
notePullGitError(error);
|
|
161
163
|
return "";
|
|
162
164
|
}
|
|
163
165
|
}
|
|
@@ -223,6 +225,60 @@ function clearStrandedIndexLock(repoDir, env, sinceMs, error) {
|
|
|
223
225
|
return false; // no lock present, or git itself unavailable — nothing to heal
|
|
224
226
|
}
|
|
225
227
|
}
|
|
228
|
+
/** Why the last pull-path git call failed (issue #482). The bounded helpers swallow
|
|
229
|
+
* their errors into booleans; they also note the error here so mergeRemote can attach
|
|
230
|
+
* it to the failing step. Synchronous code only, so a module slot is race-free. */
|
|
231
|
+
let lastPullGitError;
|
|
232
|
+
function notePullGitError(error) { lastPullGitError = error; }
|
|
233
|
+
/** Captured git stderr can echo a remote URL with embedded credentials; strip the
|
|
234
|
+
* userinfo (`://user:pass@`, `://token@`) before it is stored or printed. */
|
|
235
|
+
export function redactUrlUserinfo(text) {
|
|
236
|
+
return text.replace(/:\/\/[^\s/@'"]+@/g, "://***@");
|
|
237
|
+
}
|
|
238
|
+
function pullFailureDetail(step, error) {
|
|
239
|
+
const detail = { step };
|
|
240
|
+
if (!error || typeof error !== "object")
|
|
241
|
+
return detail;
|
|
242
|
+
const err = error;
|
|
243
|
+
if (typeof err.code === "string" && err.code)
|
|
244
|
+
detail.code = err.code;
|
|
245
|
+
else if (typeof err.signal === "string" && err.signal)
|
|
246
|
+
detail.code = err.signal;
|
|
247
|
+
else if (typeof err.status === "number")
|
|
248
|
+
detail.code = `exit ${err.status}`;
|
|
249
|
+
const stderr = (typeof err.stderr === "string" ? err.stderr : Buffer.isBuffer(err.stderr) ? err.stderr.toString("utf8") : "").trim();
|
|
250
|
+
if (stderr)
|
|
251
|
+
detail.stderr = redactUrlUserinfo(stderr).slice(-400);
|
|
252
|
+
return detail;
|
|
253
|
+
}
|
|
254
|
+
/** One-line rendering for logs and refusal messages: `step: fetch, ETIMEDOUT, fatal: ...`. */
|
|
255
|
+
export function describePullFailure(detail) {
|
|
256
|
+
if (!detail)
|
|
257
|
+
return "";
|
|
258
|
+
const firstLine = detail.stderr?.split(/\r?\n/).find((line) => line.trim())?.trim();
|
|
259
|
+
return [`step: ${detail.step}`, detail.code, firstLine].filter(Boolean).join(", ");
|
|
260
|
+
}
|
|
261
|
+
/** HUNCH_PULL_GIT_TIMEOUT_MS: per-process override of the read-side pull's DEFAULT
|
|
262
|
+
* remote budget (ls-remote/fetch/merge) for slow runners — a caller's explicit
|
|
263
|
+
* timeoutMs still wins; ignored unless a positive integer. */
|
|
264
|
+
export function pullGitTimeoutOverride() {
|
|
265
|
+
const raw = process.env.HUNCH_PULL_GIT_TIMEOUT_MS?.trim();
|
|
266
|
+
if (!raw || !/^\d+$/.test(raw))
|
|
267
|
+
return undefined;
|
|
268
|
+
const value = Number(raw);
|
|
269
|
+
return value > 0 ? Math.min(600_000, value) : undefined;
|
|
270
|
+
}
|
|
271
|
+
/** Remote budget for one read-side pull: an explicit caller budget always wins;
|
|
272
|
+
* HUNCH_PULL_GIT_TIMEOUT_MS only replaces the 5s default. */
|
|
273
|
+
export function pullRemoteTimeoutMs(callerMs) {
|
|
274
|
+
return callerMs ?? pullGitTimeoutOverride() ?? DEFAULT_READ_REMOTE_TIMEOUT_MS;
|
|
275
|
+
}
|
|
276
|
+
/** Budget for the bounded local git helpers (status, rev-parse, ls-tree): 2s by
|
|
277
|
+
* default, scaled with HUNCH_PULL_GIT_TIMEOUT_MS to max(2s, override / 2.5). */
|
|
278
|
+
export function pullLocalGitTimeoutMs() {
|
|
279
|
+
const override = pullGitTimeoutOverride();
|
|
280
|
+
return override === undefined ? 2_000 : Math.max(2_000, Math.round(override / 2.5));
|
|
281
|
+
}
|
|
226
282
|
/** Compare physical directory identity before path text. Git for Windows can
|
|
227
283
|
* return an 8.3/short or differently-cased spelling for the same top-level
|
|
228
284
|
* directory that Node reached through its long path. A nonzero file ID keeps
|
|
@@ -525,7 +581,9 @@ function observeGit(observer, event) {
|
|
|
525
581
|
catch { /* observation cannot change a Git outcome */ }
|
|
526
582
|
}
|
|
527
583
|
const CAPTURE_REMOTE_TIMEOUT_MS = 15_000;
|
|
528
|
-
|
|
584
|
+
/** A user's whole branch can be large, so `hunch push` gets far longer than a memory capture push. */
|
|
585
|
+
const BRANCH_PUSH_TIMEOUT_MS = 300_000;
|
|
586
|
+
export const DEFAULT_READ_REMOTE_TIMEOUT_MS = 5_000;
|
|
529
587
|
// A capture can spend roughly 90s in commit + bounded merge/push/retry seams.
|
|
530
588
|
// A contending writer waits beyond that proven ceiling, then takes the lock and
|
|
531
589
|
// drains every already-durable JSON write itself. This removes the old "maybe a
|
|
@@ -950,10 +1008,11 @@ function hunchWorktreeClean(hunchDir, env) {
|
|
|
950
1008
|
encoding: "utf8",
|
|
951
1009
|
stdio: ["ignore", "pipe", "ignore"],
|
|
952
1010
|
env,
|
|
953
|
-
timeout:
|
|
1011
|
+
timeout: pullLocalGitTimeoutMs(),
|
|
954
1012
|
}).trim() === "";
|
|
955
1013
|
}
|
|
956
|
-
catch {
|
|
1014
|
+
catch (error) {
|
|
1015
|
+
notePullGitError(error);
|
|
957
1016
|
return false;
|
|
958
1017
|
}
|
|
959
1018
|
}
|
|
@@ -965,7 +1024,7 @@ function overlayGitTreeIsSafe(hunchDir, revision, env) {
|
|
|
965
1024
|
encoding: "utf8",
|
|
966
1025
|
stdio: ["ignore", "pipe", "ignore"],
|
|
967
1026
|
env,
|
|
968
|
-
timeout:
|
|
1027
|
+
timeout: pullLocalGitTimeoutMs(),
|
|
969
1028
|
maxBuffer: 64 * 1024 * 1024,
|
|
970
1029
|
});
|
|
971
1030
|
return hunchTreeAttributesAreSafe(listing, (oid) => {
|
|
@@ -974,16 +1033,18 @@ function overlayGitTreeIsSafe(hunchDir, revision, env) {
|
|
|
974
1033
|
encoding: "utf8",
|
|
975
1034
|
stdio: ["ignore", "pipe", "ignore"],
|
|
976
1035
|
env,
|
|
977
|
-
timeout:
|
|
1036
|
+
timeout: pullLocalGitTimeoutMs(),
|
|
978
1037
|
maxBuffer: 4 * 1024 * 1024,
|
|
979
1038
|
});
|
|
980
1039
|
}
|
|
981
|
-
catch {
|
|
1040
|
+
catch (error) {
|
|
1041
|
+
notePullGitError(error);
|
|
982
1042
|
return null;
|
|
983
1043
|
}
|
|
984
1044
|
});
|
|
985
1045
|
}
|
|
986
|
-
catch {
|
|
1046
|
+
catch (error) {
|
|
1047
|
+
notePullGitError(error);
|
|
987
1048
|
return false;
|
|
988
1049
|
}
|
|
989
1050
|
}
|
|
@@ -1135,16 +1196,13 @@ function contractRemoteHeads(hunchDir, contract, direction, env, timeoutMs) {
|
|
|
1135
1196
|
if (!hooksDir)
|
|
1136
1197
|
return null;
|
|
1137
1198
|
try {
|
|
1138
|
-
|
|
1199
|
+
// Network git runs under boundedExecFileSync: execFileSync's timeout kills only
|
|
1200
|
+
// the root, orphaning git-remote-https.exe on Windows (issue #498).
|
|
1201
|
+
const out = boundedExecFileSync("git", [
|
|
1139
1202
|
"-C", hunchDir,
|
|
1140
1203
|
"-c", `core.hooksPath=${hooksDir}`,
|
|
1141
1204
|
"ls-remote", "--refs", "--heads", "--upload-pack=git-upload-pack", url,
|
|
1142
|
-
], {
|
|
1143
|
-
encoding: "utf8",
|
|
1144
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
1145
|
-
env: boundedTeamEnv(env),
|
|
1146
|
-
timeout: timeoutMs,
|
|
1147
|
-
});
|
|
1205
|
+
], { env: boundedTeamEnv(env), timeout: timeoutMs });
|
|
1148
1206
|
if (!contractReady(contract))
|
|
1149
1207
|
return null;
|
|
1150
1208
|
const heads = [];
|
|
@@ -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
|
}
|
|
@@ -1167,27 +1226,28 @@ function fetchContractRef(hunchDir, contract, env, timeoutMs) {
|
|
|
1167
1226
|
if (!hooksDir)
|
|
1168
1227
|
return "";
|
|
1169
1228
|
try {
|
|
1170
|
-
|
|
1229
|
+
boundedExecFileSync("git", [
|
|
1171
1230
|
"-C", hunchDir,
|
|
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
|
-
], {
|
|
1234
|
+
], { stdout: "ignore", 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,43 +1346,70 @@ 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;
|
|
1394
|
+
const argv = [
|
|
1395
|
+
"-C", hunchDir,
|
|
1396
|
+
"-c", `core.hooksPath=${hooksDir}`,
|
|
1397
|
+
"-c", `core.attributesFile=${gitNullDevice()}`,
|
|
1398
|
+
"-c", "core.autocrlf=false",
|
|
1399
|
+
"-c", "commit.gpgsign=false",
|
|
1400
|
+
...args,
|
|
1401
|
+
];
|
|
1309
1402
|
try {
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
"
|
|
1314
|
-
|
|
1315
|
-
"
|
|
1316
|
-
...args,
|
|
1317
|
-
], {
|
|
1318
|
-
stdio: "ignore", env, timeout,
|
|
1319
|
-
});
|
|
1403
|
+
// Only the network call pays for the tree-killing supervisor (#498); the
|
|
1404
|
+
// merge of an already-fetched sha and rev-parse/abort stay local.
|
|
1405
|
+
if (args[0] === "fetch")
|
|
1406
|
+
boundedExecFileSync("git", argv, { stdout: "ignore", env, timeout });
|
|
1407
|
+
else
|
|
1408
|
+
execFileSync("git", argv, { stdio: ["ignore", "ignore", "pipe"], env, timeout });
|
|
1320
1409
|
return true;
|
|
1321
1410
|
}
|
|
1322
1411
|
catch (error) {
|
|
1412
|
+
notePullGitError(error);
|
|
1323
1413
|
// Same stranding class as the commit path: a timeout-killed merge/fetch
|
|
1324
1414
|
// leaves index.lock behind and wedges every later sync (issue #53).
|
|
1325
1415
|
clearStrandedIndexLock(hunchDir, env, startedAt, error);
|
|
@@ -1328,51 +1418,67 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
|
|
|
1328
1418
|
};
|
|
1329
1419
|
let fetchedHead = "";
|
|
1330
1420
|
if (contract) {
|
|
1421
|
+
lastPullGitError = undefined;
|
|
1331
1422
|
const heads = contractRemoteHeads(hunchDir, contract, "fetch", env, timeoutMs);
|
|
1332
1423
|
if (!heads)
|
|
1333
|
-
return
|
|
1424
|
+
return failed("ls-remote");
|
|
1334
1425
|
if (heads.length === 0)
|
|
1335
1426
|
return "unconfigured";
|
|
1336
1427
|
if (heads.length !== 1 || heads[0].ref !== contract.ref)
|
|
1337
|
-
return
|
|
1428
|
+
return failed("remote-heads", null);
|
|
1429
|
+
lastPullGitError = undefined;
|
|
1338
1430
|
fetchedHead = fetchContractRef(hunchDir, contract, env, timeoutMs);
|
|
1431
|
+
if (!fetchedHead)
|
|
1432
|
+
return failed("fetch");
|
|
1339
1433
|
}
|
|
1340
1434
|
else {
|
|
1341
|
-
if (!tryGit(["rev-parse", "--abbrev-ref", "@{upstream}"],
|
|
1435
|
+
if (!tryGit(["rev-parse", "--abbrev-ref", "@{upstream}"], pullLocalGitTimeoutMs()))
|
|
1342
1436
|
return "unconfigured";
|
|
1343
1437
|
if (!tryGit(["fetch", "--no-tags"]))
|
|
1344
|
-
return
|
|
1438
|
+
return failed("fetch");
|
|
1439
|
+
lastPullGitError = undefined;
|
|
1345
1440
|
fetchedHead = upstreamSha(hunchDir, env);
|
|
1441
|
+
if (!fetchedHead)
|
|
1442
|
+
return failed("upstream-sha");
|
|
1346
1443
|
}
|
|
1347
|
-
|
|
1348
|
-
|
|
1444
|
+
bad = firstFailure([["fetched-tree-safe", () => overlayGitTreeIsSafe(hunchDir, fetchedHead, env)]]);
|
|
1445
|
+
if (bad)
|
|
1446
|
+
return failed(bad);
|
|
1349
1447
|
// Another process need not honor Hunch's lock. Re-prove both the exact local
|
|
1350
1448
|
// revision and the filesystem boundary after the network operation, before
|
|
1351
1449
|
// Git is permitted to materialize the already-validated fetched tree.
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1450
|
+
bad = firstFailure([
|
|
1451
|
+
["local-head-unchanged", () => headShaWithEnv(hunchDir, env) === localHead],
|
|
1452
|
+
["overlay-tree-safe-after-fetch", () => safeOverlayTree(overlayRoot)],
|
|
1453
|
+
["overlay-attributes-safe-after-fetch", () => overlayAttributeSourcesAreSafe(hunchDir, env)],
|
|
1454
|
+
[localHead ? "worktree-clean-after-fetch" : "unborn-bootstrap-after-fetch",
|
|
1455
|
+
() => (localHead ? hunchWorktreeClean(hunchDir, env) : unbornBootstrapFingerprint(hunchDir, env) === unbornFingerprint)],
|
|
1456
|
+
["local-tree-safe", () => !localHead || overlayGitTreeIsSafe(hunchDir, localHead, env)],
|
|
1457
|
+
["remote-contract-after-fetch", () => contractReady(contract)],
|
|
1458
|
+
]);
|
|
1459
|
+
if (bad)
|
|
1460
|
+
return failed(bad);
|
|
1359
1461
|
// Canonicalize local branch/upstream metadata before the worktree mutation.
|
|
1360
1462
|
// If this fails, the exact fetched tree has not been materialized yet.
|
|
1463
|
+
lastPullGitError = undefined;
|
|
1361
1464
|
if (contract && !setContractUpstream(hunchDir, contract, env))
|
|
1362
|
-
return
|
|
1465
|
+
return failed("set-upstream");
|
|
1363
1466
|
if (!localHead && contract && unbornFingerprint) {
|
|
1364
|
-
|
|
1467
|
+
lastPullGitError = undefined;
|
|
1468
|
+
return adoptContractHead(hunchDir, fetchedHead, contract, env, unbornFingerprint) ? "merged" : failed("adopt-remote-head");
|
|
1365
1469
|
}
|
|
1366
1470
|
if (tryGit(["merge", "--no-edit", ...(allowUnrelatedHistories ? ["--allow-unrelated-histories"] : []), fetchedHead])) {
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1471
|
+
bad = firstFailure([
|
|
1472
|
+
["overlay-tree-safe-after-merge", () => safeOverlayTree(overlayRoot)],
|
|
1473
|
+
["overlay-attributes-safe-after-merge", () => overlayAttributeSourcesAreSafe(hunchDir, env)],
|
|
1474
|
+
["remote-contract-after-merge", () => contractReady(contract)],
|
|
1475
|
+
["merged-tree-safe", () => overlayGitTreeIsSafe(hunchDir, headShaWithEnv(hunchDir, env), env)],
|
|
1476
|
+
]);
|
|
1477
|
+
return bad ? failed(bad) : "merged";
|
|
1478
|
+
}
|
|
1479
|
+
const mergeError = lastPullGitError;
|
|
1480
|
+
tryGit(["merge", "--abort"], pullLocalGitTimeoutMs()); // conflict/timeout → restore a clean tree
|
|
1481
|
+
return failed("merge", mergeError);
|
|
1376
1482
|
}
|
|
1377
1483
|
function upstreamSha(hunchDir, env) {
|
|
1378
1484
|
try {
|
|
@@ -1380,10 +1486,11 @@ function upstreamSha(hunchDir, env) {
|
|
|
1380
1486
|
encoding: "utf8",
|
|
1381
1487
|
stdio: ["ignore", "pipe", "ignore"],
|
|
1382
1488
|
env,
|
|
1383
|
-
timeout:
|
|
1489
|
+
timeout: pullLocalGitTimeoutMs(),
|
|
1384
1490
|
}).trim();
|
|
1385
1491
|
}
|
|
1386
|
-
catch {
|
|
1492
|
+
catch (error) {
|
|
1493
|
+
notePullGitError(error);
|
|
1387
1494
|
return "";
|
|
1388
1495
|
}
|
|
1389
1496
|
}
|
|
@@ -1393,7 +1500,7 @@ function upstreamSha(hunchDir, env) {
|
|
|
1393
1500
|
* and discard oversized/unavailable output without changing its exit result. */
|
|
1394
1501
|
function runObservedPush(args, env, timeoutMs, observe) {
|
|
1395
1502
|
if (!observe) {
|
|
1396
|
-
|
|
1503
|
+
boundedExecFileSync("git", args, { stdout: "ignore", env, timeout: timeoutMs });
|
|
1397
1504
|
return null;
|
|
1398
1505
|
}
|
|
1399
1506
|
let directory, descriptor;
|
|
@@ -1408,11 +1515,11 @@ function runObservedPush(args, env, timeoutMs, observe) {
|
|
|
1408
1515
|
}
|
|
1409
1516
|
catch { }
|
|
1410
1517
|
}
|
|
1411
|
-
|
|
1518
|
+
boundedExecFileSync("git", args, { stdout: "ignore", env, timeout: timeoutMs });
|
|
1412
1519
|
return null;
|
|
1413
1520
|
}
|
|
1414
1521
|
try {
|
|
1415
|
-
|
|
1522
|
+
boundedExecFileSync("git", args, { stdout: descriptor, env, timeout: timeoutMs });
|
|
1416
1523
|
try {
|
|
1417
1524
|
const buffer = Buffer.alloc(1_000_001);
|
|
1418
1525
|
const bytes = readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
@@ -1460,8 +1567,8 @@ function observePushResult(hunchDir, env, output, observer) {
|
|
|
1460
1567
|
if (!destination || /[\x00-\x1f\x7f]/.test(destination) || Date.now() >= deadline)
|
|
1461
1568
|
continue;
|
|
1462
1569
|
try {
|
|
1463
|
-
const result =
|
|
1464
|
-
env,
|
|
1570
|
+
const result = boundedExecFileSync("git", ["-C", hunchDir, "ls-remote", "--refs", "--exit-code", "--", destination, ref], {
|
|
1571
|
+
env, timeout: Math.max(1, deadline - Date.now()), maxBuffer: 16_384,
|
|
1465
1572
|
}).trim();
|
|
1466
1573
|
const rows = result.split("\n").filter(row => row.split("\t")[1] === ref);
|
|
1467
1574
|
const commitSha = rows.length === 1 ? rows[0].split("\t")[0] : "";
|
|
@@ -1553,12 +1660,7 @@ function establishEmptyRemoteUpstream(hunchDir, env, protectedRepoRoot, timeoutM
|
|
|
1553
1660
|
if (!before)
|
|
1554
1661
|
return false;
|
|
1555
1662
|
try {
|
|
1556
|
-
const refs =
|
|
1557
|
-
encoding: "utf8",
|
|
1558
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
1559
|
-
env,
|
|
1560
|
-
timeout: timeoutMs,
|
|
1561
|
-
}).trim();
|
|
1663
|
+
const refs = boundedExecFileSync("git", ["-C", hunchDir, "ls-remote", "--refs", remote], { env, timeout: timeoutMs }).trim();
|
|
1562
1664
|
if (refs)
|
|
1563
1665
|
return false;
|
|
1564
1666
|
}
|
|
@@ -1614,6 +1716,11 @@ export function teamPullIsFresh(status) {
|
|
|
1614
1716
|
return status === "updated" || status === "current" || status === "empty";
|
|
1615
1717
|
}
|
|
1616
1718
|
export function pullHunchStatus(hunchDir, opts = {}) {
|
|
1719
|
+
return pullHunchStatusDetailed(hunchDir, opts).status;
|
|
1720
|
+
}
|
|
1721
|
+
/** pullHunchStatus plus a diagnosable reason for a "failed" pull. The remote budget is
|
|
1722
|
+
* the caller's (default 5s) unless HUNCH_PULL_GIT_TIMEOUT_MS overrides it. */
|
|
1723
|
+
export function pullHunchStatusDetailed(hunchDir, opts = {}) {
|
|
1617
1724
|
const env = foreignRepoEnv({
|
|
1618
1725
|
...process.env,
|
|
1619
1726
|
...opts.env,
|
|
@@ -1624,19 +1731,22 @@ export function pullHunchStatus(hunchDir, opts = {}) {
|
|
|
1624
1731
|
const before = headShaWithEnv(hunchDir, env);
|
|
1625
1732
|
const lock = join(hunchDir, ".hunch-commit.lock");
|
|
1626
1733
|
if (acquireCommitLock(lock).state !== "acquired")
|
|
1627
|
-
return "busy";
|
|
1734
|
+
return { status: "busy" };
|
|
1735
|
+
const diag = {};
|
|
1628
1736
|
try {
|
|
1629
|
-
const
|
|
1737
|
+
const timeoutMs = pullRemoteTimeoutMs(opts.timeoutMs);
|
|
1738
|
+
const merged = mergeRemote(hunchDir, env, timeoutMs, opts.remote, opts.allowUnrelatedHistories ?? false, diag);
|
|
1630
1739
|
if (merged === "failed" || merged === "unconfigured" || !contractReady(opts.remote)) {
|
|
1631
|
-
if (merged !== "unconfigured" || !contractReady(opts.remote))
|
|
1632
|
-
return "failed";
|
|
1740
|
+
if (merged !== "unconfigured" || !contractReady(opts.remote)) {
|
|
1741
|
+
return { status: "failed", detail: merged === "failed" ? diag.detail ?? { step: "merge-remote" } : { step: "remote-contract-after-pull" } };
|
|
1742
|
+
}
|
|
1633
1743
|
// The advertised remote has no branch yet AND this clone never committed memory:
|
|
1634
1744
|
// both sides are empty, so there is nothing to be stale against (#427). A clone
|
|
1635
1745
|
// WITH memory against an empty remote stays "unconfigured" (unpublished, or the
|
|
1636
1746
|
// remote lost its history) so strict guards keep failing closed on it.
|
|
1637
|
-
return opts.remote && !before && !headShaWithEnv(hunchDir, env) ? "empty" : merged;
|
|
1747
|
+
return { status: opts.remote && !before && !headShaWithEnv(hunchDir, env) ? "empty" : merged };
|
|
1638
1748
|
}
|
|
1639
|
-
return headShaWithEnv(hunchDir, env) !== before ? "updated" : "current";
|
|
1749
|
+
return { status: headShaWithEnv(hunchDir, env) !== before ? "updated" : "current" };
|
|
1640
1750
|
}
|
|
1641
1751
|
finally {
|
|
1642
1752
|
try {
|
|
@@ -2221,7 +2331,9 @@ export function memoryMoveDiff(sha, root) {
|
|
|
2221
2331
|
* false when there is no upstream / offline / not a repo. */
|
|
2222
2332
|
export function pushCurrentBranch(root) {
|
|
2223
2333
|
try {
|
|
2224
|
-
|
|
2334
|
+
// Bounded so an unreachable remote can't hang the caller, and a timeout
|
|
2335
|
+
// kills git's whole tree, not just the root (#498).
|
|
2336
|
+
boundedExecFileSync("git", ["-C", root, "push"], { stdout: "ignore", timeout: BRANCH_PUSH_TIMEOUT_MS });
|
|
2225
2337
|
return true;
|
|
2226
2338
|
}
|
|
2227
2339
|
catch {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { readFileSync } from "node:fs";
|
|
12
12
|
import { dirname, join, posix } from "node:path";
|
|
13
|
-
import { parseSource, attributeCalls, attributeRelations, MAX_BODY_TEXT_CHARS } from "./parse.js";
|
|
13
|
+
import { parseSource, attributeCalls, attributeQualifiedCalls, attributeRelations, MAX_BODY_TEXT_CHARS } from "./parse.js";
|
|
14
14
|
import { isParserLoadError } from "./nativeTreeSitter.js";
|
|
15
15
|
import { extractHelmDirectives } from "./helm.js";
|
|
16
16
|
import { extractK8sManifest, extractKustomization, isKustomizationFile, kustomizeNamespaces, namespacesCompatible } from "./k8sManifest.js";
|
|
@@ -332,7 +332,7 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
332
332
|
}
|
|
333
333
|
fileSymbols.set(rel, idsInFile);
|
|
334
334
|
fileSymbolIndexId.set(rel, symbolIndexId);
|
|
335
|
-
perFileCalls.push({ file: rel, bySym: attributeCalls(parsed) });
|
|
335
|
+
perFileCalls.push({ file: rel, bySym: attributeCalls(parsed), qualified: attributeQualifiedCalls(parsed) });
|
|
336
336
|
perFileImports.push({ file: rel, imports: parsed.imports });
|
|
337
337
|
perFileRelations.push({ file: rel, bySym: attributeRelations(parsed) });
|
|
338
338
|
if (languageFor(rel)?.id === "php") {
|
|
@@ -359,7 +359,7 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
359
359
|
if (langId === "php") {
|
|
360
360
|
return resolvePhpImportTargets(file, spec, phpNamespaces.get(file) ?? null, phpUseDeclarations.get(file) ?? [], phpPsr4, trackedFiles);
|
|
361
361
|
}
|
|
362
|
-
return [resolveImport(file, spec,
|
|
362
|
+
return [resolveImport(file, spec, trackedFiles)].filter((target) => !!target);
|
|
363
363
|
};
|
|
364
364
|
const importedFiles = new Map(perFileImports.map(({ file, imports }) => {
|
|
365
365
|
const targets = new Set(imports.flatMap((specifier) => resolveImportTargets(file, specifier)));
|
|
@@ -381,7 +381,7 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
381
381
|
edgeSeen.add(e.id);
|
|
382
382
|
edges.push(e);
|
|
383
383
|
};
|
|
384
|
-
for (const { file, bySym } of perFileCalls) {
|
|
384
|
+
for (const { file, bySym, qualified } of perFileCalls) {
|
|
385
385
|
// Most languages leave this unset and get "calls" — YAML's alias->anchor
|
|
386
386
|
// references aren't function calls, so its LanguageSpec declares "references".
|
|
387
387
|
const edgeType = languageFor(file)?.referenceEdgeType ?? "calls";
|
|
@@ -423,6 +423,42 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
423
423
|
});
|
|
424
424
|
}
|
|
425
425
|
}
|
|
426
|
+
// Calls pinned to one imported module (`ns.fn()`, aliased `g()`): the member
|
|
427
|
+
// gate above drops them, so resolve the name ONLY inside that module's files —
|
|
428
|
+
// a unique match there is the binding; zero or several -> no edge.
|
|
429
|
+
// A module binding exposes top-level exports, so `U.helper()` prefers a function
|
|
430
|
+
// `helper` over a same-named class method `Foo.helper`; a sole method match still
|
|
431
|
+
// binds, since `module.exports = { helper() {} }` parses as a method too.
|
|
432
|
+
const moduleFiles = new Map(); // resolve each module once per file
|
|
433
|
+
for (const [callerIndex, targets] of qualified) {
|
|
434
|
+
const callerId = indexToId.get(callerIndex);
|
|
435
|
+
if (!callerId)
|
|
436
|
+
continue;
|
|
437
|
+
const callerName = byId.get(callerId)?.name ?? "?";
|
|
438
|
+
for (const { callee, module } of targets) {
|
|
439
|
+
let files = moduleFiles.get(module);
|
|
440
|
+
if (!files)
|
|
441
|
+
moduleFiles.set(module, files = new Set(resolveImportTargets(file, module)));
|
|
442
|
+
const inModule = (nameIndex.get(callee) ?? []).filter((id) => {
|
|
443
|
+
const sym = byId.get(id);
|
|
444
|
+
return !!sym && files.has(sym.file);
|
|
445
|
+
});
|
|
446
|
+
const nonMethods = inModule.filter((id) => byId.get(id).kind !== "method");
|
|
447
|
+
const matches = nonMethods.length > 0 ? nonMethods : inModule;
|
|
448
|
+
if (matches.length !== 1 || matches[0] === callerId)
|
|
449
|
+
continue;
|
|
450
|
+
const calleeId = matches[0];
|
|
451
|
+
addEdge({
|
|
452
|
+
schema: "hunch.edge/1",
|
|
453
|
+
id: edgeId(callerId, calleeId, edgeType),
|
|
454
|
+
from: callerId, to: calleeId, type: edgeType,
|
|
455
|
+
reason: `${callerName} ${edgeType} ${callee}`, strength: 0.8,
|
|
456
|
+
provenance: extracted(0.8, [file]),
|
|
457
|
+
environment: null,
|
|
458
|
+
metadata: {},
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
426
462
|
}
|
|
427
463
|
// ---- K8s manifest cross-resource references (Phase 1: name-keyed) --------
|
|
428
464
|
// Own resolver, not resolveName(): resolveName() indexes by bare symbol name
|
|
@@ -747,8 +783,8 @@ function resolveName(name, file, importedFiles, nameIndex, byId) {
|
|
|
747
783
|
return imported.length === 1 ? imported[0] : null;
|
|
748
784
|
}
|
|
749
785
|
/** Resolve a relative import specifier to a concrete tracked file path. */
|
|
750
|
-
function resolveImport(fromFile, spec,
|
|
751
|
-
return resolveRelativeImport(fromFile, spec,
|
|
786
|
+
function resolveImport(fromFile, spec, trackedFiles) {
|
|
787
|
+
return resolveRelativeImport(fromFile, spec, trackedFiles).path;
|
|
752
788
|
}
|
|
753
789
|
/** First of `${modulePath}.py` / `${modulePath}/__init__.py` that's a tracked file,
|
|
754
790
|
* or null — the shared "module file vs. package __init__" candidate check used by
|
|
@@ -17,6 +17,15 @@ export interface LanguageSpec {
|
|
|
17
17
|
defKindOf: Record<string, ParsedSymbolKind>;
|
|
18
18
|
/** Query capture-name (ending in ".name") -> the matching ".def" capture-name. */
|
|
19
19
|
nameToDef: Record<string, string>;
|
|
20
|
+
/** Optional query binding a local name to an imported module, run with
|
|
21
|
+
* matches() so captures pair up: @bind.src (module specifier), @bind.local
|
|
22
|
+
* (local name), @bind.name (an imported member, `from X import name`). Feeds
|
|
23
|
+
* qualified calls (`ns.fn()`), resolved only inside the bound module's files. */
|
|
24
|
+
bindingQuery?: string;
|
|
25
|
+
/** Local name an import binds when @bind.local is absent (Go `"a/geo"` -> geo). */
|
|
26
|
+
defaultBindingLocal?(src: string): string | null;
|
|
27
|
+
/** Module path of `from <src> import <name>` when name is itself a submodule. */
|
|
28
|
+
joinSubmodule?(src: string, name: string): string;
|
|
20
29
|
/** Optional semantic relationship captures. The generic parser/indexer emit
|
|
21
30
|
* these through the normal graph path; languages only describe syntax. */
|
|
22
31
|
relationKindOf?: Record<string, {
|