toolroll 0.9.75 → 0.9.76

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/agent-fence.js +8 -0
  3. package/dist/approval-authority.js +6 -2
  4. package/dist/approval-rules-ui.d.ts +11 -0
  5. package/dist/approval-rules-ui.js +13 -1
  6. package/dist/batch-checks.js +3 -1
  7. package/dist/builder.js +1 -1
  8. package/dist/coding-workspace.js +7 -5
  9. package/dist/contracts/console-api.d.ts +1 -0
  10. package/dist/contracts/console-api.js +1 -0
  11. package/dist/deploy-gate.d.ts +3 -0
  12. package/dist/deploy-gate.js +3 -2
  13. package/dist/desktop-update.d.ts +1 -1
  14. package/dist/discover.js +6 -1
  15. package/dist/first-run.js +3 -1
  16. package/dist/git-incident.d.ts +49 -0
  17. package/dist/git-incident.js +69 -0
  18. package/dist/git-views.d.ts +20 -0
  19. package/dist/git-views.js +120 -0
  20. package/dist/grant.js +3 -1
  21. package/dist/operate.js +7 -1
  22. package/dist/planner.js +5 -1
  23. package/dist/project-knowledge.js +1 -1
  24. package/dist/project-learning.js +22 -2
  25. package/dist/pull-request-flow.js +4 -0
  26. package/dist/release-adapters.d.ts +24 -2
  27. package/dist/release-adapters.js +136 -10
  28. package/dist/release-authority.d.ts +46 -0
  29. package/dist/release-authority.js +64 -0
  30. package/dist/release-auto.d.ts +153 -0
  31. package/dist/release-auto.js +496 -0
  32. package/dist/release-cli.js +56 -2
  33. package/dist/release-coverage.js +1 -1
  34. package/dist/release.d.ts +105 -1
  35. package/dist/release.js +229 -9
  36. package/dist/repository-context.js +4 -6
  37. package/dist/result-follow-ups.js +3 -1
  38. package/dist/scope.d.ts +31 -2
  39. package/dist/scope.js +44 -3
  40. package/dist/scout.js +5 -1
  41. package/dist/server/render-tasks.d.ts +10 -0
  42. package/dist/server/render-tasks.js +23 -13
  43. package/dist/server/route-table.js +3 -0
  44. package/dist/server/settings.js +29 -2
  45. package/dist/server/task-views.js +8 -0
  46. package/dist/store.d.ts +1 -1
  47. package/dist/store.js +19 -4
  48. package/dist/task-base.js +1 -1
  49. package/dist/teardown-git.d.ts +38 -10
  50. package/dist/teardown-git.js +894 -108
  51. package/dist/test-infra.d.ts +26 -0
  52. package/dist/test-infra.js +321 -0
  53. package/dist/worktree.d.ts +4 -0
  54. package/dist/worktree.js +13 -7
  55. package/package.json +1 -1
@@ -11,9 +11,12 @@
11
11
  * nothing is removed.
12
12
  */
13
13
  import { spawnSync } from "node:child_process";
14
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
15
- import { tmpdir } from "node:os";
16
- import { join, resolve } from "node:path";
14
+ import { closeSync, constants as fsConstants, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
15
+ import { randomBytes } from "node:crypto";
16
+ import { homedir } from "node:os";
17
+ import { ensureGitViewsRoot, withCheckoutLock } from "./git-views.js";
18
+ import { gitIncident } from "./git-incident.js";
19
+ import { basename, dirname, join, resolve } from "node:path";
17
20
  import { run } from "./exec.js";
18
21
  import { ENV, ENV_ALLOWLIST } from "./remote-main.js";
19
22
  import { WORKTREE_TIMEOUT_MS } from "./worktree.js";
@@ -25,36 +28,110 @@ export const TEARDOWN_FLAGS = [
25
28
  /** Exit code of a refused command (git's own fatal code, so no caller reads it as "not found" or "no"). */
26
29
  export const REFUSED_CODE = 128;
27
30
  const REFUSED_PREFIX = "Toolroll won't run git here: ";
28
- /** Config keys that run a command, bring in unread config, or redirect inspection away from the checked path.
29
- * `*` is one subsection, any name. */
30
- const COMMAND_KEYS = [
31
- "core.sshcommand", "core.gitproxy", "core.askpass", "core.pager", "core.editor", "core.alternaterefscommand",
32
- "core.worktree", "gpg.ssh.defaultkeycommand", "trailer.*.command", "trailer.*.cmd", "submodule.*.update", "sequence.editor", "diff.external", "credential.helper", "credential.*.helper", "filter.*.clean", "filter.*.smudge", "filter.*.process",
33
- "diff.*.command", "diff.*.textconv", "merge.*.driver", "gpg.program", "gpg.*.program", "uploadpack.packobjectshook",
34
- "remote.*.uploadpack", "remote.*.receivepack", "remote.*.vcs", "pager.*", "include.path", "includeif.*.path",
35
- ].map(key => new RegExp(`^${key.replace(/\./g, "\\.").replace(/\*/g, ".+")}$`));
31
+ const KNOWN_SECTIONS = new Map(Object.entries({
32
+ core: { allow: ["repositoryformatversion", "filemode", "bare", "logallrefupdates", "ignorecase", "precomposeunicode", "autocrlf", "eol", "safecrlf",
33
+ "symlinks", "hookspath", "fsmonitor", "excludesfile", "attributesfile", "quotepath", "longpaths", "untrackedcache", "sharedrepository", "compression",
34
+ "loosecompression", "bigfilethreshold", "preloadindex", "checkstat", "trustctime", "abbrev", "commentchar", "commentstring", "whitespace", "fscache",
35
+ "packedgitlimit", "packedgitwindowsize", "deltabasecachelimit", "multipackindex", "commitgraph", "splitindex", "warnambiguousrefs", "notesref",
36
+ "sparsecheckout", "sparsecheckoutcone", "filesreflocktimeout", "packedrefstimeout", "usereplacerefs", "fsync", "fsyncmethod", "fsyncobjectfiles",
37
+ "checkroundtripencoding", "protecthfs", "protectntfs", "logallrefupdates", "restrictinheritedhandles", "maxtreedepth", "createobject"] },
38
+ // A remote: never its helper programs; its URLs are checked apart (helperUrl).
39
+ remote: { allow: ["url", "pushurl", "fetch", "push", "mirror", "tagopt", "prune", "prunetags", "promisor", "partialclonefilter", "skipdefaultupdate",
40
+ "skipfetchall", "followremotehead", "serveroption", "proxy", "proxyauthmethod"] },
41
+ branch: "any", user: "any", init: "any", pull: "any", push: "any", fetch: "any", status: "any", color: "any", advice: "any", extensions: "any",
42
+ worktree: "any", index: "any", pack: "any", feature: "any", rerere: "any", tag: "any", log: "any", format: "any", i18n: "any", safe: "any",
43
+ blame: "any", column: "any", grep: "any", apply: "any", rebase: "any", help: "any", versionsort: "any", checkout: "any", clean: "any",
44
+ notes: "any", splitindex: "any", transfer: "any", fsck: "any", receive: "any", repack: "any", maintenance: "any", mailmap: "any", am: "any",
45
+ reset: "any", stash: "any", commit: "any", http: "any",
46
+ merge: { allow: ["conflictstyle", "ff", "log", "renormalize", "stat", "autostash", "defaulttoupstream", "renamelimit", "renames", "directoryrenames",
47
+ "verifysignatures", "tool", "guitool", "branchdesc", "suppressdest", "name", "recursive"] },
48
+ diff: { allow: ["algorithm", "renames", "renamelimit", "noprefix", "mnemonicprefix", "context", "interhunkcontext", "colormoved", "colormovedws",
49
+ "indentheuristic", "statgraphwidth", "statnamewidth", "submodule", "ignoresubmodules", "orderfile", "tool", "guitool", "wserrorhighlight", "relative",
50
+ "dirstat", "suppressblankempty", "autorefreshindex", "srcprefix", "dstprefix", "xfuncname", "funcname", "binary", "cachetextconv", "wordregex"] },
51
+ submodule: { deny: ["update"] },
52
+ gc: { deny: ["recentobjectshook"] },
53
+ uploadpack: { deny: ["packobjectshook"] },
54
+ // git-lfs's own: lfsCommand decides (inert keys only); filter: git-lfs's exact values only (runsCommand).
55
+ lfs: "any",
56
+ ssh: { allow: ["variant"] },
57
+ }));
58
+ /** A remote URL git hands to a remote helper: `<helper>::…`, or a scheme git has no transport of its own for (it runs
59
+ * git-remote-<scheme>). scp-like `host:path`, `user@host:path` and plain paths are git's own. */
60
+ const OWN_SCHEMES = new Set(["https", "http", "ssh", "git", "file", "git+ssh", "ssh+git"]);
61
+ function helperUrl(value) {
62
+ const url = value.trim();
63
+ if (url.includes("::"))
64
+ return true;
65
+ const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(url);
66
+ return scheme !== null && !OWN_SCHEMES.has(scheme[1].toLowerCase());
67
+ }
68
+ /** A config key's parts as git names them: section and variable lowercased, the subsection as it is (or null). */
69
+ function keyParts(key) {
70
+ const first = key.indexOf(".");
71
+ const last = key.lastIndexOf(".");
72
+ if (first < 0)
73
+ return { section: key.toLowerCase(), subsection: null, variable: "" };
74
+ return { section: key.slice(0, first).toLowerCase(), subsection: first === last ? null : key.slice(first + 1, last), variable: key.slice(last + 1).toLowerCase() };
75
+ }
36
76
  /** git-lfs settings that name no program (where it fetches, what, whether it checks locks, how it signs in); any other
37
77
  * `lfs.*` key in a checkout's own config could (extensions, custom and standalone transfer agents), so it's refused. */
38
- const INERT_LFS = /^lfs\.(url|pushurl|fetchinclude|fetchexclude|locksverify|repositoryformatversion|.+\.access|.+\.locksverify|setlockablereadonly|skipdownloaderrors|concurrenttransfers)$/;
39
- const lfsCommand = (key) => key.startsWith("lfs.") && !INERT_LFS.test(key);
78
+ const INERT_LFS_PLAIN = new Set(["url", "pushurl", "fetchinclude", "fetchexclude", "locksverify", "repositoryformatversion", "setlockablereadonly", "skipdownloaderrors", "concurrenttransfers"]);
79
+ const INERT_LFS_SUBSECTION = new Set(["access", "locksverify"]);
80
+ const lfsCommand = (key) => {
81
+ const { section, subsection, variable } = keyParts(key);
82
+ if (section !== "lfs")
83
+ return false;
84
+ return subsection === null ? !INERT_LFS_PLAIN.has(variable) : !INERT_LFS_SUBSECTION.has(variable);
85
+ };
40
86
  /** core.fsmonitor as a boolean is git's own daemon; anything else is a command to run. */
41
87
  const BOOLEAN = /^(|true|false|yes|no|on|off|-?\d+)$/i;
88
+ /** Whether one setting can run a command (or bring in config nobody read), whatever scope it came from. git-lfs's own
89
+ * filters, exactly as `git lfs install` writes them (LFS_FILTER), are the one filter allowed, and only where the caller
90
+ * has also refused a `.lfsconfig` naming a git-lfs extension (`lfsChecked`): git-lfs reads that file as it runs. */
91
+ function runsCommand(key, value, lfsChecked) {
92
+ const { section, subsection, variable } = keyParts(key);
93
+ if (section === "core" && variable === "fsmonitor")
94
+ return !BOOLEAN.test(value.trim());
95
+ // A filter: whether it is required names no program; its commands only git-lfs's own, exactly, beside a checked
96
+ // .lfsconfig.
97
+ if (section === "filter")
98
+ return !((variable === "required" && BOOLEAN.test(value.trim())) || (lfsChecked && subsection === "lfs" && LFS_FILTER.get(`filter.lfs.${variable}`) === value));
99
+ if (section === "lfs")
100
+ return lfsCommand(key);
101
+ if (section === "remote" && (variable === "url" || variable === "pushurl") && helperUrl(value))
102
+ return true;
103
+ const rule = KNOWN_SECTIONS.get(section);
104
+ if (rule === undefined)
105
+ return true;
106
+ if (rule === "any")
107
+ return false;
108
+ if ("deny" in rule)
109
+ return rule.deny.includes(variable);
110
+ return !rule.allow.includes(variable);
111
+ }
42
112
  /** The command-running setting a `git config --list --show-scope -z` listing sets, or null. Only local and worktree
43
- * scope count: teardown commands never read system or global config. */
44
- export function commandSetting(listing) {
45
- const fields = listing.split("\0");
46
- for (let at = 0; at + 1 < fields.length; at += 2) {
47
- const scope = fields[at];
48
- const entry = fields[at + 1];
49
- if (scope !== "local" && scope !== "worktree")
50
- continue;
51
- const newline = entry.indexOf("\n");
52
- const key = (newline < 0 ? entry : entry.slice(0, newline)).toLowerCase();
53
- const value = newline < 0 ? "" : entry.slice(newline + 1);
54
- if (key === "core.fsmonitor" ? !BOOLEAN.test(value.trim()) : COMMAND_KEYS.some(pattern => pattern.test(key)) || lfsCommand(key))
55
- return key;
113
+ * scope count: teardown commands never read system or global config. `lfsChecked`: the caller refuses a checkout whose
114
+ * `.lfsconfig` names a git-lfs extension (lfsExtensionIn), so git-lfs's own exact filters may stand (runsCommand).
115
+ * Fails closed: a listing that can't be classified names a setting. */
116
+ export function commandSetting(listing, options = {}) {
117
+ try {
118
+ const fields = listing.split("\0");
119
+ for (let at = 0; at + 1 < fields.length; at += 2) {
120
+ const scope = fields[at];
121
+ const entry = fields[at + 1];
122
+ if (scope !== "local" && scope !== "worktree")
123
+ continue;
124
+ const newline = entry.indexOf("\n");
125
+ const key = newline < 0 ? entry : entry.slice(0, newline);
126
+ const value = newline < 0 ? "" : entry.slice(newline + 1);
127
+ if (runsCommand(key, value, options.lfsChecked === true))
128
+ return key.toLowerCase();
129
+ }
130
+ return null;
131
+ }
132
+ catch {
133
+ return "a setting that couldn't be read";
56
134
  }
57
- return null;
58
135
  }
59
136
  /**
60
137
  * What the plane's git on a build's checkout keeps of the operator's own (global and system) configuration: values,
@@ -64,9 +141,9 @@ export function commandSetting(listing) {
64
141
  */
65
142
  const CARRIED_VALUES = new Set(["user.name", "user.email", "core.autocrlf", "core.eol", "core.safecrlf", "core.excludesfile", "core.ignorecase",
66
143
  "core.precomposeunicode", "core.quotepath", "core.filemode", "core.symlinks", "core.longpaths"]);
67
- const LFS_FILTER = {
144
+ const LFS_FILTER = new Map(Object.entries({
68
145
  "filter.lfs.clean": "git-lfs clean -- %f", "filter.lfs.smudge": "git-lfs smudge -- %f", "filter.lfs.process": "git-lfs filter-process", "filter.lfs.required": "true",
69
- };
146
+ }));
70
147
  /** The `-c` settings that carry the operator's own values (CARRIED_VALUES, LFS_FILTER) from a global/system listing. */
71
148
  export function carriedSettings(listing) {
72
149
  const fields = listing.split("\0");
@@ -80,7 +157,7 @@ export function carriedSettings(listing) {
80
157
  continue;
81
158
  const key = entry.slice(0, newline).toLowerCase();
82
159
  const value = entry.slice(newline + 1);
83
- if (CARRIED_VALUES.has(key) || LFS_FILTER[key] === value)
160
+ if (CARRIED_VALUES.has(key) || LFS_FILTER.get(key) === value)
84
161
  carried.set(key, value);
85
162
  }
86
163
  return [...carried].flatMap(([key, value]) => ["-c", `${key}=${value}`]);
@@ -106,29 +183,92 @@ function nestedRepository(path) {
106
183
  }
107
184
  return false;
108
185
  }
109
- /** The one way teardown runs git, including the target checkout immediately before removal. */
110
- export function teardownGit(runner = run) {
111
- const options = (cwd) => ({ cwd, envAllowlist: ENV_ALLOWLIST, env: ENV, timeoutMs: WORKTREE_TIMEOUT_MS, maxBuffer: 512 * 1024 * 1024 });
112
- const configProblem = async (cwd) => {
113
- const config = await runner("git", ["--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], options(cwd));
186
+ /**
187
+ * Runs `work` with a write view of `cwd`'s repository (gitView): the view, and how to restore git's output. From a
188
+ * folder inside a checkout, the view is of the checkout git would find (checkoutRootOf); with none, git's own "not a
189
+ * git repository", nothing run. One whose `.git` or config can't be read as git reads it, or isn't bound to one
190
+ * repository (boundDirs; `binding.repo` when the caller has the project's), is refused; so is a command whose
191
+ * repository changed under it while git ran.
192
+ */
193
+ async function inView(cwd, binding, work, after = true) {
194
+ const root = checkoutRootOf(cwd);
195
+ if (root === null)
196
+ return NOT_A_REPOSITORY;
197
+ // One plane git command at a time on a checkout (git-views.ts withCheckoutLock: re-entrant along one call chain).
198
+ let key;
199
+ try {
200
+ key = realpathSync(root);
201
+ }
202
+ catch {
203
+ return refused("the checkout couldn't be found");
204
+ }
205
+ try {
206
+ return await withCheckoutLock(realpathSync(homedir()), key, async () => {
207
+ let view;
208
+ try {
209
+ view = gitView(root, "write", binding);
210
+ }
211
+ catch (error) {
212
+ return refused(error instanceof Error ? error.message : "the checkout couldn't be read");
213
+ }
214
+ try {
215
+ const result = await work(view, (result, args) => ({ ...result, stdout: view.restore(result.stdout, args), stderr: view.restore(result.stderr, args) }));
216
+ if (!after)
217
+ return result;
218
+ const why = view.settle();
219
+ return why === null ? result : refused(why);
220
+ }
221
+ finally {
222
+ view.dispose();
223
+ }
224
+ });
225
+ }
226
+ catch (error) {
227
+ return refused(error instanceof Error ? error.message : "the checkout is busy");
228
+ }
229
+ }
230
+ /** The one way teardown runs git, including the target checkout immediately before removal. Every command runs
231
+ * against a write view of its repository (gitView), whose config is the one inspected and whose info is a copy, so a
232
+ * setting or attributes file an agent adds after the inspection is never read (a build's process can outlive its turn:
233
+ * on macOS containment is observational, src/containment.ts, and a sibling build shares the repository). A removal's
234
+ * target is bound to the same repository as the folder it is removed from. A removal without --force runs under the
235
+ * target's own view (its own config.worktree folded in), so git's last check of whether it is clean reads it as the
236
+ * target itself would. `binding.repo`: the project's main checkout, from the plane's records, when the caller has it. */
237
+ export function teardownGit(runner = run, binding = {}) {
238
+ const options = (cwd, env) => ({ cwd, envAllowlist: ENV_ALLOWLIST, env: { ...ENV, ...env }, timeoutMs: WORKTREE_TIMEOUT_MS, maxBuffer: 512 * 1024 * 1024 });
239
+ const configProblem = async (cwd, env) => {
240
+ // git-lfs's own filters may stand only beside a .lfsconfig that names no extension (commandSetting's lfsChecked).
241
+ if (lfsExtensionIn(env["GIT_WORK_TREE"] ?? cwd))
242
+ return refused("the checkout's .lfsconfig sets a git-lfs key that can run a command");
243
+ const config = await runner("git", ["--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], options(cwd, env));
114
244
  if (config.code !== 0)
115
245
  return refused("its config couldn't be read");
116
- const key = commandSetting(config.stdout);
246
+ const key = commandSetting(config.stdout, { lfsChecked: true });
117
247
  return key === null ? null : refused(`the project's git config sets ${key}, which can run a command`);
118
248
  };
119
- return async (cwd, args) => {
120
- const problem = await configProblem(cwd);
249
+ return (cwd, args) => inView(cwd, binding, async (view, restore) => {
250
+ const problem = await configProblem(cwd, view.env);
121
251
  if (problem !== null)
122
252
  return problem;
123
253
  const removal = args[0] === "worktree" && args[1] === "remove";
254
+ const forced = removal && args.includes("--force");
124
255
  const inspection = args.includes("status") || args.includes("diff");
125
256
  const target = removal ? resolve(cwd, args.at(-1)) : cwd;
126
- if (removal) {
127
- const targetProblem = await configProblem(target);
128
- if (targetProblem !== null)
129
- return targetProblem;
257
+ const safeArgs = [...args];
258
+ if (inspection && !safeArgs.includes("--ignore-submodules=all")) {
259
+ const command = safeArgs.findIndex(arg => arg === "status" || arg === "diff");
260
+ safeArgs.splice(command + 1, 0, "--ignore-submodules=all");
130
261
  }
131
- if (removal || inspection || args.includes("checkout") || args.includes("clean")) {
262
+ const ran = async (env) => restore(await runner("git", [...TEARDOWN_FLAGS, ...WRITE_VIEW_FLAGS, ...safeArgs], options(cwd, env)), args);
263
+ if (!(removal || inspection || args.includes("checkout") || args.includes("clean")))
264
+ return ran(view.env);
265
+ // The checkout the command acts on, in its own view (bound to the same repository), checked first.
266
+ return inView(target, { repo: binding.repo ?? dirname(view.dirs.common) }, async (targetView) => {
267
+ if (removal) {
268
+ const targetProblem = await configProblem(target, targetView.env);
269
+ if (targetProblem !== null)
270
+ return targetProblem;
271
+ }
132
272
  try {
133
273
  if (existsSync(target) && nestedRepository(target))
134
274
  return refused("the checkout contains a nested repository");
@@ -138,19 +278,17 @@ export function teardownGit(runner = run) {
138
278
  }
139
279
  // ls-files reads index entries only; it cannot run submodule filters. Even an uninitialized
140
280
  // gitlink is kept, so worktree remove's internal status can never traverse it.
141
- const index = await runner("git", [...TEARDOWN_FLAGS, "ls-files", "--stage", "-z"], options(target));
281
+ const index = await runner("git", [...TEARDOWN_FLAGS, ...WRITE_VIEW_FLAGS, "ls-files", "--stage", "-z"], options(target, targetView.env));
142
282
  if (index.code !== 0)
143
283
  return refused("the checkout's index couldn't be read");
144
284
  if (index.stdout.split("\0").some(entry => entry.startsWith("160000 ")))
145
285
  return refused("the checkout contains a gitlink");
146
- }
147
- const safeArgs = [...args];
148
- if (inspection && !safeArgs.includes("--ignore-submodules=all")) {
149
- const command = safeArgs.findIndex(arg => arg === "status" || arg === "diff");
150
- safeArgs.splice(command + 1, 0, "--ignore-submodules=all");
151
- }
152
- return runner("git", [...TEARDOWN_FLAGS, ...safeArgs], options(cwd));
153
- };
286
+ // git's own clean check in a removal (a `status` it runs in the target) reads the config GIT_COMMON_DIR names:
287
+ // the target's view, inspected above, so it judges the target by the target's own settings.
288
+ return ran(removal && !forced ? { ...view.env, GIT_COMMON_DIR: targetView.env["GIT_COMMON_DIR"] } : view.env);
289
+ // A removed target has no git directory left to prove: the folder it was removed from is proven instead.
290
+ }, !removal);
291
+ });
154
292
  }
155
293
  /**
156
294
  * Git on a build's checkout, as the plane runs it outside every fence (staging, committing and inspecting the build's
@@ -215,18 +353,132 @@ function gitDirsOf(checkout) {
215
353
  /** Whether one config file includes another (or can't be read as a plain file): by a plain reading, and as git itself
216
354
  * parses it, includes not followed (only this one regular file, from a neutral folder, with no other config read). */
217
355
  function configIncludes(path) {
356
+ return includeIn(path) !== null;
357
+ }
358
+ /** The include key one config file sets (lowercased, as git names it), "include" when only a plain reading finds one,
359
+ * or "an unreadable file" when it can't be read as a plain file or parsed; null when it includes nothing. */
360
+ function includeIn(path) {
218
361
  const text = regularFile(path);
219
- if (text === null || /^\s*\[\s*include(if)?\b/im.test(text))
220
- return true;
362
+ if (text === null)
363
+ return "an unreadable file";
221
364
  const parsed = spawnSync("git", ["config", "--file", path, "--no-includes", "--name-only", "--list", "-z"], { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
222
- return parsed.status !== 0 || parsed.stdout.split("\0").some(name => /^(include\.path|includeif\..*\.path)$/i.test(name));
365
+ if (parsed.status !== 0)
366
+ return "an unreadable file";
367
+ const named = parsed.stdout.split("\0").find(name => /^(include\.path|includeif\..*\.path)$/i.test(name));
368
+ if (named !== undefined)
369
+ return named.toLowerCase();
370
+ return /^\s*\[\s*include(if)?\b/im.test(text) ? "include" : null;
223
371
  }
224
- function readView(checkout) {
225
- const dirs = gitDirsOf(checkout);
226
- if (dirs === null)
227
- throw new Error("the checkout's .git couldn't be read as git reads it");
228
- const view = realpathSync(mkdtempSync(join(tmpdir(), "toolroll-git-read-")));
229
- const dispose = () => rmSync(view, { recursive: true, force: true });
372
+ function boundDirs(checkout, binding = {}) {
373
+ const fail = (why) => { throw new Error(`the checkout's .git ${why}`); };
374
+ let top;
375
+ try {
376
+ top = realpathSync(checkout);
377
+ }
378
+ catch {
379
+ return fail("couldn't be found");
380
+ }
381
+ const dotgit = join(top, ".git");
382
+ let held;
383
+ try {
384
+ held = lstatSync(dotgit);
385
+ }
386
+ catch {
387
+ return fail("couldn't be read");
388
+ }
389
+ let dirs;
390
+ if (held.isDirectory()) {
391
+ if (existsSync(join(dotgit, "commondir")))
392
+ fail("names another repository (commondir)");
393
+ dirs = { checkout: top, gitdir: dotgit, common: dotgit, linked: false };
394
+ }
395
+ else {
396
+ if (!held.isFile())
397
+ fail("is a link");
398
+ const named = gitDirsOf(top);
399
+ if (named === null)
400
+ return fail("couldn't be read as git reads it");
401
+ let gitdir;
402
+ try {
403
+ gitdir = realpathSync(named.gitdir);
404
+ }
405
+ catch {
406
+ return fail("names a git directory that isn't there");
407
+ }
408
+ const admin = lstatSync(gitdir);
409
+ if (!admin.isDirectory())
410
+ fail("names something other than a git directory");
411
+ const common = dirname(dirname(gitdir));
412
+ if (basename(dirname(gitdir)) !== "worktrees")
413
+ fail("names a folder that isn't one of its repository's worktrees");
414
+ const commondir = regularFile(join(gitdir, "commondir"))?.trim();
415
+ let named_ = null;
416
+ try {
417
+ named_ = commondir === undefined || commondir === "" ? null : realpathSync(resolve(gitdir, commondir));
418
+ }
419
+ catch {
420
+ named_ = null;
421
+ }
422
+ if (named_ !== common)
423
+ fail("belongs to a git directory whose commondir names another repository");
424
+ const back = regularFile(join(gitdir, "gitdir"))?.trim();
425
+ let backReal = null;
426
+ try {
427
+ backReal = back === undefined || back === "" ? null : realpathSync(resolve(gitdir, back));
428
+ }
429
+ catch {
430
+ backReal = null;
431
+ }
432
+ if (backReal !== dotgit)
433
+ fail("belongs to a git directory that names another checkout");
434
+ dirs = { checkout: top, gitdir, common, linked: true };
435
+ }
436
+ // The common folder is a main checkout's own `.git` folder, a real one.
437
+ if (basename(dirs.common) !== ".git")
438
+ fail("belongs to a repository that isn't a checkout's");
439
+ try {
440
+ if (!lstatSync(dirs.common).isDirectory())
441
+ fail("belongs to a repository that is a link");
442
+ }
443
+ catch {
444
+ fail("belongs to a repository that isn't there");
445
+ }
446
+ if (binding.repo != null) {
447
+ // The anchor is the plane's own record, never a pointer file: the recorded project's main checkout, whose `.git`
448
+ // must be a real folder (not a link, holding no `commondir`); its real path is the only common folder accepted. A
449
+ // record that isn't such a checkout (a linked worktree, as agent-fence.ts trustedWorktreeBinding also refuses) or
450
+ // can't be read is refused, never taken as unbound.
451
+ let recorded = null;
452
+ try {
453
+ const dotgit = join(realpathSync(binding.repo), ".git");
454
+ const held = lstatSync(dotgit);
455
+ recorded = held.isDirectory() && !held.isSymbolicLink() && !existsSync(join(dotgit, "commondir")) ? realpathSync(dotgit) : null;
456
+ }
457
+ catch {
458
+ recorded = null;
459
+ }
460
+ if (recorded === null)
461
+ fail("can't be checked against the project's record: its main checkout's .git isn't a real folder");
462
+ if (recorded !== dirs.common)
463
+ fail("belongs to another repository than the project's");
464
+ }
465
+ return dirs;
466
+ }
467
+ /** What git keeps only per worktree and the plane never links: HEAD (git won't take it as a link) and the pointers. */
468
+ /** What the plane links of a worktree's own git folder (git writes them in place through the link: the index and its
469
+ * logs by lockfile, per-worktree refs and logs inside the folders), whether or not they exist yet; a rebase's,
470
+ * cherry-pick's or sequencer's state folder only when one is already there. */
471
+ const ADMIN_LINKED = ["index", "logs", "refs", "reftable"];
472
+ /** What git keeps in a common directory (its path.c common_list, and reftable), linked whether or not it exists yet. */
473
+ const COMMON_NAMES = ["branches", "gc.pid", "logs", "lost-found", "modules", "objects", "packed-refs", "reftable", "refs", "remotes", "rr-cache", "shallow", "svn", "worktrees"];
474
+ function gitView(checkout, kind, binding = {}) {
475
+ const dirs = boundDirs(checkout, binding);
476
+ const root = ensureGitViewsRoot(realpathSync(homedir()));
477
+ const view = realpathSync(mkdtempSync(join(root, `${kind}-`)));
478
+ const admin = realpathSync(mkdtempSync(join(root, `${kind}-gitdir-`)));
479
+ const dispose = () => { rmSync(view, { recursive: true, force: true }); rmSync(admin, { recursive: true, force: true }); };
480
+ const initial = new Map();
481
+ const priorWorktrees = new Set();
230
482
  try {
231
483
  let config = regularFile(join(dirs.common, "config"));
232
484
  if (config === null)
@@ -251,29 +503,526 @@ function readView(checkout) {
251
503
  if (unset.status !== 0 && unset.status !== 5)
252
504
  throw new Error("the checkout's git config couldn't be read");
253
505
  }
254
- if (configIncludes(configPath))
255
- throw new Error("the checkout's git config includes another file");
506
+ const include = includeIn(configPath);
507
+ if (include !== null)
508
+ throw new Error(`the checkout's git config includes another file (${include})`);
256
509
  mkdirSync(join(view, "info"));
257
- const exclude = join(dirs.common, "info", "exclude");
258
- if (existsSync(exclude)) {
259
- const text = regularFile(exclude);
510
+ // A main checkout's sparse-checkout patterns are in its own (common) info folder; a linked one's in its admin's.
511
+ for (const name of kind === "write" ? ["exclude", "attributes", "sparse-checkout"] : ["exclude", "sparse-checkout"]) {
512
+ if (name === "sparse-checkout" && dirs.linked)
513
+ continue;
514
+ const path = join(dirs.common, "info", name);
515
+ if (!existsSync(path))
516
+ continue;
517
+ const text = regularFile(path);
518
+ // A write that dropped the operator's exclude or attributes could stage what they keep out: refused instead.
519
+ if (text === null && kind === "write")
520
+ throw new Error(`the checkout's info/${name} couldn't be read as a plain file`);
260
521
  if (text !== null)
261
- writeFileSync(join(view, "info", "exclude"), text, { mode: 0o600 });
522
+ writeFileSync(join(view, "info", name), text, { mode: 0o600 });
262
523
  }
263
- for (const name of readdirSync(dirs.common)) {
264
- if (name === "config" || name === "info" || name === "hooks")
524
+ if (kind === "write") {
525
+ const folder = join(dirs.common, "worktrees");
526
+ if (!existsSync(folder)) {
527
+ try {
528
+ mkdirSync(folder);
529
+ }
530
+ catch { /* git reports it if it needs the folder */ }
531
+ }
532
+ try {
533
+ for (const id of readdirSync(folder))
534
+ priorWorktrees.add(id);
535
+ }
536
+ catch { /* none yet */ }
537
+ }
538
+ for (const name of new Set([...readdirSync(dirs.common), ...COMMON_NAMES])) {
539
+ if (name === "config" || name === "info" || name === "hooks" || name === "commondir")
265
540
  continue;
266
541
  symlinkSync(join(dirs.common, name), join(view, name));
267
542
  }
543
+ // GIT_DIR is the plane's too (git's ref store reads `commondir` from GIT_DIR as it goes, empirically on 2.50.1):
544
+ // its `commondir` names the view and its `gitdir` this checkout, both the plane's; HEAD is a copy (git won't take
545
+ // it as a link) carried back after the run; the worktree's other files are linked (the index and logs even before
546
+ // they exist), so git's lockfiles write them in place.
547
+ // (A folder under a dangling link can't be made by git: the worktree's `logs` is made first, as git would.)
548
+ if (kind === "write" && !existsSync(join(dirs.gitdir, "logs"))) {
549
+ try {
550
+ mkdirSync(join(dirs.gitdir, "logs"));
551
+ }
552
+ catch { /* git reports it */ }
553
+ }
554
+ // Only what git needs of the worktree's own folder is linked (ADMIN_LINKED, and a split index's shared files), each
555
+ // checked to be a real entry directly in it, never a link (Codex review of a0fa135d).
556
+ const linked = [...ADMIN_LINKED, ...readdirSync(dirs.gitdir).filter(name => name.startsWith("sharedindex."))];
557
+ for (const name of new Set(linked)) {
558
+ const path = join(dirs.gitdir, name);
559
+ let held = null;
560
+ try {
561
+ held = lstatSync(path);
562
+ }
563
+ catch {
564
+ held = null;
565
+ }
566
+ if (held !== null && (held.isSymbolicLink() || dirname(realpathSync(path)) !== dirs.gitdir))
567
+ throw new Error(`the checkout's git directory has ${name} as a link`);
568
+ symlinkSync(path, join(admin, name));
569
+ initial.set(name, null);
570
+ }
571
+ const head = regularFile(join(dirs.gitdir, "HEAD"));
572
+ if (head === null)
573
+ throw new Error("the checkout's HEAD couldn't be read as a plain file");
574
+ // The worktree's own state files (WORKTREE_STATE: HEAD, a merge's, cherry-pick's, rebase's or bisect's pseudo-refs
575
+ // and messages) are copies (git won't take them as links), carried back after the run; its state folders, when
576
+ // there, are linked.
577
+ for (const name of WORKTREE_STATE) {
578
+ const path = join(dirs.gitdir, name);
579
+ if (!existsSync(path) && name !== "HEAD")
580
+ continue;
581
+ const text = name === "HEAD" ? head : regularFile(path);
582
+ if (text === null)
583
+ throw new Error(`the checkout's ${name} couldn't be read as a plain file`);
584
+ writeFileSync(join(admin, name), text, { mode: 0o600 });
585
+ initial.set(name, text);
586
+ }
587
+ for (const name of WORKTREE_STATE_DIRS) {
588
+ const path = join(dirs.gitdir, name);
589
+ let held = null;
590
+ try {
591
+ held = lstatSync(path);
592
+ }
593
+ catch {
594
+ continue;
595
+ }
596
+ if (held.isSymbolicLink() || !held.isDirectory())
597
+ throw new Error(`the checkout's git directory has ${name} as a link`);
598
+ symlinkSync(path, join(admin, name));
599
+ initial.set(name, null);
600
+ }
601
+ writeFileSync(join(admin, "commondir"), `${view}\n`, { mode: 0o600 });
602
+ writeFileSync(join(admin, "gitdir"), `${join(dirs.checkout, ".git")}\n`, { mode: 0o600 });
603
+ // A main checkout's own info (its sparse-checkout) is the view's frozen one; a linked one's is its own.
604
+ symlinkSync(dirs.linked ? join(dirs.gitdir, "info") : join(view, "info"), join(admin, "info"));
605
+ if (!pinned(dirs).ok)
606
+ throw new Error("the checkout's repository has a folder that is a link");
268
607
  }
269
608
  catch (error) {
270
609
  dispose();
271
610
  throw error;
272
611
  }
273
- // Where git names the view (rev-parse --git-common-dir), the caller is told the repository's own common directory.
274
- const restore = (output) => output.split("\n").map(line => line === view || line.startsWith(`${view}/`) ? dirs.common + line.slice(view.length) : line).join("\n");
275
- return { env: { GIT_DIR: dirs.gitdir, GIT_COMMON_DIR: view, GIT_WORK_TREE: resolve(checkout) }, restore, dispose };
612
+ // The folders down to the worktree's own, as they are now: carry-back writes only while each is still the same one.
613
+ let anchor;
614
+ try {
615
+ anchor = anchorOf(dirs.gitdir);
616
+ }
617
+ catch {
618
+ dispose();
619
+ throw new Error("the checkout's repository has a folder that is a link");
620
+ }
621
+ // Where git names a view (rev-parse --git-dir, --git-common-dir), the caller is told the repository's own folder;
622
+ // where `worktree list` names the common view as the main worktree, that worktree (git's rule: the common dir less
623
+ // `/.git`).
624
+ const mainWorktree = dirname(dirs.common);
625
+ const restore = (output, args) => {
626
+ if (!output.includes(view) && !output.includes(admin))
627
+ return output;
628
+ const asWorktree = args.includes("worktree");
629
+ return output.split(admin).join(dirs.gitdir).split(`${view}/`).join(`${dirs.common}/`).split(view).join(asWorktree ? mainWorktree : dirs.common);
630
+ };
631
+ const settle = () => {
632
+ const now = pinned(dirs);
633
+ let bound = false;
634
+ try {
635
+ const again = boundDirs(checkout, binding);
636
+ bound = again.gitdir === dirs.gitdir && again.common === dirs.common;
637
+ }
638
+ catch {
639
+ bound = false;
640
+ }
641
+ if (!now.ok || !bound) {
642
+ // Too late to prevent: recorded and told (git-incident.ts), never rolled back where the swap leads.
643
+ if (kind === "write") {
644
+ gitIncident({ checkout: dirs.checkout, repository: dirs.common, swapped: now.swapped.length > 0 ? now.swapped.map(({ path, leadsTo }) => ({ path, leadsTo })) : [{ path: join(dirs.checkout, ".git"), leadsTo: null }],
645
+ record: join(dirname(ensureGitViewsRoot(realpathSync(homedir()))), "git-incidents.jsonl") });
646
+ }
647
+ return "the checkout's repository changed while git ran";
648
+ }
649
+ if (kind === "write") {
650
+ // The worktree's own state git rewrote in the plane's GIT_DIR goes back to it, and what it removed is removed
651
+ // there; anything else git left there the plane can't keep, and says so.
652
+ const left = readdirSync(admin).filter(name => !initial.has(name) && !WORKTREE_STATE.includes(name) && !["commondir", "gitdir", "info"].includes(name));
653
+ try {
654
+ carryBack(admin, dirs.gitdir, initial, anchor);
655
+ }
656
+ catch (error) {
657
+ if (!(error instanceof AnchorMoved))
658
+ return "the checkout's git state couldn't be carried back after git ran";
659
+ gitIncident({ checkout: dirs.checkout, repository: dirs.common, swapped: [{ path: error.message, leadsTo: null }], record: join(dirname(ensureGitViewsRoot(realpathSync(homedir()))), "git-incidents.jsonl") });
660
+ return "the checkout's repository changed while git ran";
661
+ }
662
+ if (left.length > 0)
663
+ return `git left state Toolroll can't keep in the checkout (${left.slice(0, 5).join(", ")})`;
664
+ const relinked = relinkAdded(view, dirs.common, priorWorktrees);
665
+ if (relinked !== null)
666
+ return relinked;
667
+ }
668
+ return null;
669
+ };
670
+ return { env: { GIT_DIR: admin, GIT_COMMON_DIR: view, GIT_WORK_TREE: dirs.checkout }, restore, dispose, dirs, settle };
671
+ }
672
+ /** A worktree's own state files git may write (git's per-worktree names: HEAD and the pseudo-refs and messages of a
673
+ * merge, cherry-pick, revert, rebase or bisect), and its state folders. */
674
+ const WORKTREE_STATE = ["HEAD", "ORIG_HEAD", "MERGE_HEAD", "MERGE_MSG", "MERGE_MODE", "MERGE_RR", "AUTO_MERGE", "FETCH_HEAD", "COMMIT_EDITMSG",
675
+ "CHERRY_PICK_HEAD", "REVERT_HEAD", "REBASE_HEAD", "SQUASH_MSG", "TAG_EDITMSG", "BISECT_LOG", "BISECT_START", "BISECT_TERMS", "BISECT_EXPECTED_REV",
676
+ "BISECT_ANCESTORS_OK", "BISECT_NAMES", "BISECT_RUN", "BISECT_HEAD"];
677
+ const WORKTREE_STATE_DIRS = ["sequencer", "rebase-merge", "rebase-apply"];
678
+ /**
679
+ * Whether every path git may write through a view is still the repository's own (Codex review of a0fa135d: git
680
+ * writes deeper than the top-level folders, through a link at `refs/heads/x`, `logs/…` or `objects/xx` as well). The
681
+ * walk, by lstat, never following a link: every name of the common folder git may write (COMMON_NAMES: refs, logs,
682
+ * reftable, packed-refs, worktrees with each worktree's own folder, rr-cache, modules, …) all the way down, HEAD and
683
+ * the index; the worktree's own state (WORKTREE_STATE: its HEAD, index, refs, logs, a merge's, rebase's or bisect's
684
+ * state, a split index's shared files); and the object store (the common `objects`, and a submodule git directory's
685
+ * own under modules/): its fan-out folders' entries for links, `info` and `pack` all the way down, but not each loose
686
+ * object (a big repository has very many, and hard-linked objects are legitimate). Any link is refused, so is a folder
687
+ * that can't be listed, and so is any file walked deep with a second hard link (git appends to a log in place). The
688
+ * exemption is by place only: an `objects` anywhere else (under refs/, logs/, worktrees/) is walked like the rest. For each one found, where it leads (for an incident's record).
689
+ *
690
+ * Checked when the view is made and again after git ran; between the two, a process that outlived the agent's turn
691
+ * can still swap one of these for a link and have git write through it mid-command. That window is an accepted
692
+ * residual: git takes paths, not handles. Seen after the run, it is an incident (git-incident.ts).
693
+ */
694
+ function pinned(dirs) {
695
+ const swapped = [];
696
+ const seen = new Set();
697
+ let budget = 400_000;
698
+ const stores = objectStores(dirs.common);
699
+ /** `deep`: every entry all the way down, each file held to one hard link. `shallow`: the folder's own entries
700
+ * checked for links only. `store`: an object store (objects/: its fan-out folders shallow, `info` and `pack` deep
701
+ * without the hard-link test). A link, or a folder that can't be listed, is swapped. */
702
+ const check = (path, mode) => {
703
+ if (seen.has(path))
704
+ return;
705
+ if (budget-- <= 0)
706
+ return;
707
+ seen.add(path);
708
+ let held;
709
+ try {
710
+ held = lstatSync(path);
711
+ }
712
+ catch {
713
+ return;
714
+ }
715
+ if (held.isSymbolicLink()) {
716
+ let real = null;
717
+ try {
718
+ real = realpathSync(path);
719
+ }
720
+ catch {
721
+ real = null;
722
+ }
723
+ swapped.push({ path, leadsTo: real });
724
+ return;
725
+ }
726
+ if (held.isFile() && mode === "deep" && held.nlink > 1) {
727
+ swapped.push({ path, leadsTo: null });
728
+ return;
729
+ }
730
+ if (!held.isDirectory())
731
+ return;
732
+ let entries;
733
+ // A folder that can't be listed hides what's in it: it counts as swapped (fail closed).
734
+ try {
735
+ entries = readdirSync(path);
736
+ }
737
+ catch {
738
+ swapped.push({ path, leadsTo: null });
739
+ return;
740
+ }
741
+ for (const name of entries) {
742
+ const child = join(path, name);
743
+ if (mode === "shallow") {
744
+ try {
745
+ if (lstatSync(child).isSymbolicLink())
746
+ check(child, "shallow");
747
+ }
748
+ catch { /* gone */ }
749
+ continue;
750
+ }
751
+ if (mode === "store") {
752
+ check(child, name === "info" || name === "pack" ? "deep-unlinked" : "shallow");
753
+ continue;
754
+ }
755
+ // The object store exemption by exact place, never by name (stores, enumerated before the walk).
756
+ check(child, stores.has(child) ? "store" : mode);
757
+ }
758
+ };
759
+ // The common folder's every name git may write, all the way down (refs, logs, reftable, packed-refs, worktrees with
760
+ // each worktree's own folder, rr-cache, modules, the rest of COMMON_NAMES), and the worktree's own state.
761
+ for (const name of COMMON_NAMES)
762
+ if (name !== "objects")
763
+ check(join(dirs.common, name), "deep");
764
+ for (const name of ["HEAD", "index"])
765
+ check(join(dirs.common, name), "deep");
766
+ for (const name of [...WORKTREE_STATE, ...WORKTREE_STATE_DIRS, "index", "refs", "logs", "reftable"])
767
+ check(join(dirs.gitdir, name), "deep");
768
+ try {
769
+ for (const name of readdirSync(dirs.gitdir))
770
+ if (name.startsWith("sharedindex."))
771
+ check(join(dirs.gitdir, name), "deep");
772
+ }
773
+ catch {
774
+ swapped.push({ path: dirs.gitdir, leadsTo: null });
775
+ }
776
+ // The object store: the folder and its fan-out folders for links (not each loose object: a big repository has very
777
+ // many, and `clone --local` hard-links them legitimately; git writes a new object to a fresh temporary file linked
778
+ // into place, never through an existing one), `info` and `pack` all the way down.
779
+ for (const store of stores)
780
+ check(store, "store");
781
+ if (budget <= 0)
782
+ swapped.push({ path: dirs.common, leadsTo: null });
783
+ // A path that is no longer its own real path (a folder on the way swapped) counts too.
784
+ for (const path of [dirs.common, dirs.gitdir]) {
785
+ try {
786
+ if (realpathSync(path) !== path)
787
+ swapped.push({ path, leadsTo: realpathSync(path) });
788
+ }
789
+ catch {
790
+ swapped.push({ path, leadsTo: null });
791
+ }
792
+ }
793
+ return { ok: swapped.length === 0, swapped };
794
+ }
795
+ /**
796
+ * The object stores the walk exempts from its full check, enumerated before it, never decided by a name met on the
797
+ * way: the common `objects`, and `<gitdir>/objects` for each submodule git directory under modules/ — a folder with
798
+ * its own config, HEAD, refs/ and objects/, reached through no component named refs, logs, reftable, objects, info,
799
+ * worktrees or hooks (so `modules/x/refs/objects` with a planted HEAD is no store).
800
+ */
801
+ function objectStores(common) {
802
+ const stores = new Set([join(common, "objects")]);
803
+ const NOT_A_PATH = new Set(["refs", "logs", "reftable", "objects", "info", "worktrees", "hooks"]);
804
+ const visit = (dir, depth) => {
805
+ if (depth > 8)
806
+ return;
807
+ let entries = [];
808
+ try {
809
+ entries = readdirSync(dir, { withFileTypes: true });
810
+ }
811
+ catch {
812
+ return;
813
+ }
814
+ for (const entry of entries) {
815
+ if (!entry.isDirectory() || NOT_A_PATH.has(entry.name))
816
+ continue;
817
+ const at = join(dir, entry.name);
818
+ const own = (name, folder) => { try {
819
+ const held = lstatSync(join(at, name));
820
+ return folder ? held.isDirectory() : held.isFile();
821
+ }
822
+ catch {
823
+ return false;
824
+ } };
825
+ if (own("config", false) && own("HEAD", false) && own("refs", true) && own("objects", true)) {
826
+ stores.add(join(at, "objects"));
827
+ visit(join(at, "modules"), depth + 1);
828
+ }
829
+ else
830
+ visit(at, depth + 1);
831
+ }
832
+ };
833
+ visit(join(common, "modules"), 0);
834
+ return stores;
835
+ }
836
+ function anchorOf(path) {
837
+ const anchor = [];
838
+ for (let at = path;; at = dirname(at)) {
839
+ const held = lstatSync(at);
840
+ if (held.isSymbolicLink() || !held.isDirectory())
841
+ throw new AnchorMoved(at);
842
+ anchor.unshift({ path: at, dev: held.dev, ino: held.ino });
843
+ if (dirname(at) === at)
844
+ return anchor;
845
+ }
276
846
  }
847
+ class AnchorMoved extends Error {
848
+ }
849
+ /** Throws AnchorMoved unless every folder of `anchor` is still the one it was (Node has no openat, so each component
850
+ * is looked at again right before each rename or unlink). */
851
+ function holdAnchor(anchor) {
852
+ for (const one of anchor) {
853
+ let held;
854
+ try {
855
+ held = lstatSync(one.path);
856
+ }
857
+ catch {
858
+ throw new AnchorMoved(one.path);
859
+ }
860
+ if (held.isSymbolicLink() || !held.isDirectory() || held.dev !== one.dev || held.ino !== one.ino)
861
+ throw new AnchorMoved(one.path);
862
+ }
863
+ }
864
+ /** Carry the WORKTREE_STATE files git rewrote or removed in the plane's GIT_DIR back to the worktree's own folder: a
865
+ * new file of a random name, created exclusively (never through a link), then renamed into place; before each rename
866
+ * or unlink, every folder down to the worktree's own is proven the same one it was when the view was made. */
867
+ function carryBack(admin, gitdir, initial, anchor) {
868
+ for (const name of WORKTREE_STATE) {
869
+ const here = join(admin, name);
870
+ let held = null;
871
+ try {
872
+ held = lstatSync(here);
873
+ }
874
+ catch {
875
+ held = null;
876
+ }
877
+ const was = initial.get(name);
878
+ if (held === null) {
879
+ // Removed by git: removed in the worktree's own folder too (a merge's state once it concluded).
880
+ if (initial.get(name) != null) {
881
+ holdAnchor(anchor);
882
+ rmSync(join(gitdir, name), { force: true });
883
+ }
884
+ continue;
885
+ }
886
+ if (held.isSymbolicLink() || !held.isFile())
887
+ continue;
888
+ const text = readFileSync(here);
889
+ if (was !== undefined && was !== null && text.toString("utf8") === was)
890
+ continue;
891
+ holdAnchor(anchor);
892
+ const temporary = join(gitdir, `.toolroll-${randomBytes(16).toString("hex")}`);
893
+ const fd = openSync(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
894
+ try {
895
+ writeSync(fd, text);
896
+ }
897
+ finally {
898
+ closeSync(fd);
899
+ }
900
+ try {
901
+ holdAnchor(anchor);
902
+ renameSync(temporary, join(gitdir, name));
903
+ }
904
+ catch (error) {
905
+ rmSync(temporary, { force: true });
906
+ throw error;
907
+ }
908
+ }
909
+ }
910
+ /**
911
+ * A worktree `worktree add` made through a view, pointed at its repository instead of the view. Before git 2.48 the new
912
+ * checkout's `.git` file names the common directory as given (the view, removed right after the run: every later
913
+ * command in that checkout would find no repository); later versions name its real path, and this finds nothing to do.
914
+ * Only a worktree folder this run made (not there when the view was made) whose checkout's `.git` is a plain file
915
+ * reading exactly `gitdir: <view>/worktrees/<id>` (a name private to this run, in a folder every fence denies) is
916
+ * rewritten: a new file created exclusively (never through a link) and renamed into place, then bound to the repository
917
+ * (boundDirs) as any checkout is. Before it writes, the repository's own record of the worktree must name this very
918
+ * checkout and repository, and every folder down to the checkout is anchored (anchorOf) and proven the same one before
919
+ * the open, the re-read and the rename, as carryBack does. A failure after the rename isn't undone (the old content
920
+ * names a view that no longer exists, so the checkout is unusable either way); it refuses. Null when every such
921
+ * checkout is bound, or why one isn't.
922
+ */
923
+ function relinkAdded(view, common, prior) {
924
+ let ids;
925
+ try {
926
+ ids = readdirSync(join(common, "worktrees")).filter(id => !prior.has(id));
927
+ }
928
+ catch {
929
+ return null;
930
+ }
931
+ for (const id of ids) {
932
+ const named = regularFile(join(common, "worktrees", id, "gitdir"))?.trim();
933
+ if (named === undefined || !named.startsWith("/") || basename(named) !== ".git")
934
+ continue;
935
+ const stale = `gitdir: ${join(view, "worktrees", id)}`;
936
+ let folder;
937
+ try {
938
+ folder = realpathSync(dirname(named));
939
+ }
940
+ catch {
941
+ continue;
942
+ }
943
+ const dotgit = join(folder, ".git");
944
+ const read = () => { try {
945
+ return lstatSync(dotgit).isFile() ? regularFile(dotgit) : null;
946
+ }
947
+ catch {
948
+ return null;
949
+ } };
950
+ if (read()?.trim() !== stale)
951
+ continue;
952
+ // The repository's record of this worktree names this checkout and this repository, before anything is written.
953
+ const admin = join(common, "worktrees", id);
954
+ let recorded;
955
+ try {
956
+ const commondir = regularFile(join(admin, "commondir"))?.trim();
957
+ recorded = lstatSync(admin).isDirectory() && commondir !== undefined && commondir !== ""
958
+ && realpathSync(resolve(admin, commondir)) === realpathSync(common) && realpathSync(named) === dotgit;
959
+ }
960
+ catch {
961
+ recorded = false;
962
+ }
963
+ if (!recorded)
964
+ return "the new checkout's record in its repository doesn't name it";
965
+ let anchor;
966
+ try {
967
+ anchor = anchorOf(folder);
968
+ }
969
+ catch {
970
+ return "the new checkout's folder changed before it could be pointed at its repository";
971
+ }
972
+ const temporary = join(folder, `.toolroll-${randomBytes(16).toString("hex")}`);
973
+ try {
974
+ holdAnchor(anchor);
975
+ const fd = openSync(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o644);
976
+ try {
977
+ writeSync(fd, `gitdir: ${admin}\n`);
978
+ }
979
+ finally {
980
+ closeSync(fd);
981
+ }
982
+ // Checked again at the moment of use: the same folders, and still the plain file git wrote.
983
+ holdAnchor(anchor);
984
+ if (read()?.trim() !== stale) {
985
+ rmSync(temporary, { force: true });
986
+ return "the new checkout's .git changed before it could be pointed at its repository";
987
+ }
988
+ holdAnchor(anchor);
989
+ renameSync(temporary, dotgit);
990
+ }
991
+ catch {
992
+ rmSync(temporary, { force: true });
993
+ return "the new checkout's .git couldn't be pointed at its repository";
994
+ }
995
+ try {
996
+ boundDirs(folder, { repo: dirname(common) });
997
+ }
998
+ catch (error) {
999
+ return error instanceof Error ? error.message : "the new checkout isn't bound to its repository";
1000
+ }
1001
+ }
1002
+ return null;
1003
+ }
1004
+ /** The checkout git would find from `cwd`: the nearest folder, `cwd` or above, with a `.git`; null when there is
1005
+ * none (git's own "not a git repository"). A `.git` that can't even be looked at counts as found (the view refuses it). */
1006
+ function checkoutRootOf(cwd) {
1007
+ for (let dir = resolve(cwd);; dir = dirname(dir)) {
1008
+ try {
1009
+ lstatSync(join(dir, ".git"));
1010
+ return dir;
1011
+ }
1012
+ catch (error) {
1013
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR")
1014
+ return dir;
1015
+ }
1016
+ if (dirname(dir) === dir)
1017
+ return null;
1018
+ }
1019
+ }
1020
+ const NOT_A_REPOSITORY = { code: 128, stdout: "", stderr: "fatal: not a git repository (or any of the parent directories): .git\n", timedOut: false, notFound: false };
1021
+ /** Settings fixed on every write through a view: `worktree add` records absolute paths (relative ones would need a
1022
+ * config write, which a view keeps); no transport at all (a caller that fetches allows its one protocol by name, as the
1023
+ * nightly check's local fetch does with protocol.file.allow); and no split index (its shared index files would be
1024
+ * written in the plane's GIT_DIR). */
1025
+ export const WRITE_VIEW_FLAGS = ["-c", "worktree.useRelativePaths=false", "-c", "protocol.allow=never", "-c", "core.splitIndex=false"];
277
1026
  /** Whether the checkout's .lfsconfig (read as git parses it, that file only) sets any git-lfs key that could name a
278
1027
  * program (anything but INERT_LFS). A file that
279
1028
  * isn't a plain one, or doesn't parse, counts as naming one. */
@@ -287,12 +1036,14 @@ export function lfsExtensionIn(checkout) {
287
1036
  return false;
288
1037
  }
289
1038
  const parsed = spawnSync("git", ["config", "--file", path, "--no-includes", "--name-only", "--list", "-z"], { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
290
- return parsed.status !== 0 || parsed.stdout.split("\0").filter(Boolean).some(name => lfsCommand(name.toLowerCase()) || /^include(if)?\./i.test(name));
1039
+ return parsed.status !== 0 || parsed.stdout.split("\0").filter(Boolean).some(name => lfsCommand(name) || ["include", "includeif"].includes(keyParts(name).section));
291
1040
  }
292
- /** `mode` "read" is for reading a checkout an agent may still be writing (the code page): every command also takes
293
- * readFlags() and runs against the checkout's readView, so neither its config nor its info/attributes can change
294
- * between the inspection and the run. */
295
- export function checkoutGit(inner = run, mode = "build") {
1041
+ /** Every command runs against a view of the checkout's repository (gitView) whose config and info are copies the
1042
+ * plane made, inspected there, so neither can change between the inspection and the run. `mode` "read" is for reading
1043
+ * a checkout an agent may still be writing (the code page): every command also takes readFlags(), and the view holds
1044
+ * no info/attributes. "build" is the plane's staging and committing of a build's work, which writes the repository
1045
+ * through the view (gitView). */
1046
+ export function checkoutGit(inner = run, mode = "build", binding = {}) {
296
1047
  return async (file, args, options) => {
297
1048
  if (file !== "git")
298
1049
  return inner(file, args, options);
@@ -302,39 +1053,51 @@ export function checkoutGit(inner = run, mode = "build") {
302
1053
  // git-lfs also reads .lfsconfig from the checkout: one that names an extension (a command it would run) is refused.
303
1054
  if (lfsExtensionIn(cwd))
304
1055
  return refused("the checkout's .lfsconfig sets a git-lfs key that can run a command");
305
- let view = null;
306
- if (mode === "read") {
1056
+ const once = async () => {
1057
+ let view;
307
1058
  try {
308
- view = readView(cwd);
1059
+ view = gitView(cwd, mode === "read" ? "read" : "write", binding);
309
1060
  }
310
1061
  catch (error) {
311
1062
  return refused(error instanceof Error ? error.message : "the checkout couldn't be read");
312
1063
  }
313
- }
1064
+ try {
1065
+ const viewed = { ...options, env: { ...(options?.env ?? {}), ...view.env } };
1066
+ // The checkout's own config may name no command.
1067
+ const own = await inner("git", ["--no-optional-locks", "config", "--list", "--show-scope", "-z"], viewed);
1068
+ if (own.code !== 0)
1069
+ return refused("its config couldn't be read");
1070
+ const key = commandSetting(own.stdout, { lfsChecked: true });
1071
+ if (key !== null)
1072
+ return refused(`the checkout's git config sets ${key}, which can run a command`);
1073
+ // The operator's own config contributes values only (carriedSettings); git then reads no global or system config.
1074
+ // A read carries no filter: it reads attributes from the empty tree anyway.
1075
+ const carried = mode === "read" ? withoutFilters(carriedSettings(own.stdout)) : carriedSettings(own.stdout);
1076
+ const env = { ...viewed.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" };
1077
+ // Never into a submodule or nested repository, whose own config this never read: status and diff skip them.
1078
+ const safeArgs = [...args];
1079
+ const command = safeArgs.findIndex(arg => arg === "status" || arg === "diff");
1080
+ if (command >= 0 && !safeArgs.includes("--ignore-submodules=all"))
1081
+ safeArgs.splice(command + 1, 0, "--ignore-submodules=all");
1082
+ const flags = mode === "read" ? readFlags() : WRITE_VIEW_FLAGS;
1083
+ const ran = await inner("git", [...TEARDOWN_FLAGS, ...flags, "-c", "diff.ignoreSubmodules=all", "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", ...carried, ...safeArgs], { ...options, env });
1084
+ const why = view.settle();
1085
+ if (why !== null)
1086
+ return refused(why);
1087
+ return { ...ran, stdout: view.restore(ran.stdout, args), stderr: view.restore(ran.stderr, args) };
1088
+ }
1089
+ finally {
1090
+ view.dispose();
1091
+ }
1092
+ };
1093
+ if (mode !== "build")
1094
+ return once();
1095
+ // A write, one at a time on a checkout (git-views.ts withCheckoutLock: re-entrant along one call chain).
314
1096
  try {
315
- const viewed = { ...options, env: { ...(options?.env ?? {}), ...(view?.env ?? {}) } };
316
- // The checkout's own config may name no command.
317
- const own = await inner("git", ["--no-optional-locks", "config", "--list", "--show-scope", "-z"], view === null ? options : viewed);
318
- if (own.code !== 0)
319
- return refused("its config couldn't be read");
320
- const key = commandSetting(own.stdout);
321
- if (key !== null)
322
- return refused(`the checkout's git config sets ${key}, which can run a command`);
323
- // The operator's own config contributes values only (carriedSettings); git then reads no global or system config.
324
- // A read carries no filter: it reads attributes from the empty tree anyway.
325
- const carried = view === null ? carriedSettings(own.stdout) : withoutFilters(carriedSettings(own.stdout));
326
- const env = { ...viewed.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" };
327
- // Never into a submodule or nested repository, whose own config this never read: status and diff skip them.
328
- const safeArgs = [...args];
329
- const command = safeArgs.findIndex(arg => arg === "status" || arg === "diff");
330
- if (command >= 0 && !safeArgs.includes("--ignore-submodules=all"))
331
- safeArgs.splice(command + 1, 0, "--ignore-submodules=all");
332
- const flags = view === null ? [] : readFlags();
333
- const ran = await inner("git", [...TEARDOWN_FLAGS, ...flags, "-c", "diff.ignoreSubmodules=all", "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", ...carried, ...safeArgs], { ...options, env });
334
- return view !== null && args.includes("rev-parse") ? { ...ran, stdout: view.restore(ran.stdout) } : ran;
1097
+ return await withCheckoutLock(realpathSync(homedir()), realpathSync(cwd), once);
335
1098
  }
336
- finally {
337
- view?.dispose();
1099
+ catch (error) {
1100
+ return refused(error instanceof Error ? error.message : "the checkout is busy");
338
1101
  }
339
1102
  };
340
1103
  }
@@ -352,7 +1115,7 @@ export const PAGE_READ_FLAGS = [
352
1115
  /** The read flags, for every read of an agent-written checkout, whatever the HTTP method (security review of
353
1116
  * 01bf3114): the worktree's attributes come from the empty tree. Git still reads the repository's info/attributes and
354
1117
  * its config as it starts, so the flags alone don't stop a filter planted in both after the inspection: every read
355
- * also runs against the checkout's readView (checkoutGitSync, checkoutGit "read"), whose config and info are the plane's. The cost is cosmetic: a checkout's line-ending or encoding rules are not applied, so
1118
+ * also runs against a view of the checkout's repository (gitView; checkoutGitSync, checkoutGit "read"), whose config and info are the plane's. The cost is cosmetic: a checkout's line-ending or encoding rules are not applied, so
356
1119
  * a file they would normalize can read as changed (fail closed: shipping then asks for a clean commit). */
357
1120
  export function readFlags() {
358
1121
  return PAGE_READ_FLAGS;
@@ -360,7 +1123,7 @@ export function readFlags() {
360
1123
  /**
361
1124
  * checkoutGit, synchronously, for reads a page shows from a checkout an agent writes (docs/plans/get-side-effects.md):
362
1125
  * the same refusals (an include, an .lfsconfig extension, a config key that can run a command), no system or global
363
- * config, the operator's own values carried, PAGE_READ_FLAGS and the checkout's readView (which hold even if its config
1126
+ * config, the operator's own values carried, PAGE_READ_FLAGS and a view of the checkout's repository (gitView: they hold even if its config
364
1127
  * or info/attributes change after they were read), submodules skipped, bounded. A refusal throws.
365
1128
  */
366
1129
  export function checkoutGitSync(cwd, args, options = {}) {
@@ -369,14 +1132,14 @@ export function checkoutGitSync(cwd, args, options = {}) {
369
1132
  if (lfsExtensionIn(cwd))
370
1133
  throw new Error("the checkout's .lfsconfig sets a git-lfs key that can run a command");
371
1134
  // Its config and info/attributes as read once, in a folder of the plane's own: neither can change before git runs.
372
- const view = readView(cwd);
1135
+ const view = gitView(cwd, "read", options.binding);
373
1136
  try {
374
1137
  const env = { PATH: process.env["PATH"] ?? "", HOME: process.env["HOME"] ?? "", LANG: "C", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0", ...view.env };
375
1138
  const bounded = { cwd, env, encoding: "utf8", timeout: options.timeoutMs ?? 60_000, maxBuffer: options.maxBuffer ?? 1_048_576, stdio: ["ignore", "pipe", "pipe"] };
376
1139
  const own = spawnSync("git", ["--no-optional-locks", "config", "--list", "--show-scope", "-z"], bounded);
377
1140
  if (own.status !== 0)
378
1141
  throw new Error("its config couldn't be read");
379
- const key = commandSetting(own.stdout);
1142
+ const key = commandSetting(own.stdout, { lfsChecked: true });
380
1143
  if (key !== null)
381
1144
  throw new Error(`the checkout's git config sets ${key}, which can run a command`);
382
1145
  const safeArgs = [...args];
@@ -391,9 +1154,32 @@ export function checkoutGitSync(cwd, args, options = {}) {
391
1154
  const ran = spawnSync("git", [...TEARDOWN_FLAGS, ...readFlags(), "-c", "diff.ignoreSubmodules=all", ...carried, ...safeArgs], bounded);
392
1155
  if (ran.status !== 0)
393
1156
  throw new Error(`git ${args[0] ?? ""} failed`);
394
- return args.includes("rev-parse") ? view.restore(ran.stdout) : ran.stdout;
1157
+ const why = view.settle();
1158
+ if (why !== null)
1159
+ throw new Error(why);
1160
+ return view.restore(ran.stdout, args);
395
1161
  }
396
1162
  finally {
397
1163
  view.dispose();
398
1164
  }
399
1165
  }
1166
+ /**
1167
+ * checkoutGit ("build": the repository written through its view) for callers that name the repository with a leading
1168
+ * `git -C <dir>` instead of a cwd: worktree add and remove in a project's repository, a merge in a checkout of it.
1169
+ * The project's repository is shared with every build, which can write its config, info and hooks; nothing here runs
1170
+ * what they name. Anything but git goes to `inner` as given.
1171
+ */
1172
+ export function hardenedGit(inner = run, mode = "build", binding = {}) {
1173
+ const hardened = checkoutGit(inner, mode, binding);
1174
+ return (file, args, options) => {
1175
+ if (file !== "git")
1176
+ return inner(file, args, options);
1177
+ let cwd = options?.cwd;
1178
+ let rest = [...args];
1179
+ while (rest[0] === "-C" && rest.length > 1) {
1180
+ cwd = resolve(cwd ?? process.cwd(), rest[1]);
1181
+ rest = rest.slice(2);
1182
+ }
1183
+ return hardened("git", rest, { ...options, ...(cwd === undefined ? {} : { cwd }) });
1184
+ };
1185
+ }