@davesheffer/hunch 1.8.2 → 1.9.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/README.md +96 -1
- package/dist/cli/index.js +1238 -396
- package/dist/constitution/adapters.js +31 -14
- package/dist/constitution/behaviorEvaluator.js +20 -7
- package/dist/constitution/behaviorProof.js +3 -2
- package/dist/constitution/canonical.js +7 -1
- package/dist/constitution/card.js +7 -2
- package/dist/constitution/compiler.js +71 -1
- package/dist/constitution/correctionPolicyMaterializer.js +496 -0
- package/dist/constitution/delta.js +3 -2
- package/dist/constitution/evaluator.js +29 -3
- package/dist/constitution/experiment.js +96 -5
- package/dist/constitution/experimentRunner.js +43 -14
- package/dist/constitution/g2BehaviorCandidates.js +49 -26
- package/dist/constitution/g2BehaviorDependencies.js +203 -14
- package/dist/constitution/g2Candidates.js +1 -1
- package/dist/constitution/lifecycle.js +17 -0
- package/dist/constitution/plan.js +26 -9
- package/dist/constitution/replacementFreeGit.js +67 -0
- package/dist/constitution/replay.js +6 -0
- package/dist/constitution/replayCache.js +1 -1
- package/dist/constitution/replayWorker.js +1 -1
- package/dist/constitution/repository.js +141 -5
- package/dist/constitution/safeCheckout.js +75 -0
- package/dist/constitution/schema.js +30 -5
- package/dist/constitution/service.js +74 -14
- package/dist/constitution/sourceMutation.js +65 -12
- package/dist/constitution/staticGraphBaseline.js +44 -0
- package/dist/constitution/structural.js +60 -4
- package/dist/core/autoreview.js +1 -1
- package/dist/core/canonicalOrder.js +6 -0
- package/dist/core/conformance.js +68 -27
- package/dist/core/docscan.js +2 -1
- package/dist/core/escalations.js +11 -0
- package/dist/core/io.js +44 -9
- package/dist/core/overlaySafety.js +178 -0
- package/dist/core/paths.js +13 -2
- package/dist/core/safeRepoFile.js +74 -0
- package/dist/extractors/comments.js +6 -8
- package/dist/extractors/git.js +1631 -82
- package/dist/extractors/indexer.js +86 -47
- package/dist/extractors/repoSource.js +390 -0
- package/dist/integrations/ciAction.js +10 -2
- package/dist/integrations/gitignore.js +44 -5
- package/dist/integrations/mergeDriver.js +23 -5
- package/dist/integrations/sync.js +61 -5
- package/dist/integrations/team.js +666 -23
- package/dist/mcp/server.js +261 -34
- package/dist/store/db.js +57 -7
- package/dist/store/hunchStore.js +92 -11
- package/dist/store/jsonStore.js +350 -63
- package/dist/store/schema.js +27 -11
- package/dist/synthesis/provider.js +13 -4
- package/dist/synthesis/synthesize.js +56 -19
- package/dist/wiki/graph.js +5 -4
- package/dist/wiki/wiki.js +16 -10
- package/package.json +15 -3
- package/tooling/competitive-watch.mjs +108 -0
- package/tooling/md1-benchmark.mjs +628 -0
package/dist/extractors/git.js
CHANGED
|
@@ -1,9 +1,59 @@
|
|
|
1
1
|
/** Deterministic git introspection for the extractor + learning loop.
|
|
2
2
|
* No LLM here — just parsing what git already knows. */
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { devNull } from "node:os";
|
|
6
|
+
import { isAbsolute, resolve, join, basename, dirname, relative, sep } from "node:path";
|
|
7
|
+
import { mkdirSync, rmSync, statSync, lstatSync, realpathSync, readFileSync, renameSync, readdirSync } from "node:fs";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
6
9
|
import { MEMLOG_FORMAT } from "../core/memorylog.js";
|
|
10
|
+
import { hunchAttributesAreSafe, hunchTreeAttributesAreSafe, safeOverlayTree } from "../core/overlaySafety.js";
|
|
11
|
+
import { createRepoFileReader } from "../core/safeRepoFile.js";
|
|
12
|
+
// `git` exports these repository-local variables to hooks. They outrank cwd/-C,
|
|
13
|
+
// so carrying them from the code repository into a command for the memory
|
|
14
|
+
// overlay can target the wrong index/object store. This is the documented set
|
|
15
|
+
// from `git rev-parse --local-env-vars`; keep explicit global credentials and
|
|
16
|
+
// transport settings, but always clear repository identity before selecting cwd.
|
|
17
|
+
const LOCAL_GIT_ENV_VARS = [
|
|
18
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT",
|
|
19
|
+
"GIT_OBJECT_DIRECTORY", "GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_GRAFT_FILE",
|
|
20
|
+
"GIT_INDEX_FILE", "GIT_NO_REPLACE_OBJECTS", "GIT_REPLACE_REF_BASE", "GIT_PREFIX",
|
|
21
|
+
"GIT_INTERNAL_SUPER_PREFIX", "GIT_SHALLOW_FILE", "GIT_COMMON_DIR",
|
|
22
|
+
];
|
|
23
|
+
export function foreignRepoEnv(source) {
|
|
24
|
+
const env = { ...source };
|
|
25
|
+
for (const key of LOCAL_GIT_ENV_VARS)
|
|
26
|
+
delete env[key];
|
|
27
|
+
for (const key of Object.keys(env)) {
|
|
28
|
+
if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(key))
|
|
29
|
+
delete env[key];
|
|
30
|
+
}
|
|
31
|
+
return env;
|
|
32
|
+
}
|
|
33
|
+
function machineCommitEnv(repoRoot, source) {
|
|
34
|
+
const env = foreignRepoEnv(source);
|
|
35
|
+
const configured = (key) => {
|
|
36
|
+
try {
|
|
37
|
+
return execFileSync("git", ["-C", repoRoot, "config", "--get", key], {
|
|
38
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
|
|
39
|
+
}).trim();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const name = configured("user.name");
|
|
46
|
+
const email = configured("user.email");
|
|
47
|
+
if (!env.GIT_AUTHOR_NAME && !name)
|
|
48
|
+
env.GIT_AUTHOR_NAME = "Hunch Memory";
|
|
49
|
+
if (!env.GIT_COMMITTER_NAME && !name)
|
|
50
|
+
env.GIT_COMMITTER_NAME = "Hunch Memory";
|
|
51
|
+
if (!env.GIT_AUTHOR_EMAIL && !email)
|
|
52
|
+
env.GIT_AUTHOR_EMAIL = "hunch-memory@localhost";
|
|
53
|
+
if (!env.GIT_COMMITTER_EMAIL && !email)
|
|
54
|
+
env.GIT_COMMITTER_EMAIL = "hunch-memory@localhost";
|
|
55
|
+
return env;
|
|
56
|
+
}
|
|
7
57
|
function git(args, cwd, maxBuffer = 64 * 1024 * 1024) {
|
|
8
58
|
// stdio: capture stdout, silence stderr (so "no commits yet" etc. don't leak).
|
|
9
59
|
return execFileSync("git", args, {
|
|
@@ -19,6 +69,23 @@ function gitSafe(args, cwd, maxBuffer) {
|
|
|
19
69
|
return "";
|
|
20
70
|
}
|
|
21
71
|
}
|
|
72
|
+
/** Object-identity reads must not inherit clone-local replacement refs/grafts.
|
|
73
|
+
* Most Git helpers intentionally preserve ordinary repository behavior; use
|
|
74
|
+
* this narrower path only where a cross-clone canonical identity is minted. */
|
|
75
|
+
function gitSafeWithoutReplacements(args, cwd, maxBuffer = 64 * 1024 * 1024) {
|
|
76
|
+
try {
|
|
77
|
+
return execFileSync("git", args, {
|
|
78
|
+
cwd,
|
|
79
|
+
encoding: "utf8",
|
|
80
|
+
env: { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" },
|
|
81
|
+
maxBuffer,
|
|
82
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
83
|
+
}).trim();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
22
89
|
function gitRawSafe(args, cwd, maxBuffer = 64 * 1024 * 1024) {
|
|
23
90
|
try {
|
|
24
91
|
return execFileSync("git", args, {
|
|
@@ -30,9 +97,323 @@ function gitRawSafe(args, cwd, maxBuffer = 64 * 1024 * 1024) {
|
|
|
30
97
|
return null;
|
|
31
98
|
}
|
|
32
99
|
}
|
|
100
|
+
function gitSafeIsolated(args, cwd, maxBuffer = 64 * 1024 * 1024) {
|
|
101
|
+
try {
|
|
102
|
+
return execFileSync("git", args, {
|
|
103
|
+
cwd, encoding: "utf8", maxBuffer,
|
|
104
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
105
|
+
env: foreignRepoEnv(process.env),
|
|
106
|
+
}).trim();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return "";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function gitRawSafeIsolated(args, cwd, maxBuffer = 64 * 1024 * 1024) {
|
|
113
|
+
try {
|
|
114
|
+
return execFileSync("git", args, {
|
|
115
|
+
cwd, encoding: "utf8", maxBuffer,
|
|
116
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
117
|
+
env: foreignRepoEnv(process.env),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function headShaWithEnv(cwd, env) {
|
|
125
|
+
return gitSafeWithEnv(["rev-parse", "HEAD"], cwd, env);
|
|
126
|
+
}
|
|
127
|
+
/** Git query for a repository other than the invocation repository. The
|
|
128
|
+
* caller supplies an environment with code-repo GIT_DIR/GIT_INDEX_FILE state
|
|
129
|
+
* removed, so hooks cannot redirect an overlay query back into the code repo. */
|
|
130
|
+
function gitSafeWithEnv(args, cwd, env) {
|
|
131
|
+
try {
|
|
132
|
+
return execFileSync("git", ["-C", cwd, ...args], {
|
|
133
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
|
|
134
|
+
}).trim();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return "";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
33
140
|
export function isGitRepo(cwd) {
|
|
34
141
|
return gitSafe(["rev-parse", "--is-inside-work-tree"], cwd) === "true";
|
|
35
142
|
}
|
|
143
|
+
/** Canonical worktree root, or null when Git cannot positively identify one.
|
|
144
|
+
* Callers enforcing a privacy boundary must distinguish "different repo" from
|
|
145
|
+
* "malformed/unknown Git state" instead of treating both as safe. */
|
|
146
|
+
export function gitWorktreeRoot(cwd) {
|
|
147
|
+
const top = gitSafeIsolated(["rev-parse", "--show-toplevel"], cwd);
|
|
148
|
+
return top ? canonicalPath(top) : null;
|
|
149
|
+
}
|
|
150
|
+
/** True only when `cwd` is the repository's actual worktree root. Unlike
|
|
151
|
+
* `isGitRepo`, this does not accept an ancestor repository discovered by Git's
|
|
152
|
+
* upward walk. Private overlays use this stronger boundary so a nested
|
|
153
|
+
* `.hunch-private/.hunch` can never stage or commit into the code repository. */
|
|
154
|
+
export function isGitRepoRoot(cwd) {
|
|
155
|
+
const raw = gitSafeIsolated(["rev-parse", "--show-toplevel"], cwd);
|
|
156
|
+
return !!raw && sameFilesystemEntry(raw, cwd);
|
|
157
|
+
}
|
|
158
|
+
function canonicalPath(path) {
|
|
159
|
+
try {
|
|
160
|
+
return realpathSync(path);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return resolve(path);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export function gitNullDevice() {
|
|
167
|
+
return process.platform === "win32" ? "NUL" : devNull;
|
|
168
|
+
}
|
|
169
|
+
/** Compare physical directory identity before path text. Git for Windows can
|
|
170
|
+
* return an 8.3/short or differently-cased spelling for the same top-level
|
|
171
|
+
* directory that Node reached through its long path. A nonzero file ID keeps
|
|
172
|
+
* this exact even on case-sensitive Windows directories; canonical text is a
|
|
173
|
+
* conservative fallback for filesystems that do not expose stable IDs. */
|
|
174
|
+
function sameFilesystemEntry(left, right) {
|
|
175
|
+
try {
|
|
176
|
+
const leftStat = statSync(left, { bigint: true });
|
|
177
|
+
const rightStat = statSync(right, { bigint: true });
|
|
178
|
+
if (leftStat.ino !== 0n && rightStat.ino !== 0n) {
|
|
179
|
+
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch { /* fall back to canonical path text */ }
|
|
183
|
+
return canonicalPath(left) === canonicalPath(right);
|
|
184
|
+
}
|
|
185
|
+
/** Whether two paths resolve to the same repository identity. Comparing only
|
|
186
|
+
* worktree roots is insufficient: linked worktrees have different roots but
|
|
187
|
+
* share one Git common directory and therefore one publishable history. */
|
|
188
|
+
export function sameGitRepository(left, right) {
|
|
189
|
+
if (sameFilesystemEntry(left, right))
|
|
190
|
+
return true;
|
|
191
|
+
const common = (cwd) => {
|
|
192
|
+
const value = gitSafeIsolated(["rev-parse", "--git-common-dir"], cwd);
|
|
193
|
+
return value ? (isAbsolute(value) ? value : resolve(cwd, value)) : "";
|
|
194
|
+
};
|
|
195
|
+
const leftCommon = common(left);
|
|
196
|
+
const rightCommon = common(right);
|
|
197
|
+
return !!leftCommon && !!rightCommon && sameFilesystemEntry(leftCommon, rightCommon);
|
|
198
|
+
}
|
|
199
|
+
function remoteIdentity(raw, cwd, purpose = "route") {
|
|
200
|
+
const value = raw.trim();
|
|
201
|
+
if (!value)
|
|
202
|
+
return "";
|
|
203
|
+
const trimRepoSuffix = (path) => path.replace(/[\\/]+$/, "").replace(/\.git$/i, "");
|
|
204
|
+
// Local repositories are filesystem identities, not provider aliases. Both
|
|
205
|
+
// `/srv/memory` and `/srv/memory.git` may exist and publish unrelated graphs;
|
|
206
|
+
// collapsing the conventional suffix is safe only for known network hosts.
|
|
207
|
+
const normalizeLocalPath = (path) => canonicalPath(path).replace(/[\\/]+$/, "");
|
|
208
|
+
const normalizeNetworkPath = (host, path) => {
|
|
209
|
+
const normalized = trimRepoSuffix(path).replace(/^\/+/, "");
|
|
210
|
+
return host === "github.com" || host === "www.github.com" ? normalized.toLowerCase() : normalized;
|
|
211
|
+
};
|
|
212
|
+
const azureDevOpsIdentity = (host, path) => {
|
|
213
|
+
const segments = trimRepoSuffix(path).replace(/^\/+/, "").split("/");
|
|
214
|
+
let coordinates = null;
|
|
215
|
+
if (host === "dev.azure.com" && segments.length === 4 && segments[2].toLowerCase() === "_git") {
|
|
216
|
+
coordinates = [segments[0], segments[1], segments[3]];
|
|
217
|
+
}
|
|
218
|
+
else if ((host === "ssh.dev.azure.com" || host === "vs-ssh.visualstudio.com")
|
|
219
|
+
&& segments.length === 4 && segments[0].toLowerCase() === "v3") {
|
|
220
|
+
coordinates = [segments[1], segments[2], segments[3]];
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
const legacyHost = host.match(/^([^.]+)\.visualstudio\.com$/);
|
|
224
|
+
if (legacyHost && segments.length === 3 && segments[1].toLowerCase() === "_git") {
|
|
225
|
+
coordinates = [legacyHost[1], segments[0], segments[2]];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!coordinates?.every(Boolean))
|
|
229
|
+
return null;
|
|
230
|
+
return `provider:azure-devops:${coordinates.map((part) => part.toLowerCase()).join("/")}`;
|
|
231
|
+
};
|
|
232
|
+
const networkIdentity = (host, path, pathCaseHost = host, username = "", transport = "ssh") => {
|
|
233
|
+
const provider = azureDevOpsIdentity(host, path);
|
|
234
|
+
if (provider)
|
|
235
|
+
return provider;
|
|
236
|
+
if (host === "github.com" || host === "www.github.com") {
|
|
237
|
+
return `provider:github:${normalizeNetworkPath("github.com", path)}`;
|
|
238
|
+
}
|
|
239
|
+
// Route proof is deliberately exact for an unknown host: transport and SSH
|
|
240
|
+
// account can select different namespaces. Preserve username bytes/case.
|
|
241
|
+
// Publication proof is deliberately conservative in the other direction:
|
|
242
|
+
// https://host/org/repo and ssh://user@host/org/repo may publish the same
|
|
243
|
+
// history, so an overlay must not evade the code-remote boundary by changing
|
|
244
|
+
// transport spelling.
|
|
245
|
+
const account = purpose === "route" && username ? `${username}@` : "";
|
|
246
|
+
const route = ["ssh", "git+ssh", "ssh+git"].includes(transport) ? "ssh" : transport;
|
|
247
|
+
const normalizedPath = normalizeNetworkPath(pathCaseHost, path);
|
|
248
|
+
return `net:${purpose === "route" ? route : "any"}://${account}${host}/${purpose === "publication" ? normalizedPath.toLowerCase() : normalizedPath}`;
|
|
249
|
+
};
|
|
250
|
+
try {
|
|
251
|
+
if (value.startsWith("file://"))
|
|
252
|
+
return `file:${normalizeLocalPath(fileURLToPath(value))}`;
|
|
253
|
+
}
|
|
254
|
+
catch { /* fall through to the literal URL form */ }
|
|
255
|
+
if (isAbsolute(value) || value.startsWith("./") || value.startsWith("../")) {
|
|
256
|
+
return `file:${normalizeLocalPath(resolve(cwd, value))}`;
|
|
257
|
+
}
|
|
258
|
+
const scp = value.match(/^(?:([^@/]+)@)?([^:/]+):(.+)$/);
|
|
259
|
+
if (scp && !value.includes("://")) {
|
|
260
|
+
const rawHost = scp[2].toLowerCase().replace(/\.$/, "");
|
|
261
|
+
const host = rawHost === "www.github.com" ? "github.com" : rawHost;
|
|
262
|
+
return networkIdentity(host, scp[3], host, scp[1] ?? "", "ssh");
|
|
263
|
+
}
|
|
264
|
+
if (!value.includes("://"))
|
|
265
|
+
return `file:${normalizeLocalPath(resolve(cwd, value))}`;
|
|
266
|
+
try {
|
|
267
|
+
const url = new URL(value);
|
|
268
|
+
const rawHostname = url.hostname.toLowerCase().replace(/\.$/, "");
|
|
269
|
+
const protocol = url.protocol.toLowerCase();
|
|
270
|
+
const githubSsh443 = rawHostname === "ssh.github.com" && ["ssh:", "git+ssh:", "ssh+git:"].includes(protocol) && url.port === "443";
|
|
271
|
+
const hostname = rawHostname === "www.github.com" || githubSsh443 ? "github.com" : rawHostname;
|
|
272
|
+
const defaults = { "ssh:": "22", "git+ssh:": "22", "ssh+git:": "22", "https:": "443", "http:": "80", "git:": "9418" };
|
|
273
|
+
const port = !githubSsh443 && url.port && url.port !== defaults[protocol] ? `:${url.port}` : "";
|
|
274
|
+
const host = `${hostname}${port}`;
|
|
275
|
+
return networkIdentity(host, decodeURIComponent(url.pathname), hostname, decodeURIComponent(url.username), protocol.replace(/:$/, ""));
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return `literal:${trimRepoSuffix(value)}`;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function localRemotePath(raw, cwd) {
|
|
282
|
+
const value = raw.trim();
|
|
283
|
+
if (!value)
|
|
284
|
+
return null;
|
|
285
|
+
try {
|
|
286
|
+
if (value.startsWith("file://"))
|
|
287
|
+
return canonicalPath(fileURLToPath(value));
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
if (isAbsolute(value) || value.startsWith("./") || value.startsWith("../")) {
|
|
293
|
+
return canonicalPath(resolve(cwd, value));
|
|
294
|
+
}
|
|
295
|
+
const scp = value.match(/^(?:[^@/]+@)?[^:/]+:.+$/);
|
|
296
|
+
if (!value.includes("://") && !scp)
|
|
297
|
+
return canonicalPath(resolve(cwd, value));
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
function repositoriesShareCommitObjects(left, right) {
|
|
301
|
+
const leftHead = gitSafeIsolated(["rev-parse", "--verify", "HEAD^{commit}"], left);
|
|
302
|
+
const rightHead = gitSafeIsolated(["rev-parse", "--verify", "HEAD^{commit}"], right);
|
|
303
|
+
if (!leftHead || !rightHead)
|
|
304
|
+
return false;
|
|
305
|
+
if (gitRawSafeIsolated(["cat-file", "-e", `${leftHead}^{commit}`], right) !== null
|
|
306
|
+
|| gitRawSafeIsolated(["cat-file", "-e", `${rightHead}^{commit}`], left) !== null)
|
|
307
|
+
return true;
|
|
308
|
+
// Comparing only the tips misses a forked contamination: the code repo and
|
|
309
|
+
// an overlay can each add a unique commit after sharing an older code
|
|
310
|
+
// history. Pushing the overlay tip would still publish every reachable code
|
|
311
|
+
// ancestor. Full repositories that share ancestry share at least one root;
|
|
312
|
+
// for shallow repositories, also ask whether either visible boundary/root is
|
|
313
|
+
// present in the other object database.
|
|
314
|
+
const roots = (cwd) => gitSafeIsolated(["rev-list", "--max-parents=0", "HEAD"], cwd)
|
|
315
|
+
.split(/\r?\n/)
|
|
316
|
+
.filter((oid) => /^[0-9a-f]{40,64}$/i.test(oid));
|
|
317
|
+
const leftRoots = roots(left);
|
|
318
|
+
const rightRoots = roots(right);
|
|
319
|
+
if (!leftRoots.length || !rightRoots.length)
|
|
320
|
+
return false;
|
|
321
|
+
const rightRootSet = new Set(rightRoots);
|
|
322
|
+
if (leftRoots.some((oid) => rightRootSet.has(oid)))
|
|
323
|
+
return true;
|
|
324
|
+
return leftRoots.some((oid) => gitRawSafeIsolated(["cat-file", "-e", `${oid}^{commit}`], right) !== null)
|
|
325
|
+
|| rightRoots.some((oid) => gitRawSafeIsolated(["cat-file", "-e", `${oid}^{commit}`], left) !== null);
|
|
326
|
+
}
|
|
327
|
+
function localRemoteTargets(cwd) {
|
|
328
|
+
const out = gitRawSafeIsolated(["remote", "-v"], cwd) ?? "";
|
|
329
|
+
const targets = new Set();
|
|
330
|
+
for (const line of out.split("\n")) {
|
|
331
|
+
const match = line.match(/^[^\t]+\t(.+) \((?:fetch|push)\)$/);
|
|
332
|
+
if (!match)
|
|
333
|
+
continue;
|
|
334
|
+
const target = localRemotePath(match[1], cwd);
|
|
335
|
+
if (target)
|
|
336
|
+
targets.add(target);
|
|
337
|
+
}
|
|
338
|
+
return [...targets];
|
|
339
|
+
}
|
|
340
|
+
function repositoryTargetMatches(target, repoRoot) {
|
|
341
|
+
return sameGitRepository(target, repoRoot) || repositoriesShareCommitObjects(target, repoRoot);
|
|
342
|
+
}
|
|
343
|
+
/** Resolve a user-supplied Git remote once, before handing it to commands that
|
|
344
|
+
* run from different working directories. Git otherwise gives a relative local
|
|
345
|
+
* URL a different meaning under `git clone` and `git -C <overlay> remote add`,
|
|
346
|
+
* which can turn a successful preflight into a later privacy-boundary escape. */
|
|
347
|
+
export function canonicalRemoteUrl(raw, cwd) {
|
|
348
|
+
const value = raw.trim();
|
|
349
|
+
if (!value)
|
|
350
|
+
return "";
|
|
351
|
+
try {
|
|
352
|
+
if (value.startsWith("file://"))
|
|
353
|
+
return canonicalPath(fileURLToPath(value));
|
|
354
|
+
}
|
|
355
|
+
catch { /* let Git report an invalid URL without weakening the boundary */ }
|
|
356
|
+
if (isAbsolute(value))
|
|
357
|
+
return canonicalPath(value);
|
|
358
|
+
const scp = value.match(/^(?:[^@/]+@)?[^:/]+:.+$/);
|
|
359
|
+
if (!value.includes("://") && !scp)
|
|
360
|
+
return canonicalPath(resolve(cwd, value));
|
|
361
|
+
return value;
|
|
362
|
+
}
|
|
363
|
+
/** Compare two remote spellings in the contexts where Git would interpret
|
|
364
|
+
* them. This is identity comparison, not brittle string equality. */
|
|
365
|
+
export function sameRemoteUrl(left, leftCwd, right, rightCwd) {
|
|
366
|
+
const leftIdentity = remoteIdentity(left, leftCwd, "route");
|
|
367
|
+
return !!leftIdentity && leftIdentity === remoteIdentity(right, rightCwd, "route");
|
|
368
|
+
}
|
|
369
|
+
function gitRemoteIdentities(cwd, direction = "any") {
|
|
370
|
+
const out = gitRawSafeIsolated(["remote", "-v"], cwd) ?? "";
|
|
371
|
+
const identities = new Set();
|
|
372
|
+
for (const line of out.split("\n")) {
|
|
373
|
+
const match = line.match(/^[^\t]+\t(.+) \((fetch|push)\)$/);
|
|
374
|
+
if (!match)
|
|
375
|
+
continue;
|
|
376
|
+
if (direction !== "any" && match[2] !== direction)
|
|
377
|
+
continue;
|
|
378
|
+
const identity = remoteIdentity(match[1], cwd, "publication");
|
|
379
|
+
if (identity)
|
|
380
|
+
identities.add(identity);
|
|
381
|
+
}
|
|
382
|
+
return identities;
|
|
383
|
+
}
|
|
384
|
+
/** True when two worktrees can publish to the same local Git history OR name
|
|
385
|
+
* the same configured remote repository. Separate clones of one remote are a
|
|
386
|
+
* single publication boundary even though their local common dirs differ. */
|
|
387
|
+
export function sameGitPublication(left, right) {
|
|
388
|
+
if (sameGitRepository(left, right))
|
|
389
|
+
return true;
|
|
390
|
+
// A clone of the code repository with its origin removed still carries the
|
|
391
|
+
// same commit objects. Treat shared history as one publication boundary; a
|
|
392
|
+
// memory overlay must start from an independent graph history.
|
|
393
|
+
if (repositoriesShareCommitObjects(left, right))
|
|
394
|
+
return true;
|
|
395
|
+
for (const target of localRemoteTargets(left))
|
|
396
|
+
if (repositoryTargetMatches(target, right))
|
|
397
|
+
return true;
|
|
398
|
+
for (const target of localRemoteTargets(right))
|
|
399
|
+
if (repositoryTargetMatches(target, left))
|
|
400
|
+
return true;
|
|
401
|
+
const leftRemotes = gitRemoteIdentities(left);
|
|
402
|
+
if (!leftRemotes.size)
|
|
403
|
+
return false;
|
|
404
|
+
for (const remote of gitRemoteIdentities(right))
|
|
405
|
+
if (leftRemotes.has(remote))
|
|
406
|
+
return true;
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
/** Preflight a requested overlay URL before clone/attach can mutate a remote. */
|
|
410
|
+
export function repositoryUsesRemote(repoRoot, remoteUrl, remoteCwd = repoRoot) {
|
|
411
|
+
const localTarget = localRemotePath(remoteUrl, remoteCwd);
|
|
412
|
+
if (localTarget && repositoryTargetMatches(localTarget, repoRoot))
|
|
413
|
+
return true;
|
|
414
|
+
const requested = remoteIdentity(remoteUrl, remoteCwd, "publication");
|
|
415
|
+
return !!requested && gitRemoteIdentities(repoRoot).has(requested);
|
|
416
|
+
}
|
|
36
417
|
/** The MAIN worktree's root — the stable anchor for an overlay store. A linked worktree
|
|
37
418
|
* can be `git worktree remove`d, so anything anchored inside it (an overlay clone, an
|
|
38
419
|
* absolute pointer target) silently dies for every OTHER worktree; the main checkout
|
|
@@ -45,42 +426,131 @@ export function mainWorktreeRoot(root) {
|
|
|
45
426
|
const abs = resolve(root, common);
|
|
46
427
|
return basename(abs) === ".git" ? dirname(abs) : root;
|
|
47
428
|
}
|
|
48
|
-
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
429
|
+
/** Stable, privacy-safe repository label for artifacts that must be reusable
|
|
430
|
+
* across linked worktrees and ordinary clones. Prefer the canonical fetch
|
|
431
|
+
* remote identity: its SHA-256 digest exposes neither a private URL nor a local
|
|
432
|
+
* path, ignores mutable remote aliases, and does not depend on clone depth. If
|
|
433
|
+
* several fetch remotes exist, canonical identity ordering makes the choice
|
|
434
|
+
* deterministic without privileging a name such as `origin`.
|
|
435
|
+
*
|
|
436
|
+
* Remote-less full clones fall back to intrinsic root commits. A remote-less
|
|
437
|
+
* shallow clone cannot prove a clone-independent repository identity without
|
|
438
|
+
* fetching missing history or persisting a shared ID, so it retains the local
|
|
439
|
+
* main-worktree label (which is still stable across linked worktrees). */
|
|
440
|
+
export function stableRepositoryName(root) {
|
|
441
|
+
const fetchRemote = [...gitRemoteIdentities(root, "fetch")].sort()[0];
|
|
442
|
+
if (fetchRemote) {
|
|
443
|
+
const digest = createHash("sha256").update(fetchRemote, "utf8").digest("hex");
|
|
444
|
+
return `git-remote:sha256:${digest}`;
|
|
445
|
+
}
|
|
446
|
+
const shallow = gitSafe(["rev-parse", "--is-shallow-repository"], root) === "true";
|
|
447
|
+
if (shallow)
|
|
448
|
+
return basename(mainWorktreeRoot(root));
|
|
449
|
+
const roots = gitSafeWithoutReplacements(["rev-list", "--max-parents=0", "HEAD"], root)
|
|
450
|
+
.split(/\s+/)
|
|
451
|
+
.filter(Boolean)
|
|
452
|
+
.sort();
|
|
453
|
+
if (roots.length)
|
|
454
|
+
return `git:${roots.join("+")}`;
|
|
455
|
+
return basename(mainWorktreeRoot(root));
|
|
456
|
+
}
|
|
457
|
+
const CAPTURE_REMOTE_TIMEOUT_MS = 15_000;
|
|
458
|
+
const READ_REMOTE_TIMEOUT_MS = 5_000;
|
|
459
|
+
// A capture can spend roughly 90s in commit + bounded merge/push/retry seams.
|
|
460
|
+
// A contending writer waits beyond that proven ceiling, then takes the lock and
|
|
461
|
+
// drains every already-durable JSON write itself. This removes the old "maybe a
|
|
462
|
+
// third capture sweeps it later" liveness hole.
|
|
463
|
+
const CAPTURE_LOCK_HANDOFF_MS = 120_000;
|
|
464
|
+
function unsafeOverlayPublication(hunchDir, protectedRepoRoot) {
|
|
465
|
+
let currentOverlayRoot = dirname(resolve(hunchDir));
|
|
466
|
+
try {
|
|
467
|
+
currentOverlayRoot = dirname(realpathSync(hunchDir));
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
const standalone = isGitRepoRoot(currentOverlayRoot);
|
|
473
|
+
const overlapsCode = standalone ? sameGitPublication(currentOverlayRoot, protectedRepoRoot) : null;
|
|
474
|
+
const unsafe = !standalone || overlapsCode === true;
|
|
475
|
+
if (unsafe && process.env.HUNCH_TEAM_CLONE_DEBUG === "1") {
|
|
476
|
+
process.stderr.write(`[hunch-team-boundary] standalone=${standalone} overlaps_code=${overlapsCode ?? "unchecked"}\n`);
|
|
477
|
+
}
|
|
478
|
+
return unsafe;
|
|
479
|
+
}
|
|
480
|
+
export function commitAndPushHunch(hunchDir, message, opts) {
|
|
481
|
+
// Runtime callers may be older compiled JS even though TypeScript requires the
|
|
482
|
+
// protected-repository contract. Preserve this helper's never-throw promise and
|
|
483
|
+
// fail closed instead of dereferencing a missing options object.
|
|
484
|
+
if (!opts) {
|
|
485
|
+
console.error(`hunch: refusing to auto-commit memory at "${hunchDir}" — the protected repository identity was not provided. Nothing was staged, committed, or pushed.`);
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
// A push-capable target is a PRIVATE/SHARED overlay and must live directly
|
|
489
|
+
// inside its OWN repository. `git -C` otherwise walks upward and may resolve
|
|
490
|
+
// a nested overlay to the user's code repo. JSON-only private artifacts would
|
|
491
|
+
// pass the staged-file backstop below, so enforce the repository boundary
|
|
492
|
+
// before staging even one byte. Public `.hunch/` commits intentionally use
|
|
493
|
+
// push:false and are allowed to resolve to the enclosing project repository.
|
|
494
|
+
if (opts.push !== false) {
|
|
495
|
+
if (unsafeOverlayPublication(hunchDir, opts.protectedRepoRoot)) {
|
|
496
|
+
console.error(`hunch: refusing to auto-commit private memory at "${hunchDir}" — it is not a standalone Git repository distinct from the protected code repository. Nothing was staged, committed, or pushed. (Run \`hunch private\` or \`hunch shared --repo <url>\` to create a dedicated overlay repository.)`);
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
62
500
|
// Serialize across worktrees: several worktrees auto-committing the SAME overlay repo
|
|
63
|
-
// at once would race git's index.lock.
|
|
64
|
-
//
|
|
65
|
-
//
|
|
501
|
+
// at once would race git's index.lock. A contender whose record is already
|
|
502
|
+
// durable waits for the live owner to finish, then acquires the lock and
|
|
503
|
+
// drains anything the owner's exact path snapshot did not include.
|
|
66
504
|
const lock = join(hunchDir, ".hunch-commit.lock");
|
|
67
|
-
|
|
505
|
+
const firstLockAttempt = acquireCommitLock(lock);
|
|
506
|
+
if (firstLockAttempt.state !== "acquired"
|
|
507
|
+
&& !waitForCommitLockHandoff(lock, firstLockAttempt, CAPTURE_LOCK_HANDOFF_MS))
|
|
68
508
|
return null;
|
|
69
509
|
try {
|
|
70
|
-
const env =
|
|
510
|
+
const env = machineCommitEnv(hunchDir, {
|
|
511
|
+
...process.env,
|
|
512
|
+
HUNCH_SYNC: "1",
|
|
513
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
514
|
+
GIT_MERGE_AUTOEDIT: "no",
|
|
515
|
+
GIT_ATTR_NOSYSTEM: "1",
|
|
516
|
+
});
|
|
71
517
|
const run = (args) => {
|
|
72
518
|
try {
|
|
73
519
|
execFileSync("git", ["-C", hunchDir, ...args], { stdio: "ignore", env });
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return false; // best-effort: nothing staged / not a repo / offline
|
|
74
524
|
}
|
|
75
|
-
catch { /* best-effort: nothing staged / not a repo / offline */ }
|
|
76
525
|
};
|
|
77
|
-
|
|
526
|
+
if (opts.push !== false) {
|
|
527
|
+
if (!overlayAttributeSourcesAreSafe(hunchDir, env)) {
|
|
528
|
+
console.error(`hunch: refusing to auto-commit private memory at "${hunchDir}" — an unsafe Git attributes source could transform memory bytes. Nothing was staged, committed, or pushed.`);
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
const paths = committableOverlayJsonPaths(hunchDir);
|
|
532
|
+
if (!paths)
|
|
533
|
+
return null;
|
|
534
|
+
// Force-add only the exact contained JSON source-of-truth allowlist. A
|
|
535
|
+
// remote .gitignore, local info/exclude, or ambient excludesFile must not
|
|
536
|
+
// be able to silently stop the shared graph's heartbeat.
|
|
537
|
+
for (let index = 0; index < paths.length; index += 128) {
|
|
538
|
+
if (!run(["-c", `core.attributesFile=${gitNullDevice()}`, "add", "-f", "--", ...paths.slice(index, index + 128)])) {
|
|
539
|
+
run(["reset", "-q", "--", "."]);
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
run(["add", "--", "."]);
|
|
546
|
+
}
|
|
78
547
|
// SAFETY BACKSTOP (critical — bug_overlay_clobber): a memory sync is PURELY ADDITIVE small
|
|
79
548
|
// JSON. If the staged set contains a DELETION, rename, or any non-.json file, hunchDir is NOT
|
|
80
549
|
// a clean overlay store — most dangerously, the overlay was never its own git repo so `git -C`
|
|
81
550
|
// walked UP to the PROJECT repo. Committing/pushing there would overwrite/delete the user's
|
|
82
551
|
// code (we shipped exactly this). Refuse hard: unstage and bail without committing or pushing.
|
|
83
|
-
|
|
552
|
+
const memoryPaths = stagedMemoryPaths(hunchDir, env);
|
|
553
|
+
if (memoryPaths === null) {
|
|
84
554
|
try {
|
|
85
555
|
execFileSync("git", ["-C", hunchDir, "reset", "-q", "--", "."], { stdio: "ignore", env });
|
|
86
556
|
}
|
|
@@ -94,34 +564,74 @@ export function commitAndPushHunch(hunchDir, message, opts = {}) {
|
|
|
94
564
|
}
|
|
95
565
|
return null;
|
|
96
566
|
}
|
|
567
|
+
if (memoryPaths.length === 0)
|
|
568
|
+
return null;
|
|
97
569
|
// Grounding docs refreshed by this capture ride the same memory commit, so committed record
|
|
98
570
|
// counts can never go stale (the refresh-counts treadmill: every capture commit bumped the
|
|
99
571
|
// count and re-staled the docs for the next release-gate clean-tree check). Staged AFTER the
|
|
100
572
|
// memory-only backstop on purpose: alsoStage is a code-controlled list of generated grounding
|
|
101
573
|
// docs the caller verified git-clean BEFORE rewriting, so it can neither weaken the
|
|
102
574
|
// bug_overlay_clobber detection above nor sweep user edits.
|
|
103
|
-
for (const file of opts.alsoStage ?? [])
|
|
104
|
-
run(
|
|
575
|
+
for (const file of opts.alsoStage ?? []) {
|
|
576
|
+
run(opts.push === false
|
|
577
|
+
? ["add", "--", file]
|
|
578
|
+
: ["-c", `core.attributesFile=${gitNullDevice()}`, "add", "--", file]);
|
|
579
|
+
}
|
|
105
580
|
// Only sync+push when a memory commit was actually created — never run pull/push against the
|
|
106
581
|
// enclosing repo on an empty stage. Two-way sync: MERGE the remote BEFORE pushing so a push
|
|
107
582
|
// can't be rejected non-fast-forward; the .hunch merge driver resolves same-record conflicts
|
|
108
583
|
// by id. On conflict/offline, mergeRemote aborts to a clean tree and we skip the push.
|
|
109
584
|
let committed = false;
|
|
585
|
+
// `git commit` without pathspecs commits the ENTIRE index. Even after the
|
|
586
|
+
// staged-set check, another process (or a pre-staged JSON file) could enter
|
|
587
|
+
// the index before commit. `--only` makes the mutation boundary mechanical:
|
|
588
|
+
// commit this Hunch tree plus the caller-vetted grounding files, and leave
|
|
589
|
+
// every unrelated staged byte untouched.
|
|
590
|
+
// Commit an exact path allowlist. A hook or concurrent index writer cannot
|
|
591
|
+
// smuggle local.json (or any other path) into a broad `--only -- .` commit.
|
|
592
|
+
// Hooks are disabled for this machine-generated commit; user hooks are an
|
|
593
|
+
// untrusted mutation seam and are unnecessary for JSON memory artifacts.
|
|
594
|
+
const hooksDir = disabledHooksDir(hunchDir);
|
|
595
|
+
if (!hooksDir)
|
|
596
|
+
return null;
|
|
597
|
+
const commitPaths = [...memoryPaths, ...(opts.alsoStage ?? [])];
|
|
110
598
|
try {
|
|
111
|
-
execFileSync("git", [
|
|
599
|
+
execFileSync("git", [
|
|
600
|
+
"-C", hunchDir,
|
|
601
|
+
"-c", `core.hooksPath=${hooksDir}`,
|
|
602
|
+
...(opts.push === false ? [] : ["-c", `core.attributesFile=${gitNullDevice()}`]),
|
|
603
|
+
"-c", "commit.gpgsign=false",
|
|
604
|
+
"commit", "--no-gpg-sign", "--only", "-m", message, "--", ...commitPaths,
|
|
605
|
+
], { stdio: "ignore", env, timeout: 15_000 });
|
|
112
606
|
committed = true;
|
|
113
607
|
}
|
|
114
608
|
catch { /* nothing staged / not a repo */ }
|
|
115
609
|
if (!committed)
|
|
116
610
|
return null;
|
|
117
|
-
if (opts.push !== false
|
|
611
|
+
if (opts.push !== false) {
|
|
612
|
+
// The overlay remote is mutable process state. Re-prove the publication
|
|
613
|
+
// boundary after the local commit and BEFORE pull: hooks or another
|
|
614
|
+
// process may have re-pointed it since the pre-staging check.
|
|
615
|
+
if (unsafeOverlayPublication(hunchDir, opts.protectedRepoRoot)) {
|
|
616
|
+
console.error(`hunch: private memory was committed locally, but the overlay publication boundary changed before sync. Nothing was pulled or pushed.`);
|
|
617
|
+
return "committed";
|
|
618
|
+
}
|
|
619
|
+
if (!contractReady(opts.remote)
|
|
620
|
+
|| mergeRemote(hunchDir, env, CAPTURE_REMOTE_TIMEOUT_MS, opts.remote) === "failed")
|
|
621
|
+
return "committed";
|
|
622
|
+
// Hooks are disabled in the merge seam, but another process can still
|
|
623
|
+
// rewrite Git configuration. Check once after merge and once at the
|
|
624
|
+
// actual push seam; either refusal leaves the private commit local.
|
|
625
|
+
if (unsafeOverlayPublication(hunchDir, opts.protectedRepoRoot) || !contractReady(opts.remote)) {
|
|
626
|
+
console.error(`hunch: private memory was committed locally, but the overlay publication boundary changed during sync. Nothing was pushed.`);
|
|
627
|
+
return "committed";
|
|
628
|
+
}
|
|
118
629
|
// Push tracked (not via run): a no-upstream/offline/rejected push must report
|
|
119
630
|
// "committed", not overclaim "pushed" — the next flush's merge+push retries.
|
|
120
|
-
|
|
121
|
-
|
|
631
|
+
if (unsafeOverlayPublication(hunchDir, opts.protectedRepoRoot) || !contractReady(opts.remote))
|
|
632
|
+
return "committed";
|
|
633
|
+
if (pushWithOneRemoteAdvanceRetry(hunchDir, env, opts.protectedRepoRoot, CAPTURE_REMOTE_TIMEOUT_MS, opts.remote))
|
|
122
634
|
return "pushed";
|
|
123
|
-
}
|
|
124
|
-
catch { /* offline / no upstream */ }
|
|
125
635
|
}
|
|
126
636
|
return "committed";
|
|
127
637
|
}
|
|
@@ -136,7 +646,10 @@ export function commitAndPushHunch(hunchDir, message, opts = {}) {
|
|
|
136
646
|
* dirty, so a doc the user never committed is never swept into a memory commit). */
|
|
137
647
|
export function isGitCleanPath(root, rel) {
|
|
138
648
|
try {
|
|
139
|
-
return execFileSync("git", ["-C", root, "status", "--porcelain", "--", rel], {
|
|
649
|
+
return execFileSync("git", ["-C", root, "status", "--porcelain", "--", rel], {
|
|
650
|
+
encoding: "utf8",
|
|
651
|
+
env: foreignRepoEnv(process.env),
|
|
652
|
+
}).trim() === "";
|
|
140
653
|
}
|
|
141
654
|
catch {
|
|
142
655
|
return false;
|
|
@@ -146,18 +659,28 @@ export function isGitCleanPath(root, rel) {
|
|
|
146
659
|
* The overlay store is entirely JSON (decisions/, bugs/, …, manifest.json). A real memory sync
|
|
147
660
|
* is purely additive; a DELETION, rename, or any non-.json staged path means hunchDir is NOT a
|
|
148
661
|
* clean overlay repo (e.g. it resolved to the project repo), so committing there would clobber
|
|
149
|
-
* code. Empty stage ⇒
|
|
150
|
-
function
|
|
662
|
+
* code. Empty stage ⇒ [] (nothing to commit); invalid stage ⇒ null. The transient mkdir lock is ignored. */
|
|
663
|
+
function stagedMemoryPaths(hunchDir, env) {
|
|
151
664
|
let out = "";
|
|
665
|
+
let prefix = "";
|
|
152
666
|
try {
|
|
153
|
-
|
|
667
|
+
prefix = execFileSync("git", ["-C", hunchDir, "rev-parse", "--show-prefix"], { encoding: "utf8", env }).trim().replace(/\\/g, "/");
|
|
154
668
|
}
|
|
155
669
|
catch {
|
|
156
|
-
return
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
if (!prefix)
|
|
673
|
+
return null; // a Hunch layout is a scoped subdirectory, never the whole repository
|
|
674
|
+
try {
|
|
675
|
+
out = execFileSync("git", ["-C", hunchDir, "diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-status"], { encoding: "utf8", env });
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
return null;
|
|
157
679
|
}
|
|
158
680
|
const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
159
681
|
if (!lines.length)
|
|
160
|
-
return
|
|
682
|
+
return [];
|
|
683
|
+
const memoryPaths = [];
|
|
161
684
|
for (const line of lines) {
|
|
162
685
|
const parts = line.split("\t");
|
|
163
686
|
const status = (parts[0] ?? "").trim();
|
|
@@ -165,42 +688,716 @@ function stagedIsMemoryOnly(hunchDir, env) {
|
|
|
165
688
|
if (path.includes(".hunch-commit.lock"))
|
|
166
689
|
continue; // transient lock dir, never a record
|
|
167
690
|
if (!/^[AM]$/.test(status))
|
|
168
|
-
return
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
691
|
+
return null; // only Add / Modify — any D/R/C/T → not a memory sync
|
|
692
|
+
const normalizedPath = path.replace(/\\/g, "/");
|
|
693
|
+
if (!normalizedPath.startsWith(prefix))
|
|
694
|
+
return null; // never bless another staged JSON file
|
|
695
|
+
const memoryRelativePath = normalizedPath.slice(prefix.length);
|
|
696
|
+
if (!memoryRelativePath || memoryRelativePath === "local.json")
|
|
697
|
+
return null; // machine-local overlay pointer; never publish it
|
|
698
|
+
if (!normalizedPath.endsWith(".json"))
|
|
699
|
+
return null; // the store is entirely JSON records
|
|
700
|
+
memoryPaths.push(memoryRelativePath);
|
|
701
|
+
}
|
|
702
|
+
return [...new Set(memoryPaths)];
|
|
703
|
+
}
|
|
704
|
+
/** Enumerate ordinary JSON files already contained under an overlay. Push-capable
|
|
705
|
+
* stores force-add this exact allowlist so remote .gitignore, info/exclude, or an
|
|
706
|
+
* ambient excludesFile cannot silently stop the memory pump. Public push:false
|
|
707
|
+
* stores intentionally keep ordinary Git ignore semantics after migration. */
|
|
708
|
+
function committableOverlayJsonPaths(hunchDir) {
|
|
709
|
+
try {
|
|
710
|
+
const root = realpathSync(hunchDir);
|
|
711
|
+
const paths = [];
|
|
712
|
+
const walk = (dir, prefix = "") => {
|
|
713
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
714
|
+
if (!prefix && entry.name === ".hunch-commit.lock")
|
|
715
|
+
continue;
|
|
716
|
+
const absolute = join(dir, entry.name);
|
|
717
|
+
const relativeName = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
718
|
+
const stat = lstatSync(absolute);
|
|
719
|
+
if (stat.isSymbolicLink())
|
|
720
|
+
return false;
|
|
721
|
+
if (stat.isDirectory()) {
|
|
722
|
+
if (!walk(absolute, relativeName))
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
else if (!stat.isFile()) {
|
|
726
|
+
return false;
|
|
727
|
+
}
|
|
728
|
+
else if (relativeName.endsWith(".json") && relativeName !== "local.json") {
|
|
729
|
+
const fromRoot = relative(root, realpathSync(absolute));
|
|
730
|
+
if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot))
|
|
731
|
+
return false;
|
|
732
|
+
paths.push(relativeName);
|
|
733
|
+
}
|
|
734
|
+
else if (relativeName === "local.json") {
|
|
735
|
+
return false;
|
|
736
|
+
}
|
|
737
|
+
else if (/^[^/]+\.sqlite[^/]*$/i.test(relativeName)
|
|
738
|
+
|| relativeName.split("/").some((segment) => segment.includes(".tmp"))
|
|
739
|
+
|| relativeName === "events.log") {
|
|
740
|
+
// Known clone-local/derived artifacts are never staged. Everything
|
|
741
|
+
// else is a topology violation: a shared graph repository cannot
|
|
742
|
+
// quietly carry arbitrary source alongside its JSON memory.
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return true;
|
|
750
|
+
};
|
|
751
|
+
return walk(root) ? paths.sort() : null;
|
|
752
|
+
}
|
|
753
|
+
catch {
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
/** True only when the checked-out Hunch tree has no staged, unstaged, untracked,
|
|
758
|
+
* or conflicted memory bytes. Remote sync must never use autostash: applying a
|
|
759
|
+
* stash can feed a user's uncommitted record through the structured merge driver
|
|
760
|
+
* and replace it with a higher-confidence remote record. */
|
|
761
|
+
function hunchWorktreeClean(hunchDir, env) {
|
|
762
|
+
try {
|
|
763
|
+
return execFileSync("git", ["-C", hunchDir, "status", "--porcelain=v1", "--untracked-files=all", "--", "."], {
|
|
764
|
+
encoding: "utf8",
|
|
765
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
766
|
+
env,
|
|
767
|
+
timeout: 2_000,
|
|
768
|
+
}).trim() === "";
|
|
769
|
+
}
|
|
770
|
+
catch {
|
|
771
|
+
return false;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
function overlayGitTreeIsSafe(hunchDir, revision, env) {
|
|
775
|
+
if (!/^[0-9a-f]+$/i.test(revision))
|
|
776
|
+
return false;
|
|
777
|
+
try {
|
|
778
|
+
const listing = execFileSync("git", ["-C", hunchDir, "ls-tree", "--full-tree", "-r", "-t", "-z", revision], {
|
|
779
|
+
encoding: "utf8",
|
|
780
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
781
|
+
env,
|
|
782
|
+
timeout: 2_000,
|
|
783
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
784
|
+
});
|
|
785
|
+
return hunchTreeAttributesAreSafe(listing, (oid) => {
|
|
786
|
+
try {
|
|
787
|
+
return execFileSync("git", ["-C", hunchDir, "cat-file", "blob", oid], {
|
|
788
|
+
encoding: "utf8",
|
|
789
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
790
|
+
env,
|
|
791
|
+
timeout: 2_000,
|
|
792
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
const MAX_ATTRIBUTES_BYTES = 4 * 1024 * 1024;
|
|
805
|
+
function boundedAttributesFileIsSafe(file, expectedCanonicalPath) {
|
|
806
|
+
try {
|
|
807
|
+
const stat = lstatSync(file);
|
|
808
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1 || stat.size > MAX_ATTRIBUTES_BYTES)
|
|
809
|
+
return false;
|
|
810
|
+
if (!sameFilesystemEntry(file, expectedCanonicalPath))
|
|
811
|
+
return false;
|
|
812
|
+
return hunchAttributesAreSafe(readFileSync(file, "utf8"));
|
|
813
|
+
}
|
|
814
|
+
catch (error) {
|
|
815
|
+
return error.code === "ENOENT";
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
function absoluteGitMetadataDir(cwd, env) {
|
|
819
|
+
try {
|
|
820
|
+
const raw = execFileSync("git", ["-C", cwd, "rev-parse", "--absolute-git-dir"], {
|
|
821
|
+
encoding: "utf8",
|
|
822
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
823
|
+
env,
|
|
824
|
+
timeout: 2_000,
|
|
825
|
+
}).trim();
|
|
826
|
+
if (!raw)
|
|
827
|
+
return null;
|
|
828
|
+
const canonical = realpathSync(raw);
|
|
829
|
+
const stat = lstatSync(canonical);
|
|
830
|
+
return !stat.isSymbolicLink() && stat.isDirectory() ? canonical : null;
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
function overlayGitMetadataDir(hunchDir, env) {
|
|
837
|
+
try {
|
|
838
|
+
const overlayRoot = dirname(realpathSync(hunchDir));
|
|
839
|
+
const expected = realpathSync(join(overlayRoot, ".git"));
|
|
840
|
+
const actual = absoluteGitMetadataDir(hunchDir, env);
|
|
841
|
+
return actual && sameFilesystemEntry(actual, expected) ? expected : null;
|
|
842
|
+
}
|
|
843
|
+
catch {
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
/** Attribute sources not stored in the fetched tree can still influence Git's
|
|
848
|
+
* clean/smudge pipeline. Validate the live root and nested Hunch attributes and
|
|
849
|
+
* the otherwise-unavoidable $GIT_DIR/info/attributes before any add/merge/reset.
|
|
850
|
+
* Global and system sources are separately disabled on the command itself. */
|
|
851
|
+
function overlayAttributeSourcesAreSafe(hunchDir, env) {
|
|
852
|
+
try {
|
|
853
|
+
const canonicalHunch = realpathSync(hunchDir);
|
|
854
|
+
const overlayRoot = dirname(canonicalHunch);
|
|
855
|
+
if (canonicalHunch !== join(overlayRoot, ".hunch") || !safeOverlayTree(overlayRoot))
|
|
856
|
+
return false;
|
|
857
|
+
const gitDir = overlayGitMetadataDir(hunchDir, env);
|
|
858
|
+
if (!gitDir)
|
|
859
|
+
return false;
|
|
860
|
+
if (!boundedAttributesFileIsSafe(join(overlayRoot, ".gitattributes"), join(overlayRoot, ".gitattributes"))
|
|
861
|
+
|| !boundedAttributesFileIsSafe(join(gitDir, "info", "attributes"), join(gitDir, "info", "attributes"))) {
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
864
|
+
const walk = (dir) => {
|
|
865
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
866
|
+
if (dir === canonicalHunch && entry.name === ".hunch-commit.lock")
|
|
867
|
+
continue;
|
|
868
|
+
const path = join(dir, entry.name);
|
|
869
|
+
const stat = lstatSync(path);
|
|
870
|
+
if (stat.isSymbolicLink())
|
|
871
|
+
return false;
|
|
872
|
+
if (stat.isDirectory()) {
|
|
873
|
+
if (!walk(path))
|
|
874
|
+
return false;
|
|
875
|
+
}
|
|
876
|
+
else if (!stat.isFile()) {
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
else if (entry.name === ".gitattributes"
|
|
880
|
+
&& !boundedAttributesFileIsSafe(path, path)) {
|
|
881
|
+
return false;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return true;
|
|
885
|
+
};
|
|
886
|
+
return walk(canonicalHunch);
|
|
887
|
+
}
|
|
888
|
+
catch {
|
|
889
|
+
return false;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
const TEAM_FETCH_REF = "refs/hunch/team-sync";
|
|
893
|
+
function contractReady(contract) {
|
|
894
|
+
if (!contract)
|
|
895
|
+
return true;
|
|
896
|
+
if (!contract.fetchUrl || !contract.pushUrl || !/^refs\/heads\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(contract.ref)) {
|
|
897
|
+
return false;
|
|
898
|
+
}
|
|
899
|
+
try {
|
|
900
|
+
return contract.verify();
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
return false;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
/** Team network operations deliberately ignore process-level Git command/config
|
|
907
|
+
* injection. Repository-local route overrides are rejected by the contract
|
|
908
|
+
* verifier; these variables are the remaining ambient way to replace SSH or add
|
|
909
|
+
* arbitrary `-c` entries between validation and use. */
|
|
910
|
+
function boundedTeamEnv(env) {
|
|
911
|
+
const bounded = foreignRepoEnv(env);
|
|
912
|
+
for (const key of Object.keys(bounded)) {
|
|
913
|
+
if (key === "GIT_SSH" || key === "GIT_SSH_COMMAND" || key === "GIT_CONFIG_PARAMETERS"
|
|
914
|
+
|| key === "GIT_CONFIG_COUNT" || /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(key)) {
|
|
915
|
+
delete bounded[key];
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
bounded.GIT_CONFIG_NOSYSTEM = "1";
|
|
919
|
+
bounded.GIT_ATTR_NOSYSTEM = "1";
|
|
920
|
+
bounded.GIT_ALLOW_PROTOCOL = "https:ssh:git:file";
|
|
921
|
+
bounded.GIT_TERMINAL_PROMPT = "0";
|
|
922
|
+
return bounded;
|
|
923
|
+
}
|
|
924
|
+
function disabledHooksDir(hunchDir) {
|
|
925
|
+
try {
|
|
926
|
+
const gitDir = absoluteGitMetadataDir(hunchDir, foreignRepoEnv(process.env));
|
|
927
|
+
if (!gitDir)
|
|
928
|
+
return null;
|
|
929
|
+
const hooksDir = join(gitDir, "hunch-disabled-hooks");
|
|
930
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
931
|
+
const stat = lstatSync(hooksDir);
|
|
932
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()
|
|
933
|
+
|| relative(gitDir, realpathSync(hooksDir)) !== "hunch-disabled-hooks"
|
|
934
|
+
|| readdirSync(hooksDir).length !== 0)
|
|
935
|
+
return null;
|
|
936
|
+
return hooksDir;
|
|
937
|
+
}
|
|
938
|
+
catch {
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
/** List every branch on the exact contract URL. A team graph has either zero
|
|
943
|
+
* branches while bootstrapping or exactly its one canonical branch. */
|
|
944
|
+
function contractRemoteHeads(hunchDir, contract, direction, env, timeoutMs) {
|
|
945
|
+
if (!contractReady(contract))
|
|
946
|
+
return null;
|
|
947
|
+
const url = direction === "fetch" ? contract.fetchUrl : contract.pushUrl;
|
|
948
|
+
const hooksDir = disabledHooksDir(hunchDir);
|
|
949
|
+
if (!hooksDir)
|
|
950
|
+
return null;
|
|
951
|
+
try {
|
|
952
|
+
const out = execFileSync("git", [
|
|
953
|
+
"-C", hunchDir,
|
|
954
|
+
"-c", `core.hooksPath=${hooksDir}`,
|
|
955
|
+
"ls-remote", "--refs", "--heads", "--upload-pack=git-upload-pack", url,
|
|
956
|
+
], {
|
|
957
|
+
encoding: "utf8",
|
|
958
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
959
|
+
env: boundedTeamEnv(env),
|
|
960
|
+
timeout: timeoutMs,
|
|
961
|
+
});
|
|
962
|
+
if (!contractReady(contract))
|
|
963
|
+
return null;
|
|
964
|
+
const heads = [];
|
|
965
|
+
for (const line of out.split(/\r?\n/).filter(Boolean)) {
|
|
966
|
+
const match = line.match(/^([0-9a-f]{40,64})\t(refs\/heads\/.+)$/i);
|
|
967
|
+
if (!match)
|
|
968
|
+
return null;
|
|
969
|
+
heads.push({ oid: match[1], ref: match[2] });
|
|
970
|
+
}
|
|
971
|
+
return heads;
|
|
972
|
+
}
|
|
973
|
+
catch {
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
function fetchContractRef(hunchDir, contract, env, timeoutMs) {
|
|
978
|
+
if (!contractReady(contract))
|
|
979
|
+
return "";
|
|
980
|
+
const hooksDir = disabledHooksDir(hunchDir);
|
|
981
|
+
if (!hooksDir)
|
|
982
|
+
return "";
|
|
983
|
+
try {
|
|
984
|
+
execFileSync("git", [
|
|
985
|
+
"-C", hunchDir,
|
|
986
|
+
"-c", `core.hooksPath=${hooksDir}`,
|
|
987
|
+
"fetch", "--no-tags", "--no-write-fetch-head", "--upload-pack=git-upload-pack",
|
|
988
|
+
contract.fetchUrl, `+${contract.ref}:${TEAM_FETCH_REF}`,
|
|
989
|
+
], { stdio: "ignore", env: boundedTeamEnv(env), timeout: timeoutMs });
|
|
990
|
+
if (!contractReady(contract))
|
|
991
|
+
return "";
|
|
992
|
+
const oid = execFileSync("git", ["-C", hunchDir, "rev-parse", "--verify", TEAM_FETCH_REF], {
|
|
993
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
|
|
994
|
+
}).trim();
|
|
995
|
+
return /^[0-9a-f]{40,64}$/i.test(oid) ? oid : "";
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
return "";
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
function setContractUpstream(hunchDir, contract, env) {
|
|
1002
|
+
try {
|
|
1003
|
+
const branch = execFileSync("git", ["-C", hunchDir, "symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
1004
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
|
|
1005
|
+
}).trim();
|
|
1006
|
+
if (!branch)
|
|
1007
|
+
return false;
|
|
1008
|
+
const remoteBranch = contract.ref.slice("refs/heads/".length);
|
|
1009
|
+
let oid = gitSafeWithEnv(["rev-parse", "--verify", TEAM_FETCH_REF], hunchDir, env);
|
|
1010
|
+
if (!oid)
|
|
1011
|
+
oid = headShaWithEnv(hunchDir, env);
|
|
1012
|
+
if (!oid || !remoteBranch)
|
|
1013
|
+
return false;
|
|
1014
|
+
execFileSync("git", ["-C", hunchDir, "update-ref", `refs/remotes/origin/${remoteBranch}`, oid], {
|
|
1015
|
+
stdio: "ignore", env, timeout: 2_000,
|
|
1016
|
+
});
|
|
1017
|
+
execFileSync("git", ["-C", hunchDir, "config", `branch.${branch}.remote`, "origin"], { stdio: "ignore", env, timeout: 2_000 });
|
|
1018
|
+
execFileSync("git", ["-C", hunchDir, "config", `branch.${branch}.merge`, contract.ref], { stdio: "ignore", env, timeout: 2_000 });
|
|
1019
|
+
return contractReady(contract);
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
return false;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
/** A fresh clone of an empty remote has no HEAD, but auto-wiring creates the
|
|
1026
|
+
* default manifest before another teammate may publish first. That one generated
|
|
1027
|
+
* file is safe to replace with the now-canonical remote tree; any actual record or
|
|
1028
|
+
* other dirty path makes bootstrap fail closed. */
|
|
1029
|
+
function unbornBootstrapFingerprint(hunchDir, env) {
|
|
1030
|
+
try {
|
|
1031
|
+
const status = execFileSync("git", ["-C", hunchDir, "status", "--porcelain=v1", "--untracked-files=all", "--", "."], {
|
|
1032
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
|
|
1033
|
+
}).trim();
|
|
1034
|
+
const lines = status ? status.split(/\r?\n/) : [];
|
|
1035
|
+
if (lines.some((line) => !/^\?\? (?:\.hunch\/)?manifest\.json$/.test(line.trim())))
|
|
1036
|
+
return null;
|
|
1037
|
+
let manifest = "";
|
|
1038
|
+
try {
|
|
1039
|
+
manifest = readFileSync(join(hunchDir, "manifest.json"), "utf8");
|
|
1040
|
+
const value = JSON.parse(manifest);
|
|
1041
|
+
if (!value || Array.isArray(value) || typeof value !== "object"
|
|
1042
|
+
|| Object.keys(value).some((key) => key !== "schema_version")
|
|
1043
|
+
|| typeof value.schema_version !== "number")
|
|
1044
|
+
return null;
|
|
1045
|
+
}
|
|
1046
|
+
catch {
|
|
1047
|
+
if (lines.length)
|
|
1048
|
+
return null;
|
|
1049
|
+
}
|
|
1050
|
+
return createHash("sha256").update(status).update("\0").update(manifest).digest("hex");
|
|
1051
|
+
}
|
|
1052
|
+
catch {
|
|
1053
|
+
return null;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
function adoptContractHead(hunchDir, fetchedHead, contract, env, fingerprint) {
|
|
1057
|
+
if (unbornBootstrapFingerprint(hunchDir, env) !== fingerprint || !contractReady(contract))
|
|
1058
|
+
return false;
|
|
1059
|
+
const hooksDir = disabledHooksDir(hunchDir);
|
|
1060
|
+
if (!hooksDir)
|
|
1061
|
+
return false;
|
|
1062
|
+
const manifest = join(hunchDir, "manifest.json");
|
|
1063
|
+
const backup = join(hunchDir, ".hunch-commit.lock", "bootstrap-manifest.json");
|
|
1064
|
+
let moved = false;
|
|
1065
|
+
try {
|
|
1066
|
+
try {
|
|
1067
|
+
renameSync(manifest, backup);
|
|
1068
|
+
moved = true;
|
|
1069
|
+
}
|
|
1070
|
+
catch { /* absent default manifest */ }
|
|
1071
|
+
execFileSync("git", [
|
|
1072
|
+
"-C", hunchDir,
|
|
1073
|
+
"-c", `core.hooksPath=${hooksDir}`,
|
|
1074
|
+
"-c", `core.attributesFile=${gitNullDevice()}`,
|
|
1075
|
+
"reset", "--hard", fetchedHead,
|
|
1076
|
+
], {
|
|
1077
|
+
stdio: "ignore", env, timeout: 5_000,
|
|
1078
|
+
});
|
|
1079
|
+
return overlayGitTreeIsSafe(hunchDir, headShaWithEnv(hunchDir, env), env)
|
|
1080
|
+
&& overlayAttributeSourcesAreSafe(hunchDir, env);
|
|
1081
|
+
}
|
|
1082
|
+
catch {
|
|
1083
|
+
if (moved) {
|
|
1084
|
+
try {
|
|
1085
|
+
renameSync(backup, manifest);
|
|
1086
|
+
}
|
|
1087
|
+
catch { /* leave the durable backup under the lock */ }
|
|
1088
|
+
}
|
|
1089
|
+
return false;
|
|
1090
|
+
}
|
|
1091
|
+
finally {
|
|
1092
|
+
if (moved)
|
|
1093
|
+
try {
|
|
1094
|
+
rmSync(backup, { force: true });
|
|
1095
|
+
}
|
|
1096
|
+
catch { /* lock cleanup is the final backstop */ }
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories = false) {
|
|
1100
|
+
env = machineCommitEnv(hunchDir, env);
|
|
1101
|
+
env.GIT_ATTR_NOSYSTEM = "1";
|
|
1102
|
+
let overlayRoot = "";
|
|
1103
|
+
try {
|
|
1104
|
+
overlayRoot = dirname(realpathSync(hunchDir));
|
|
1105
|
+
}
|
|
1106
|
+
catch {
|
|
1107
|
+
return "failed";
|
|
1108
|
+
}
|
|
1109
|
+
if (!isGitRepoRoot(overlayRoot) || !safeOverlayTree(overlayRoot)
|
|
1110
|
+
|| !overlayAttributeSourcesAreSafe(hunchDir, env) || !contractReady(contract)) {
|
|
1111
|
+
return "failed";
|
|
1112
|
+
}
|
|
1113
|
+
const localHead = headShaWithEnv(hunchDir, env);
|
|
1114
|
+
const unbornFingerprint = localHead ? null : unbornBootstrapFingerprint(hunchDir, env);
|
|
1115
|
+
if (localHead ? !hunchWorktreeClean(hunchDir, env) : !unbornFingerprint)
|
|
1116
|
+
return "failed";
|
|
1117
|
+
const hooksDir = disabledHooksDir(hunchDir);
|
|
1118
|
+
if (!hooksDir)
|
|
1119
|
+
return "failed";
|
|
1120
|
+
const tryGit = (args, timeout = timeoutMs) => {
|
|
180
1121
|
try {
|
|
181
|
-
execFileSync("git", [
|
|
1122
|
+
execFileSync("git", [
|
|
1123
|
+
"-C", hunchDir,
|
|
1124
|
+
"-c", `core.hooksPath=${hooksDir}`,
|
|
1125
|
+
"-c", `core.attributesFile=${gitNullDevice()}`,
|
|
1126
|
+
"-c", "commit.gpgsign=false",
|
|
1127
|
+
...args,
|
|
1128
|
+
], {
|
|
1129
|
+
stdio: "ignore", env, timeout,
|
|
1130
|
+
});
|
|
182
1131
|
return true;
|
|
183
1132
|
}
|
|
184
1133
|
catch {
|
|
185
1134
|
return false;
|
|
186
1135
|
}
|
|
187
1136
|
};
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
1137
|
+
let fetchedHead = "";
|
|
1138
|
+
if (contract) {
|
|
1139
|
+
const heads = contractRemoteHeads(hunchDir, contract, "fetch", env, timeoutMs);
|
|
1140
|
+
if (!heads)
|
|
1141
|
+
return "failed";
|
|
1142
|
+
if (heads.length === 0)
|
|
1143
|
+
return "unconfigured";
|
|
1144
|
+
if (heads.length !== 1 || heads[0].ref !== contract.ref)
|
|
1145
|
+
return "failed";
|
|
1146
|
+
fetchedHead = fetchContractRef(hunchDir, contract, env, timeoutMs);
|
|
1147
|
+
}
|
|
1148
|
+
else {
|
|
1149
|
+
if (!tryGit(["rev-parse", "--abbrev-ref", "@{upstream}"], 2_000))
|
|
1150
|
+
return "unconfigured";
|
|
1151
|
+
if (!tryGit(["fetch", "--no-tags"]))
|
|
1152
|
+
return "failed";
|
|
1153
|
+
fetchedHead = upstreamSha(hunchDir, env);
|
|
1154
|
+
}
|
|
1155
|
+
if (!fetchedHead || !overlayGitTreeIsSafe(hunchDir, fetchedHead, env))
|
|
1156
|
+
return "failed";
|
|
1157
|
+
// Another process need not honor Hunch's lock. Re-prove both the exact local
|
|
1158
|
+
// revision and the filesystem boundary after the network operation, before
|
|
1159
|
+
// Git is permitted to materialize the already-validated fetched tree.
|
|
1160
|
+
if (headShaWithEnv(hunchDir, env) !== localHead
|
|
1161
|
+
|| !safeOverlayTree(overlayRoot)
|
|
1162
|
+
|| !overlayAttributeSourcesAreSafe(hunchDir, env)
|
|
1163
|
+
|| (localHead ? !hunchWorktreeClean(hunchDir, env) : unbornBootstrapFingerprint(hunchDir, env) !== unbornFingerprint)
|
|
1164
|
+
|| (localHead && !overlayGitTreeIsSafe(hunchDir, localHead, env))
|
|
1165
|
+
|| !contractReady(contract))
|
|
1166
|
+
return "failed";
|
|
1167
|
+
// Canonicalize local branch/upstream metadata before the worktree mutation.
|
|
1168
|
+
// If this fails, the exact fetched tree has not been materialized yet.
|
|
1169
|
+
if (contract && !setContractUpstream(hunchDir, contract, env))
|
|
1170
|
+
return "failed";
|
|
1171
|
+
if (!localHead && contract && unbornFingerprint) {
|
|
1172
|
+
return adoptContractHead(hunchDir, fetchedHead, contract, env, unbornFingerprint) ? "merged" : "failed";
|
|
1173
|
+
}
|
|
1174
|
+
if (tryGit(["merge", "--no-edit", ...(allowUnrelatedHistories ? ["--allow-unrelated-histories"] : []), fetchedHead])) {
|
|
1175
|
+
return safeOverlayTree(overlayRoot)
|
|
1176
|
+
&& overlayAttributeSourcesAreSafe(hunchDir, env)
|
|
1177
|
+
&& contractReady(contract)
|
|
1178
|
+
&& overlayGitTreeIsSafe(hunchDir, headShaWithEnv(hunchDir, env), env)
|
|
1179
|
+
? "merged"
|
|
1180
|
+
: "failed";
|
|
1181
|
+
}
|
|
1182
|
+
tryGit(["merge", "--abort"], 2_000); // conflict/timeout → restore a clean tree
|
|
1183
|
+
return "failed";
|
|
1184
|
+
}
|
|
1185
|
+
function upstreamSha(hunchDir, env) {
|
|
1186
|
+
try {
|
|
1187
|
+
return execFileSync("git", ["-C", hunchDir, "rev-parse", "@{upstream}"], {
|
|
1188
|
+
encoding: "utf8",
|
|
1189
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1190
|
+
env,
|
|
1191
|
+
timeout: 2_000,
|
|
1192
|
+
}).trim();
|
|
1193
|
+
}
|
|
1194
|
+
catch {
|
|
1195
|
+
return "";
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
function tryPush(hunchDir, env, timeoutMs, contract) {
|
|
1199
|
+
if (!contractReady(contract))
|
|
1200
|
+
return false;
|
|
1201
|
+
const hooksDir = disabledHooksDir(hunchDir);
|
|
1202
|
+
if (!hooksDir)
|
|
1203
|
+
return false;
|
|
1204
|
+
try {
|
|
1205
|
+
if (contract && !setContractUpstream(hunchDir, contract, env))
|
|
1206
|
+
return false;
|
|
1207
|
+
const args = contract
|
|
1208
|
+
? ["-C", hunchDir, "-c", `core.hooksPath=${hooksDir}`, "push", "--receive-pack=git-receive-pack", contract.pushUrl, `HEAD:${contract.ref}`]
|
|
1209
|
+
: ["-C", hunchDir, "-c", `core.hooksPath=${hooksDir}`, "push"];
|
|
1210
|
+
execFileSync("git", args, { stdio: "ignore", env: contract ? boundedTeamEnv(env) : env, timeout: timeoutMs });
|
|
1211
|
+
return contractReady(contract);
|
|
1212
|
+
}
|
|
1213
|
+
catch {
|
|
1214
|
+
return false;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
/** Publish an unborn/untracked local branch to a genuinely empty shared remote
|
|
1218
|
+
* and establish its upstream. This path is deliberately narrower than a generic
|
|
1219
|
+
* `push -u`: it selects only an explicit configured push remote (or the sole
|
|
1220
|
+
* remote), proves that remote has no refs, snapshots its identity across the
|
|
1221
|
+
* network check, and uses a non-force push so a concurrent first writer wins
|
|
1222
|
+
* safely instead of being overwritten. */
|
|
1223
|
+
function establishEmptyRemoteUpstream(hunchDir, env, protectedRepoRoot, timeoutMs, contract) {
|
|
1224
|
+
if (unsafeOverlayPublication(hunchDir, protectedRepoRoot)
|
|
1225
|
+
|| !hunchWorktreeClean(hunchDir, env)
|
|
1226
|
+
|| !contractReady(contract))
|
|
1227
|
+
return false;
|
|
1228
|
+
if (contract) {
|
|
1229
|
+
const heads = contractRemoteHeads(hunchDir, contract, "push", env, timeoutMs);
|
|
1230
|
+
if (!heads || heads.length !== 0 || !contractReady(contract))
|
|
1231
|
+
return false;
|
|
1232
|
+
// Exact URL + exact canonical ref + non-force push. If another teammate wins
|
|
1233
|
+
// the first-writer race after the empty proof, Git rejects this safely and the
|
|
1234
|
+
// bounded retry path fetches/merges that winner.
|
|
1235
|
+
return tryPush(hunchDir, env, timeoutMs, contract);
|
|
1236
|
+
}
|
|
1237
|
+
let branch = "";
|
|
1238
|
+
let remotes = [];
|
|
1239
|
+
let preferred = "";
|
|
1240
|
+
try {
|
|
1241
|
+
branch = execFileSync("git", ["-C", hunchDir, "symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
1242
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
|
|
1243
|
+
}).trim();
|
|
1244
|
+
remotes = execFileSync("git", ["-C", hunchDir, "remote"], {
|
|
1245
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
|
|
1246
|
+
}).split("\n").map((remote) => remote.trim()).filter(Boolean);
|
|
1247
|
+
preferred = execFileSync("git", ["-C", hunchDir, "config", "--get", "remote.pushDefault"], {
|
|
1248
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
|
|
1249
|
+
}).trim();
|
|
1250
|
+
}
|
|
1251
|
+
catch {
|
|
1252
|
+
// A missing remote.pushDefault is normal; choose the sole remote below.
|
|
1253
|
+
}
|
|
1254
|
+
const remote = preferred && remotes.includes(preferred)
|
|
1255
|
+
? preferred
|
|
1256
|
+
: remotes.length === 1 ? remotes[0] : "";
|
|
1257
|
+
if (!branch || !remote)
|
|
1258
|
+
return false;
|
|
1259
|
+
const pushIdentity = () => {
|
|
1260
|
+
try {
|
|
1261
|
+
const url = execFileSync("git", ["-C", hunchDir, "remote", "get-url", "--push", remote], {
|
|
1262
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
|
|
1263
|
+
}).trim();
|
|
1264
|
+
return remoteIdentity(url, hunchDir);
|
|
1265
|
+
}
|
|
1266
|
+
catch {
|
|
1267
|
+
return "";
|
|
1268
|
+
}
|
|
1269
|
+
};
|
|
1270
|
+
const before = pushIdentity();
|
|
1271
|
+
if (!before)
|
|
1272
|
+
return false;
|
|
1273
|
+
try {
|
|
1274
|
+
const refs = execFileSync("git", ["-C", hunchDir, "ls-remote", "--refs", remote], {
|
|
1275
|
+
encoding: "utf8",
|
|
1276
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1277
|
+
env,
|
|
1278
|
+
timeout: timeoutMs,
|
|
1279
|
+
}).trim();
|
|
1280
|
+
if (refs)
|
|
1281
|
+
return false;
|
|
1282
|
+
}
|
|
1283
|
+
catch {
|
|
1284
|
+
return false;
|
|
1285
|
+
}
|
|
1286
|
+
if (unsafeOverlayPublication(hunchDir, protectedRepoRoot)
|
|
1287
|
+
|| !hunchWorktreeClean(hunchDir, env)
|
|
1288
|
+
|| pushIdentity() !== before)
|
|
1289
|
+
return false;
|
|
1290
|
+
try {
|
|
1291
|
+
const hooksDir = disabledHooksDir(hunchDir);
|
|
1292
|
+
if (!hooksDir)
|
|
1293
|
+
return false;
|
|
1294
|
+
execFileSync("git", ["-C", hunchDir, "-c", `core.hooksPath=${hooksDir}`, "push", "--set-upstream", remote, `HEAD:refs/heads/${branch}`], {
|
|
1295
|
+
stdio: "ignore",
|
|
1296
|
+
env,
|
|
1297
|
+
timeout: timeoutMs,
|
|
1298
|
+
});
|
|
191
1299
|
return true;
|
|
192
|
-
|
|
193
|
-
|
|
1300
|
+
}
|
|
1301
|
+
catch {
|
|
1302
|
+
return false;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
/** Push once, then retry exactly once only when a bounded pull proves the upstream
|
|
1306
|
+
* advanced during the first push seam. Offline/auth/hook failures with an unchanged
|
|
1307
|
+
* upstream never loop, and every remote mutation re-proves the publication boundary. */
|
|
1308
|
+
function pushWithOneRemoteAdvanceRetry(hunchDir, env, protectedRepoRoot, timeoutMs, contract) {
|
|
1309
|
+
if (unsafeOverlayPublication(hunchDir, protectedRepoRoot) || !contractReady(contract))
|
|
1310
|
+
return false;
|
|
1311
|
+
const before = contract
|
|
1312
|
+
? gitSafeWithEnv(["rev-parse", "--verify", TEAM_FETCH_REF], hunchDir, env)
|
|
1313
|
+
: upstreamSha(hunchDir, env);
|
|
1314
|
+
if (!before)
|
|
1315
|
+
return establishEmptyRemoteUpstream(hunchDir, env, protectedRepoRoot, timeoutMs, contract);
|
|
1316
|
+
if (tryPush(hunchDir, env, timeoutMs, contract))
|
|
1317
|
+
return true;
|
|
1318
|
+
if (unsafeOverlayPublication(hunchDir, protectedRepoRoot) || !contractReady(contract))
|
|
1319
|
+
return false;
|
|
1320
|
+
const merged = mergeRemote(hunchDir, env, timeoutMs, contract);
|
|
1321
|
+
const after = contract
|
|
1322
|
+
? gitSafeWithEnv(["rev-parse", "--verify", TEAM_FETCH_REF], hunchDir, env)
|
|
1323
|
+
: upstreamSha(hunchDir, env);
|
|
1324
|
+
if (merged !== "merged" || !before || !after || before === after)
|
|
1325
|
+
return false;
|
|
1326
|
+
if (unsafeOverlayPublication(hunchDir, protectedRepoRoot) || !contractReady(contract))
|
|
1327
|
+
return false;
|
|
1328
|
+
return tryPush(hunchDir, env, timeoutMs, contract);
|
|
1329
|
+
}
|
|
1330
|
+
export function pullHunchStatus(hunchDir, opts = {}) {
|
|
1331
|
+
const env = foreignRepoEnv({
|
|
1332
|
+
...process.env,
|
|
1333
|
+
...opts.env,
|
|
1334
|
+
HUNCH_SYNC: "1",
|
|
1335
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
1336
|
+
GIT_MERGE_AUTOEDIT: "no",
|
|
1337
|
+
});
|
|
1338
|
+
const before = headShaWithEnv(hunchDir, env);
|
|
1339
|
+
const lock = join(hunchDir, ".hunch-commit.lock");
|
|
1340
|
+
if (acquireCommitLock(lock).state !== "acquired")
|
|
1341
|
+
return "busy";
|
|
1342
|
+
try {
|
|
1343
|
+
const merged = mergeRemote(hunchDir, env, opts.timeoutMs ?? READ_REMOTE_TIMEOUT_MS, opts.remote, opts.allowUnrelatedHistories ?? false);
|
|
1344
|
+
if (merged === "failed" || merged === "unconfigured" || !contractReady(opts.remote)) {
|
|
1345
|
+
return merged === "unconfigured" ? merged : "failed";
|
|
1346
|
+
}
|
|
1347
|
+
return headShaWithEnv(hunchDir, env) !== before ? "updated" : "current";
|
|
1348
|
+
}
|
|
1349
|
+
finally {
|
|
1350
|
+
try {
|
|
1351
|
+
rmSync(lock, { recursive: true, force: true });
|
|
1352
|
+
}
|
|
1353
|
+
catch { /* released best-effort */ }
|
|
1354
|
+
}
|
|
194
1355
|
}
|
|
195
|
-
/** Best-effort READ-side sync: merge the overlay's remote into the local branch (e.g. on MCP
|
|
196
|
-
* server start) so this machine/session sees other machines' memory. Never throws; leaves a
|
|
197
|
-
* clean tree. Serialized with the commit lock so it can't race a concurrent flush. */
|
|
198
1356
|
export function pullHunch(hunchDir) {
|
|
1357
|
+
return pullHunchStatus(hunchDir) === "updated";
|
|
1358
|
+
}
|
|
1359
|
+
/** Explicit retry path for a clean overlay that already has a local memory commit
|
|
1360
|
+
* stranded by an earlier offline/rejected push. Unlike commitAndPushHunch this
|
|
1361
|
+
* creates no commit: it only converges and publishes existing overlay history. */
|
|
1362
|
+
export function syncExistingHunch(hunchDir, protectedRepoRoot, timeoutMs = CAPTURE_REMOTE_TIMEOUT_MS, remote) {
|
|
1363
|
+
if (unsafeOverlayPublication(hunchDir, protectedRepoRoot) || !contractReady(remote))
|
|
1364
|
+
return "failed";
|
|
199
1365
|
const lock = join(hunchDir, ".hunch-commit.lock");
|
|
200
|
-
if (
|
|
201
|
-
return;
|
|
1366
|
+
if (acquireCommitLock(lock).state !== "acquired")
|
|
1367
|
+
return "failed";
|
|
202
1368
|
try {
|
|
203
|
-
|
|
1369
|
+
const env = foreignRepoEnv({
|
|
1370
|
+
...process.env,
|
|
1371
|
+
HUNCH_SYNC: "1",
|
|
1372
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
1373
|
+
GIT_MERGE_AUTOEDIT: "no",
|
|
1374
|
+
});
|
|
1375
|
+
const merged = mergeRemote(hunchDir, env, timeoutMs, remote);
|
|
1376
|
+
if (merged === "failed" || unsafeOverlayPublication(hunchDir, protectedRepoRoot) || !contractReady(remote))
|
|
1377
|
+
return "failed";
|
|
1378
|
+
if (merged === "unconfigured") {
|
|
1379
|
+
return pushWithOneRemoteAdvanceRetry(hunchDir, env, protectedRepoRoot, timeoutMs, remote) ? "pushed" : "failed";
|
|
1380
|
+
}
|
|
1381
|
+
const upstream = remote
|
|
1382
|
+
? gitSafeWithEnv(["rev-parse", "--verify", TEAM_FETCH_REF], hunchDir, env)
|
|
1383
|
+
: upstreamSha(hunchDir, env);
|
|
1384
|
+
if (!upstream)
|
|
1385
|
+
return "failed";
|
|
1386
|
+
let ahead = false;
|
|
1387
|
+
try {
|
|
1388
|
+
ahead = execFileSync("git", ["-C", hunchDir, "rev-list", "--count", `${upstream}..HEAD`], {
|
|
1389
|
+
encoding: "utf8",
|
|
1390
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1391
|
+
env,
|
|
1392
|
+
timeout: 2_000,
|
|
1393
|
+
}).trim() !== "0";
|
|
1394
|
+
}
|
|
1395
|
+
catch {
|
|
1396
|
+
return "failed";
|
|
1397
|
+
}
|
|
1398
|
+
if (!ahead)
|
|
1399
|
+
return "current";
|
|
1400
|
+
return pushWithOneRemoteAdvanceRetry(hunchDir, env, protectedRepoRoot, timeoutMs, remote) ? "pushed" : "failed";
|
|
204
1401
|
}
|
|
205
1402
|
finally {
|
|
206
1403
|
try {
|
|
@@ -209,28 +1406,127 @@ export function pullHunch(hunchDir) {
|
|
|
209
1406
|
catch { /* released best-effort */ }
|
|
210
1407
|
}
|
|
211
1408
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
1409
|
+
const UNKNOWN_LOCK_STALE_MS = 10 * 60_000;
|
|
1410
|
+
function createOwnedCommitLock(lock) {
|
|
1411
|
+
let created = false;
|
|
215
1412
|
try {
|
|
1413
|
+
// mkdir is the exclusive atomic operation here. Renaming a staged directory
|
|
1414
|
+
// is NOT exclusive on POSIX: it may replace an already-existing empty lock
|
|
1415
|
+
// directory, which would steal a fresh legacy/ownerless lock. There is a
|
|
1416
|
+
// harmless ownerless window between these two mkdir calls; contenders treat
|
|
1417
|
+
// it as held until the conservative legacy TTL expires.
|
|
216
1418
|
mkdirSync(lock);
|
|
1419
|
+
created = true;
|
|
1420
|
+
// Empty directories are not Git worktree entries, so owner metadata cannot
|
|
1421
|
+
// be staged by the memory-only `git add .` seam.
|
|
1422
|
+
mkdirSync(join(lock, `owner-${process.pid}`));
|
|
217
1423
|
return true;
|
|
218
1424
|
}
|
|
219
1425
|
catch {
|
|
220
|
-
|
|
221
|
-
|
|
1426
|
+
// If the exclusive mkdir succeeded but owner creation somehow failed, only
|
|
1427
|
+
// this process can have created the still-ownerless directory. Remove it so
|
|
1428
|
+
// a transient local failure does not strand every writer for the full TTL.
|
|
1429
|
+
if (created && !commitLockOwner(lock)) {
|
|
1430
|
+
try {
|
|
222
1431
|
rmSync(lock, { recursive: true, force: true });
|
|
223
|
-
mkdirSync(lock);
|
|
224
|
-
return true;
|
|
225
1432
|
}
|
|
1433
|
+
catch { /* best effort */ }
|
|
1434
|
+
}
|
|
1435
|
+
return false;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
function commitLockOwner(lock) {
|
|
1439
|
+
try {
|
|
1440
|
+
for (const name of readdirSync(lock)) {
|
|
1441
|
+
const match = name.match(/^owner-([1-9][0-9]*)$/);
|
|
1442
|
+
if (match)
|
|
1443
|
+
return { name, pid: Number(match[1]) };
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
catch { /* vanished or unreadable */ }
|
|
1447
|
+
return null;
|
|
1448
|
+
}
|
|
1449
|
+
function processIsAlive(pid) {
|
|
1450
|
+
try {
|
|
1451
|
+
process.kill(pid, 0);
|
|
1452
|
+
return true;
|
|
1453
|
+
}
|
|
1454
|
+
catch (error) {
|
|
1455
|
+
return error.code === "EPERM";
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
/** Atomic directory lock with PID ownership. A dead owner is recoverable
|
|
1459
|
+
* immediately; a legacy owner-less lock is reclaimed only after a TTL safely
|
|
1460
|
+
* above the longest bounded sync. The fixed reclaim directory serializes
|
|
1461
|
+
* competing crash recovery attempts and the owner is re-read after claiming. */
|
|
1462
|
+
function acquireCommitLock(lock) {
|
|
1463
|
+
if (createOwnedCommitLock(lock))
|
|
1464
|
+
return { state: "acquired" };
|
|
1465
|
+
const observed = commitLockOwner(lock);
|
|
1466
|
+
if (observed && processIsAlive(observed.pid))
|
|
1467
|
+
return { state: "held-live", ownerPid: observed.pid };
|
|
1468
|
+
let reclaimable = !!observed;
|
|
1469
|
+
if (!observed) {
|
|
1470
|
+
try {
|
|
1471
|
+
reclaimable = Date.now() - statSync(lock).mtimeMs > UNKNOWN_LOCK_STALE_MS;
|
|
1472
|
+
}
|
|
1473
|
+
catch {
|
|
1474
|
+
return createOwnedCommitLock(lock) ? { state: "acquired" } : { state: "held-unknown" };
|
|
226
1475
|
}
|
|
227
|
-
|
|
1476
|
+
}
|
|
1477
|
+
if (!reclaimable)
|
|
1478
|
+
return { state: "held-unknown" };
|
|
1479
|
+
const claim = join(lock, "reclaim");
|
|
1480
|
+
try {
|
|
1481
|
+
mkdirSync(claim);
|
|
1482
|
+
}
|
|
1483
|
+
catch {
|
|
1484
|
+
return { state: "held-unknown" };
|
|
1485
|
+
}
|
|
1486
|
+
const current = commitLockOwner(lock);
|
|
1487
|
+
if ((observed && (!current || current.name !== observed.name || processIsAlive(current.pid)))
|
|
1488
|
+
|| (!observed && current)) {
|
|
1489
|
+
try {
|
|
1490
|
+
rmSync(claim, { recursive: true, force: true });
|
|
1491
|
+
}
|
|
1492
|
+
catch { /* best effort */ }
|
|
1493
|
+
return current && processIsAlive(current.pid)
|
|
1494
|
+
? { state: "held-live", ownerPid: current.pid }
|
|
1495
|
+
: { state: "held-unknown" };
|
|
1496
|
+
}
|
|
1497
|
+
try {
|
|
1498
|
+
rmSync(lock, { recursive: true, force: true });
|
|
1499
|
+
}
|
|
1500
|
+
catch {
|
|
1501
|
+
return { state: "held-unknown" };
|
|
1502
|
+
}
|
|
1503
|
+
return createOwnedCommitLock(lock) ? { state: "acquired" } : { state: "held-unknown" };
|
|
1504
|
+
}
|
|
1505
|
+
function waitForCommitLockHandoff(lock, first, timeoutMs) {
|
|
1506
|
+
if (first.state !== "held-live" || first.ownerPid === process.pid)
|
|
228
1507
|
return false;
|
|
1508
|
+
const deadline = Date.now() + timeoutMs;
|
|
1509
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
1510
|
+
let attempt = first;
|
|
1511
|
+
while (Date.now() < deadline) {
|
|
1512
|
+
if (attempt.state === "acquired")
|
|
1513
|
+
return true;
|
|
1514
|
+
if (attempt.state !== "held-live" || attempt.ownerPid === process.pid)
|
|
1515
|
+
return false;
|
|
1516
|
+
Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()));
|
|
1517
|
+
attempt = acquireCommitLock(lock);
|
|
229
1518
|
}
|
|
1519
|
+
return false;
|
|
230
1520
|
}
|
|
231
1521
|
export function headSha(cwd) {
|
|
232
1522
|
return gitSafe(["rev-parse", "HEAD"], cwd);
|
|
233
1523
|
}
|
|
1524
|
+
/** HEAD for a repository that is not the invocation repository. Unlike
|
|
1525
|
+
* headSha, this deliberately ignores code-repo GIT_DIR/GIT_INDEX_FILE state
|
|
1526
|
+
* inherited from hooks. */
|
|
1527
|
+
export function isolatedHeadSha(cwd) {
|
|
1528
|
+
return gitSafeIsolated(["rev-parse", "HEAD"], cwd);
|
|
1529
|
+
}
|
|
234
1530
|
/** Stop tracking `paths` in git (remove from the INDEX only — keep the working-tree
|
|
235
1531
|
* files). Used by `hunch private --migrate` to un-publish the .hunch memory tree
|
|
236
1532
|
* without deleting it locally. `--ignore-unmatch` makes an already-untracked path a
|
|
@@ -319,16 +1615,266 @@ export function pushCurrentBranch(root) {
|
|
|
319
1615
|
return false;
|
|
320
1616
|
}
|
|
321
1617
|
}
|
|
322
|
-
|
|
323
|
-
|
|
1618
|
+
const REVERTABLE_RECORD_DIRS = new Set([
|
|
1619
|
+
"components",
|
|
1620
|
+
"edges",
|
|
1621
|
+
"symbols",
|
|
1622
|
+
"decisions",
|
|
1623
|
+
"bugs",
|
|
1624
|
+
"constraints",
|
|
1625
|
+
"runbooks",
|
|
1626
|
+
"evidence",
|
|
1627
|
+
"corpora",
|
|
1628
|
+
"policies",
|
|
1629
|
+
"proofs",
|
|
1630
|
+
"plans",
|
|
1631
|
+
"dispositions",
|
|
1632
|
+
"shadow",
|
|
1633
|
+
]);
|
|
1634
|
+
const REVERTABLE_AUXILIARY_PATHS = new Set([
|
|
1635
|
+
".hunch/manifest.json",
|
|
1636
|
+
".hunch/config.json",
|
|
1637
|
+
]);
|
|
1638
|
+
const PARTIALLY_MANAGED_GROUNDING_PATHS = new Set([
|
|
1639
|
+
"AGENTS.md",
|
|
1640
|
+
"CLAUDE.md",
|
|
1641
|
+
".github/copilot-instructions.md",
|
|
1642
|
+
]);
|
|
1643
|
+
const FULLY_MANAGED_GROUNDING_PATHS = new Set([
|
|
1644
|
+
".cursor/rules/hunch.mdc",
|
|
1645
|
+
".windsurf/rules/hunch.md",
|
|
1646
|
+
]);
|
|
1647
|
+
const HUNCH_GROUNDING_START = Buffer.from("<!-- HUNCH:START — auto-generated, do not edit by hand -->", "utf8");
|
|
1648
|
+
const HUNCH_GROUNDING_END = Buffer.from("<!-- HUNCH:END -->", "utf8");
|
|
1649
|
+
const HUNCH_GROUNDING_SENTINEL = Buffer.from("\0HUNCH-MANAGED-REGION\0", "utf8");
|
|
1650
|
+
const MAX_REVERT_GROUNDING_BYTES = 8 * 1024 * 1024;
|
|
1651
|
+
const REVERT_TRANSFORM_ATTRIBUTES = ["filter", "working-tree-encoding", "ident", "eol", "text", "crlf", "merge"];
|
|
1652
|
+
function revertGitEnv() {
|
|
1653
|
+
const env = foreignRepoEnv(process.env);
|
|
1654
|
+
// A local refs/replace entry can make a safe-looking SHA behave like an
|
|
1655
|
+
// entirely different commit. Every inspection and the revert itself must see
|
|
1656
|
+
// the literal object graph the user named.
|
|
1657
|
+
env.GIT_NO_REPLACE_OBJECTS = "1";
|
|
1658
|
+
env.GIT_TERMINAL_PROMPT = "0";
|
|
1659
|
+
env.GIT_ATTR_NOSYSTEM = "1";
|
|
1660
|
+
return env;
|
|
1661
|
+
}
|
|
1662
|
+
function revertGitRaw(args, root, env) {
|
|
1663
|
+
try {
|
|
1664
|
+
return execFileSync("git", ["-C", root, ...args], {
|
|
1665
|
+
encoding: "utf8",
|
|
1666
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
1667
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1668
|
+
env,
|
|
1669
|
+
timeout: 5_000,
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
catch {
|
|
1673
|
+
return null;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
function revertGit(args, root, env) {
|
|
1677
|
+
return revertGitRaw(args, root, env)?.trim() ?? "";
|
|
1678
|
+
}
|
|
1679
|
+
function regularBlobAt(ref, path, root, env) {
|
|
1680
|
+
const raw = revertGitRaw(["ls-tree", "-z", ref, "--", path], root, env);
|
|
1681
|
+
if (raw == null)
|
|
1682
|
+
return null;
|
|
1683
|
+
const tab = raw.indexOf("\t");
|
|
1684
|
+
if (tab < 0 || raw.slice(tab + 1) !== `${path}\0`)
|
|
1685
|
+
return null;
|
|
1686
|
+
const header = raw.slice(0, tab).match(/^(100644|100755) blob ([a-f0-9]{40,64})$/i);
|
|
1687
|
+
return header ? { mode: header[1], oid: header[2] } : null;
|
|
1688
|
+
}
|
|
1689
|
+
function blobBytes(oid, root, env) {
|
|
1690
|
+
try {
|
|
1691
|
+
return execFileSync("git", ["-C", root, "cat-file", "blob", oid], {
|
|
1692
|
+
maxBuffer: MAX_REVERT_GROUNDING_BYTES,
|
|
1693
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1694
|
+
env,
|
|
1695
|
+
timeout: 5_000,
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
catch {
|
|
1699
|
+
return null;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
/** Preserve every byte outside Hunch's one well-formed managed region. These
|
|
1703
|
+
* documents contain user/team instructions too; reverting a capture must never
|
|
1704
|
+
* roll those bytes back merely because the same commit refreshed Hunch counts. */
|
|
1705
|
+
function groundingEnvelope(blob) {
|
|
1706
|
+
const start = blob.indexOf(HUNCH_GROUNDING_START);
|
|
1707
|
+
if (start < 0 || blob.indexOf(HUNCH_GROUNDING_START, start + HUNCH_GROUNDING_START.length) >= 0)
|
|
1708
|
+
return null;
|
|
1709
|
+
const end = blob.indexOf(HUNCH_GROUNDING_END, start + HUNCH_GROUNDING_START.length);
|
|
1710
|
+
if (end < 0 || blob.indexOf(HUNCH_GROUNDING_END, end + HUNCH_GROUNDING_END.length) >= 0)
|
|
1711
|
+
return null;
|
|
1712
|
+
return Buffer.concat([
|
|
1713
|
+
blob.subarray(0, start),
|
|
1714
|
+
HUNCH_GROUNDING_SENTINEL,
|
|
1715
|
+
blob.subarray(end + HUNCH_GROUNDING_END.length),
|
|
1716
|
+
]);
|
|
1717
|
+
}
|
|
1718
|
+
function onlyManagedGroundingChanged(before, after, root, env) {
|
|
1719
|
+
if (before.mode !== after.mode)
|
|
1720
|
+
return false;
|
|
1721
|
+
const beforeBytes = blobBytes(before.oid, root, env);
|
|
1722
|
+
const afterBytes = blobBytes(after.oid, root, env);
|
|
1723
|
+
if (!beforeBytes || !afterBytes)
|
|
1724
|
+
return false;
|
|
1725
|
+
const beforeEnvelope = groundingEnvelope(beforeBytes);
|
|
1726
|
+
const afterEnvelope = groundingEnvelope(afterBytes);
|
|
1727
|
+
return !!beforeEnvelope && !!afterEnvelope && beforeEnvelope.equals(afterEnvelope);
|
|
1728
|
+
}
|
|
1729
|
+
function hunchJsonKind(path) {
|
|
1730
|
+
if (REVERTABLE_AUXILIARY_PATHS.has(path))
|
|
1731
|
+
return "auxiliary";
|
|
1732
|
+
const match = path.match(/^\.hunch\/([^/]+)\/([A-Za-z0-9][A-Za-z0-9._-]*\.json)$/);
|
|
1733
|
+
if (!match || !REVERTABLE_RECORD_DIRS.has(match[1]))
|
|
1734
|
+
return null;
|
|
1735
|
+
if ((match[1] === "symbols" || match[1] === "edges") && match[2] !== "index.json")
|
|
1736
|
+
return null;
|
|
1737
|
+
return "record";
|
|
1738
|
+
}
|
|
1739
|
+
function pathsHaveTransformAttributes(paths, root, env) {
|
|
1740
|
+
if (!paths.length)
|
|
1741
|
+
return false;
|
|
1742
|
+
const raw = revertGitRaw(["check-attr", "-z", ...REVERT_TRANSFORM_ATTRIBUTES, "--", ...paths], root, env);
|
|
1743
|
+
if (raw == null)
|
|
1744
|
+
return true;
|
|
1745
|
+
const fields = raw.split("\0");
|
|
1746
|
+
if (fields.at(-1) === "")
|
|
1747
|
+
fields.pop();
|
|
1748
|
+
if (fields.length !== paths.length * REVERT_TRANSFORM_ATTRIBUTES.length * 3)
|
|
1749
|
+
return true;
|
|
1750
|
+
for (let index = 0; index < fields.length; index += 3) {
|
|
1751
|
+
const attribute = fields[index + 1];
|
|
1752
|
+
const value = fields[index + 2];
|
|
1753
|
+
if (!attribute || value == null || !REVERT_TRANSFORM_ATTRIBUTES.includes(attribute))
|
|
1754
|
+
return true;
|
|
1755
|
+
if (value !== "unspecified" && value !== "unset")
|
|
1756
|
+
return true;
|
|
1757
|
+
}
|
|
1758
|
+
return false;
|
|
1759
|
+
}
|
|
1760
|
+
/** Is `sha` one exact, append-only public Hunch move that can be safely
|
|
1761
|
+
* reverted? The timeline is selected with a `.hunch/` pathspec, so a mixed
|
|
1762
|
+
* code+memory commit also appears there; validate the complete commit before
|
|
1763
|
+
* allowing `git revert` to touch the checkout. */
|
|
1764
|
+
function revertableMemoryMove(sha, root) {
|
|
1765
|
+
const env = revertGitEnv();
|
|
1766
|
+
// Accept an exact hexadecimal object id/unique abbreviation only. Resolving
|
|
1767
|
+
// HEAD, a branch, a rev expression, or an option-like string is outside the
|
|
1768
|
+
// CLI's `<sha>` contract and would make the selected target mutable/ambiguous.
|
|
1769
|
+
if (!/^[a-f0-9]{7,64}$/i.test(sha))
|
|
1770
|
+
return null;
|
|
1771
|
+
const commit = revertGit(["rev-parse", "--verify", "--quiet", `${sha}^{commit}`], root, env);
|
|
1772
|
+
if (!/^[a-f0-9]{40,64}$/i.test(commit))
|
|
1773
|
+
return null;
|
|
1774
|
+
// Revert is allowed only from a pristine checkout/index. Include untracked
|
|
1775
|
+
// paths: a successful revert followed by a later broad user commit must not
|
|
1776
|
+
// accidentally publish pre-existing local bytes as part of the undo.
|
|
1777
|
+
if (revertGitRaw(["status", "--porcelain=v1", "-z", "--untracked-files=all"], root, env) !== "")
|
|
1778
|
+
return null;
|
|
1779
|
+
const row = revertGit(["rev-list", "--parents", "-n", "1", commit], root, env)
|
|
1780
|
+
.split(/\s+/)
|
|
1781
|
+
.filter(Boolean);
|
|
1782
|
+
if (row[0] !== commit || row.length > 2)
|
|
1783
|
+
return null; // unknown or merge commit
|
|
1784
|
+
const parent = row[1] ?? null;
|
|
1785
|
+
try {
|
|
1786
|
+
execFileSync("git", ["-C", root, "merge-base", "--is-ancestor", commit, "HEAD"], { stdio: "ignore", env, timeout: 5_000 });
|
|
1787
|
+
}
|
|
1788
|
+
catch {
|
|
1789
|
+
return null; // never apply an unrelated/unpublished history fragment
|
|
1790
|
+
}
|
|
1791
|
+
const raw = revertGitRaw(["diff-tree", "--root", "--no-commit-id", "--name-status", "-r", "-z", commit], root, env);
|
|
1792
|
+
if (raw == null)
|
|
1793
|
+
return null;
|
|
1794
|
+
const fields = raw.split("\0").filter((field) => field !== "");
|
|
1795
|
+
let sawMemoryRecord = false;
|
|
1796
|
+
const paths = [];
|
|
1797
|
+
for (let index = 0; index < fields.length;) {
|
|
1798
|
+
const status = fields[index++];
|
|
1799
|
+
// A legitimate automatic memory move has exactly add/modify entries.
|
|
1800
|
+
// Deletions, renames, copies, type changes, and unknown status codes need a
|
|
1801
|
+
// future tombstone-aware protocol rather than an unrestricted Git revert.
|
|
1802
|
+
if (status !== "A" && status !== "M")
|
|
1803
|
+
return null;
|
|
1804
|
+
const path = fields[index++];
|
|
1805
|
+
if (!path)
|
|
1806
|
+
return null;
|
|
1807
|
+
paths.push(path);
|
|
1808
|
+
const parts = path.split("/");
|
|
1809
|
+
if (parts.some((part) => !part || part === "." || part === ".."))
|
|
1810
|
+
return null;
|
|
1811
|
+
const after = regularBlobAt(commit, path, root, env);
|
|
1812
|
+
if (!after)
|
|
1813
|
+
return null;
|
|
1814
|
+
const before = status === "M" && parent ? regularBlobAt(parent, path, root, env) : null;
|
|
1815
|
+
if (status === "M" && !before)
|
|
1816
|
+
return null;
|
|
1817
|
+
const memoryKind = hunchJsonKind(path);
|
|
1818
|
+
if (memoryKind) {
|
|
1819
|
+
sawMemoryRecord ||= memoryKind === "record";
|
|
1820
|
+
continue;
|
|
1821
|
+
}
|
|
1822
|
+
if (FULLY_MANAGED_GROUNDING_PATHS.has(path))
|
|
1823
|
+
continue;
|
|
1824
|
+
if (PARTIALLY_MANAGED_GROUNDING_PATHS.has(path)) {
|
|
1825
|
+
// Newly-created partially managed docs can contain arbitrary user prose
|
|
1826
|
+
// outside the Hunch block. Only a proven managed-region refresh is safe.
|
|
1827
|
+
if (status !== "M" || !before || !onlyManagedGroundingChanged(before, after, root, env))
|
|
1828
|
+
return null;
|
|
1829
|
+
continue;
|
|
1830
|
+
}
|
|
1831
|
+
return null;
|
|
1832
|
+
}
|
|
1833
|
+
// Manifest/config and grounding are auxiliary to a real graph mutation; they
|
|
1834
|
+
// can never make a routing-only or generated-doc-only commit revertable.
|
|
1835
|
+
if (!sawMemoryRecord || pathsHaveTransformAttributes(paths, root, env))
|
|
1836
|
+
return null;
|
|
1837
|
+
return { commit, paths };
|
|
1838
|
+
}
|
|
1839
|
+
/** Revert one validated memory-only move locally (no push). Returns true on
|
|
1840
|
+
* success. Unsafe targets are refused before mutation; a conflicting revert is
|
|
1841
|
+
* aborted so the working tree is never left half-reverted. */
|
|
324
1842
|
export function revertMemoryMove(sha, root) {
|
|
1843
|
+
const target = revertableMemoryMove(sha, root);
|
|
1844
|
+
if (!target)
|
|
1845
|
+
return false;
|
|
1846
|
+
const env = revertGitEnv();
|
|
325
1847
|
try {
|
|
326
|
-
|
|
1848
|
+
// Recheck the clean boundary after target inspection to narrow the race
|
|
1849
|
+
// between validation and Git taking its own index lock.
|
|
1850
|
+
if (revertGitRaw(["status", "--porcelain=v1", "-z", "--untracked-files=all"], root, env) !== "")
|
|
1851
|
+
return false;
|
|
1852
|
+
if (pathsHaveTransformAttributes(target.paths, root, env))
|
|
1853
|
+
return false;
|
|
1854
|
+
const hooksDir = disabledHooksDir(root);
|
|
1855
|
+
if (!hooksDir)
|
|
1856
|
+
return false;
|
|
1857
|
+
execFileSync("git", [
|
|
1858
|
+
"-C", root,
|
|
1859
|
+
"-c", `core.hooksPath=${hooksDir}`,
|
|
1860
|
+
"-c", "commit.gpgsign=false",
|
|
1861
|
+
"-c", "core.autocrlf=false",
|
|
1862
|
+
"-c", "core.eol=lf",
|
|
1863
|
+
"-c", "core.safecrlf=false",
|
|
1864
|
+
"revert", "--no-edit", "--no-gpg-sign", target.commit,
|
|
1865
|
+
], { stdio: "ignore", env, timeout: 15_000 });
|
|
327
1866
|
return true;
|
|
328
1867
|
}
|
|
329
1868
|
catch {
|
|
330
1869
|
try {
|
|
331
|
-
execFileSync("git", [
|
|
1870
|
+
execFileSync("git", [
|
|
1871
|
+
"-C", root,
|
|
1872
|
+
"-c", "commit.gpgsign=false",
|
|
1873
|
+
"-c", "core.autocrlf=false",
|
|
1874
|
+
"-c", "core.eol=lf",
|
|
1875
|
+
"-c", "core.safecrlf=false",
|
|
1876
|
+
"revert", "--abort",
|
|
1877
|
+
], { stdio: "ignore", env, timeout: 5_000 });
|
|
332
1878
|
}
|
|
333
1879
|
catch { /* nothing to abort */ }
|
|
334
1880
|
return false;
|
|
@@ -494,14 +2040,14 @@ export function fileGitMetrics(cwd, want, days = 90) {
|
|
|
494
2040
|
}
|
|
495
2041
|
/** Files staged for commit (for `hunch check` pre-commit enforcement). */
|
|
496
2042
|
export function stagedFiles(cwd) {
|
|
497
|
-
const out = gitSafe(["diff", "--cached", "--name-only", "--diff-filter=ACMR"], cwd);
|
|
2043
|
+
const out = gitSafe(["diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd);
|
|
498
2044
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
499
2045
|
}
|
|
500
2046
|
/** Files changed anywhere in the working tree compared with HEAD: both staged
|
|
501
2047
|
* and unstaged tracked files, plus untracked files. This powers the local,
|
|
502
2048
|
* pre-commit Change Gate; it never mutates the index or asks an agent/model. */
|
|
503
2049
|
export function workingFiles(cwd) {
|
|
504
|
-
const changed = gitSafe(["diff", "HEAD", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
|
|
2050
|
+
const changed = gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
|
|
505
2051
|
const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter(Boolean);
|
|
506
2052
|
return [...new Set([...changed, ...untracked])].sort();
|
|
507
2053
|
}
|
|
@@ -514,7 +2060,7 @@ export function revExists(ref, cwd) {
|
|
|
514
2060
|
/** Files a PR/branch changes vs `base` (3-dot: changes on HEAD since the merge-base,
|
|
515
2061
|
* i.e. exactly the PR's own commits — the CI Constraint Guard's surface). */
|
|
516
2062
|
export function rangeFiles(base, cwd, head = "HEAD") {
|
|
517
|
-
const out = gitSafe(["diff", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
|
|
2063
|
+
const out = gitSafe(["diff", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
|
|
518
2064
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
519
2065
|
}
|
|
520
2066
|
/** Commit subjects on `head` since `base` (2-dot: commits added by the task),
|
|
@@ -526,14 +2072,14 @@ export function rangeSubjects(base, cwd, head = "HEAD", max = 50) {
|
|
|
526
2072
|
/** The PR's unified diff vs `base` (3-dot), for the Regression Guard's structural
|
|
527
2073
|
* analysis. Same noise-exclusion + truncation budget as commit/staged diffs. */
|
|
528
2074
|
export function rangeDiff(base, cwd, head = "HEAD", maxBytes = 60_000) {
|
|
529
|
-
const out = gitSafe(["diff", "--no-color", "--unified=2", `${base}...${head}`, "--", ...DIFF_NOISE], cwd);
|
|
2075
|
+
const out = gitSafe(["diff", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", `${base}...${head}`, "--", ...DIFF_NOISE], cwd);
|
|
530
2076
|
return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
|
|
531
2077
|
}
|
|
532
2078
|
/** Unified diff of the staged changes (for the Regression Guard's structural
|
|
533
2079
|
* analysis). Excludes machine-generated noise and truncates at the SAME budget as
|
|
534
2080
|
* commitDiff, so the staged and `--commit` guard paths can't diverge on big diffs. */
|
|
535
2081
|
export function stagedDiff(cwd, maxBytes = 60_000) {
|
|
536
|
-
const out = gitSafe(["diff", "--cached", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
|
|
2082
|
+
const out = gitSafe(["diff", "--cached", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
|
|
537
2083
|
return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
|
|
538
2084
|
}
|
|
539
2085
|
/** Unified diff of the complete local working tree vs HEAD. Git's normal diff
|
|
@@ -542,12 +2088,15 @@ export function stagedDiff(cwd, maxBytes = 60_000) {
|
|
|
542
2088
|
* Binary/unreadable files remain in workingFiles (scope checks still apply) but
|
|
543
2089
|
* intentionally contribute no synthetic content to regression analysis. */
|
|
544
2090
|
export function workingDiff(cwd, maxBytes = 60_000) {
|
|
545
|
-
let out = gitSafe(["diff", "HEAD", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
|
|
546
|
-
const tracked = new Set(gitSafe(["diff", "HEAD", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean));
|
|
2091
|
+
let out = gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
|
|
2092
|
+
const tracked = new Set(gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean));
|
|
547
2093
|
const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter((f) => f && !tracked.has(f));
|
|
2094
|
+
const readWorkingFile = createRepoFileReader(cwd);
|
|
548
2095
|
for (const file of untracked) {
|
|
549
2096
|
try {
|
|
550
|
-
const text =
|
|
2097
|
+
const text = readWorkingFile(file);
|
|
2098
|
+
if (text === null)
|
|
2099
|
+
continue;
|
|
551
2100
|
if (text.includes("\0"))
|
|
552
2101
|
continue;
|
|
553
2102
|
const lines = text.split("\n");
|