toolroll 0.9.75 → 0.9.77

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 (129) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +11 -4
  3. package/THIRD_PARTY_NOTICES.md +5 -5
  4. package/dist/agent-fence.js +36 -8
  5. package/dist/approval-authority.js +6 -2
  6. package/dist/approval-rules-ui.d.ts +11 -0
  7. package/dist/approval-rules-ui.js +13 -1
  8. package/dist/assignment.js +3 -0
  9. package/dist/batch-checks.js +3 -1
  10. package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
  11. package/dist/browser/workspace.css +2 -2
  12. package/dist/browser/workspace.js +31 -31
  13. package/dist/builder.d.ts +14 -0
  14. package/dist/builder.js +141 -42
  15. package/dist/chat-decide.js +4 -0
  16. package/dist/check-failure.d.ts +92 -0
  17. package/dist/check-failure.js +267 -0
  18. package/dist/check-progress.d.ts +28 -1
  19. package/dist/check-progress.js +105 -6
  20. package/dist/check-report.d.ts +52 -6
  21. package/dist/check-report.js +193 -8
  22. package/dist/checkout-cleanup.js +2 -2
  23. package/dist/cli.js +8 -5
  24. package/dist/coding-handoff.d.ts +1 -0
  25. package/dist/coding-handoff.js +20 -11
  26. package/dist/coding-workspace.js +7 -5
  27. package/dist/commit-extract.d.ts +6 -0
  28. package/dist/commit-extract.js +63 -8
  29. package/dist/console-chrome.d.ts +41 -0
  30. package/dist/console-chrome.js +166 -0
  31. package/dist/contracts/console-api.d.ts +1 -0
  32. package/dist/contracts/console-api.js +1 -0
  33. package/dist/contracts/release-check.d.ts +12 -0
  34. package/dist/contracts/release-check.js +17 -0
  35. package/dist/contracts/verification-receipt.d.ts +20 -2
  36. package/dist/contracts/verification-receipt.js +15 -3
  37. package/dist/contracts/verification-view.d.ts +8 -0
  38. package/dist/controller-supervisor.d.ts +8 -0
  39. package/dist/controller-supervisor.js +33 -1
  40. package/dist/demo.js +3 -2
  41. package/dist/deploy-gate.d.ts +3 -0
  42. package/dist/deploy-gate.js +8 -2
  43. package/dist/desktop-update.d.ts +1 -1
  44. package/dist/discover.js +6 -1
  45. package/dist/dispose.d.ts +3 -0
  46. package/dist/dispose.js +6 -3
  47. package/dist/doctor.d.ts +7 -0
  48. package/dist/doctor.js +10 -2
  49. package/dist/envelope.js +2 -1
  50. package/dist/evidence.js +4 -1
  51. package/dist/exec.d.ts +7 -0
  52. package/dist/exec.js +7 -1
  53. package/dist/first-run.js +3 -1
  54. package/dist/git-floor.d.ts +16 -0
  55. package/dist/git-floor.js +36 -0
  56. package/dist/git-incident.d.ts +49 -0
  57. package/dist/git-incident.js +69 -0
  58. package/dist/git-views.d.ts +20 -0
  59. package/dist/git-views.js +120 -0
  60. package/dist/grant.js +7 -1
  61. package/dist/guides.js +4 -3
  62. package/dist/operate.d.ts +13 -0
  63. package/dist/operate.js +779 -319
  64. package/dist/planner.js +5 -1
  65. package/dist/prepared-evidence.d.ts +2 -1
  66. package/dist/prepared-evidence.js +8 -3
  67. package/dist/project-knowledge.js +6 -2
  68. package/dist/project-learning.js +23 -3
  69. package/dist/proof.js +6 -0
  70. package/dist/pull-request-flow.js +4 -0
  71. package/dist/read-executor-worker.js +8 -4
  72. package/dist/read-executor.d.ts +18 -1
  73. package/dist/read-executor.js +8 -0
  74. package/dist/release-adapters.d.ts +71 -2
  75. package/dist/release-adapters.js +471 -12
  76. package/dist/release-authority.d.ts +46 -0
  77. package/dist/release-authority.js +68 -0
  78. package/dist/release-auto.d.ts +184 -0
  79. package/dist/release-auto.js +612 -0
  80. package/dist/release-card.d.ts +15 -3
  81. package/dist/release-card.js +25 -9
  82. package/dist/release-ci-hold.d.ts +41 -0
  83. package/dist/release-ci-hold.js +53 -0
  84. package/dist/release-cli.js +77 -14
  85. package/dist/release-coverage.js +5 -2
  86. package/dist/release-recheck.d.ts +1 -1
  87. package/dist/release-recheck.js +6 -1
  88. package/dist/release.d.ts +207 -6
  89. package/dist/release.js +495 -20
  90. package/dist/repository-context.js +4 -6
  91. package/dist/result-follow-ups.js +3 -1
  92. package/dist/review-context.js +8 -6
  93. package/dist/runner.js +2 -1
  94. package/dist/sandbox-runtime.d.ts +8 -0
  95. package/dist/sandbox-runtime.js +14 -2
  96. package/dist/scope.d.ts +31 -2
  97. package/dist/scope.js +51 -4
  98. package/dist/scout.js +5 -1
  99. package/dist/secret-mint.d.ts +1 -0
  100. package/dist/secret-mint.js +14 -0
  101. package/dist/serve.js +101 -51
  102. package/dist/server/chat.js +5 -5
  103. package/dist/server/render-tasks.d.ts +12 -0
  104. package/dist/server/render-tasks.js +25 -14
  105. package/dist/server/request-context.d.ts +6 -0
  106. package/dist/server/route-table.js +3 -0
  107. package/dist/server/runtime.d.ts +2 -0
  108. package/dist/server/settings.js +29 -2
  109. package/dist/server/task-views.js +15 -2
  110. package/dist/setup-guide.d.ts +2 -2
  111. package/dist/storage.d.ts +5 -0
  112. package/dist/storage.js +28 -1
  113. package/dist/store.d.ts +11 -1
  114. package/dist/store.js +44 -5
  115. package/dist/task-base.d.ts +4 -0
  116. package/dist/task-base.js +22 -2
  117. package/dist/teardown-git.d.ts +90 -13
  118. package/dist/teardown-git.js +1027 -129
  119. package/dist/test-infra.d.ts +26 -0
  120. package/dist/test-infra.js +321 -0
  121. package/dist/up-processes.d.ts +137 -0
  122. package/dist/up-processes.js +165 -0
  123. package/dist/up-role-child.d.ts +1 -0
  124. package/dist/up-role-child.js +77 -0
  125. package/dist/verification-evidence.d.ts +1 -0
  126. package/dist/verification-evidence.js +13 -1
  127. package/dist/worktree.d.ts +4 -0
  128. package/dist/worktree.js +13 -7
  129. package/package.json +2 -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 { accessSync, closeSync, constants as fsConstants, existsSync, fstatSync, 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, delimiter, dirname, isAbsolute, 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
@@ -166,29 +304,37 @@ export function teardownGit(runner = run) {
166
304
  * git at any file, a pipe that never ends among them. Each file is read only as the regular file it is, bounded; one
167
305
  * that can't be read so counts as including.
168
306
  */
169
- export function checkoutIncludes(checkout) {
307
+ export function checkoutIncludes(checkout, gitFile = "git") {
170
308
  const dirs = gitDirsOf(checkout);
171
309
  if (dirs === null)
172
310
  return true;
173
311
  for (const path of [join(dirs.common, "config"), join(dirs.gitdir, "config.worktree")]) {
174
312
  if (!existsSync(path) && path.endsWith("config.worktree"))
175
313
  continue;
176
- if (configIncludes(path))
314
+ if (includeIn(path, gitFile) !== null)
177
315
  return true;
178
316
  }
179
317
  return false;
180
318
  }
181
319
  /** One config file, read only as the regular file it is (bounded), or null. */
182
320
  function regularFile(path) {
321
+ // Opened without following a link and without blocking, then judged by what was opened: a FIFO swapped in after a
322
+ // look never hangs the read (eval-harness review 1, finding 7).
323
+ let fd = null;
183
324
  try {
184
- const held = lstatSync(path);
325
+ fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
326
+ const held = fstatSync(fd);
185
327
  if (!held.isFile() || held.size > 1024 * 1024)
186
328
  return null;
187
- return readFileSync(path, "utf8");
329
+ return readFileSync(fd, "utf8");
188
330
  }
189
331
  catch {
190
332
  return null;
191
333
  }
334
+ finally {
335
+ if (fd !== null)
336
+ closeSync(fd);
337
+ }
192
338
  }
193
339
  /** A checkout's git directory and common directory, as git finds them from its `.git` (a folder, or a one-line
194
340
  * `gitdir: ` file); null when the pointer isn't one git would read the same way. */
@@ -212,21 +358,130 @@ function gitDirsOf(checkout) {
212
358
  const named = regularFile(join(gitdir, "commondir"))?.trim();
213
359
  return { gitdir, common: named === undefined || named === "" ? gitdir : resolve(gitdir, named) };
214
360
  }
215
- /** Whether one config file includes another (or can't be read as a plain file): by a plain reading, and as git itself
216
- * parses it, includes not followed (only this one regular file, from a neutral folder, with no other config read). */
217
- function configIncludes(path) {
361
+ /** The include key one config file sets (lowercased, as git names it), "include" when only a plain reading finds one,
362
+ * or "an unreadable file" when it can't be read as a plain file or parsed; null when it includes nothing. */
363
+ function includeIn(path, gitFile = "git") {
218
364
  const text = regularFile(path);
219
- if (text === null || /^\s*\[\s*include(if)?\b/im.test(text))
220
- return true;
221
- 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 (text === null)
366
+ return "an unreadable file";
367
+ const parsed = spawnSync(gitFile, ["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" } });
368
+ if (parsed.status !== 0)
369
+ return "an unreadable file";
370
+ const named = parsed.stdout.split("\0").find(name => /^(include\.path|includeif\..*\.path)$/i.test(name));
371
+ if (named !== undefined)
372
+ return named.toLowerCase();
373
+ return /^\s*\[\s*include(if)?\b/im.test(text) ? "include" : null;
223
374
  }
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 });
375
+ function boundDirs(checkout, binding = {}) {
376
+ const fail = (why) => { throw new Error(`the checkout's .git ${why}`); };
377
+ let top;
378
+ try {
379
+ top = realpathSync(checkout);
380
+ }
381
+ catch {
382
+ return fail("couldn't be found");
383
+ }
384
+ const dotgit = join(top, ".git");
385
+ let held;
386
+ try {
387
+ held = lstatSync(dotgit);
388
+ }
389
+ catch {
390
+ return fail("couldn't be read");
391
+ }
392
+ let dirs;
393
+ if (held.isDirectory()) {
394
+ if (existsSync(join(dotgit, "commondir")))
395
+ fail("names another repository (commondir)");
396
+ dirs = { checkout: top, gitdir: dotgit, common: dotgit, linked: false };
397
+ }
398
+ else {
399
+ if (!held.isFile())
400
+ fail("is a link");
401
+ const named = gitDirsOf(top);
402
+ if (named === null)
403
+ return fail("couldn't be read as git reads it");
404
+ let gitdir;
405
+ try {
406
+ gitdir = realpathSync(named.gitdir);
407
+ }
408
+ catch {
409
+ return fail("names a git directory that isn't there");
410
+ }
411
+ const admin = lstatSync(gitdir);
412
+ if (!admin.isDirectory())
413
+ fail("names something other than a git directory");
414
+ const common = dirname(dirname(gitdir));
415
+ if (basename(dirname(gitdir)) !== "worktrees")
416
+ fail("names a folder that isn't one of its repository's worktrees");
417
+ const commondir = regularFile(join(gitdir, "commondir"))?.trim();
418
+ let named_ = null;
419
+ try {
420
+ named_ = commondir === undefined || commondir === "" ? null : realpathSync(resolve(gitdir, commondir));
421
+ }
422
+ catch {
423
+ named_ = null;
424
+ }
425
+ if (named_ !== common)
426
+ fail("belongs to a git directory whose commondir names another repository");
427
+ const back = regularFile(join(gitdir, "gitdir"))?.trim();
428
+ let backReal = null;
429
+ try {
430
+ backReal = back === undefined || back === "" ? null : realpathSync(resolve(gitdir, back));
431
+ }
432
+ catch {
433
+ backReal = null;
434
+ }
435
+ if (backReal !== dotgit)
436
+ fail("belongs to a git directory that names another checkout");
437
+ dirs = { checkout: top, gitdir, common, linked: true };
438
+ }
439
+ // The common folder is a main checkout's own `.git` folder, a real one.
440
+ if (basename(dirs.common) !== ".git")
441
+ fail("belongs to a repository that isn't a checkout's");
442
+ try {
443
+ if (!lstatSync(dirs.common).isDirectory())
444
+ fail("belongs to a repository that is a link");
445
+ }
446
+ catch {
447
+ fail("belongs to a repository that isn't there");
448
+ }
449
+ if (binding.repo != null) {
450
+ // The anchor is the plane's own record, never a pointer file: the recorded project's main checkout, whose `.git`
451
+ // must be a real folder (not a link, holding no `commondir`); its real path is the only common folder accepted. A
452
+ // record that isn't such a checkout (a linked worktree, as agent-fence.ts trustedWorktreeBinding also refuses) or
453
+ // can't be read is refused, never taken as unbound.
454
+ let recorded = null;
455
+ try {
456
+ const dotgit = join(realpathSync(binding.repo), ".git");
457
+ const held = lstatSync(dotgit);
458
+ recorded = held.isDirectory() && !held.isSymbolicLink() && !existsSync(join(dotgit, "commondir")) ? realpathSync(dotgit) : null;
459
+ }
460
+ catch {
461
+ recorded = null;
462
+ }
463
+ if (recorded === null)
464
+ fail("can't be checked against the project's record: its main checkout's .git isn't a real folder");
465
+ if (recorded !== dirs.common)
466
+ fail("belongs to another repository than the project's");
467
+ }
468
+ return dirs;
469
+ }
470
+ /** What git keeps only per worktree and the plane never links: HEAD (git won't take it as a link) and the pointers. */
471
+ /** What the plane links of a worktree's own git folder (git writes them in place through the link: the index and its
472
+ * logs by lockfile, per-worktree refs and logs inside the folders), whether or not they exist yet; a rebase's,
473
+ * cherry-pick's or sequencer's state folder only when one is already there. */
474
+ const ADMIN_LINKED = ["index", "logs", "refs", "reftable"];
475
+ /** What git keeps in a common directory (its path.c common_list, and reftable), linked whether or not it exists yet. */
476
+ const COMMON_NAMES = ["branches", "gc.pid", "logs", "lost-found", "modules", "objects", "packed-refs", "reftable", "refs", "remotes", "rr-cache", "shallow", "svn", "worktrees"];
477
+ function gitView(checkout, kind, binding = {}, gitFile = "git") {
478
+ const dirs = boundDirs(checkout, binding);
479
+ const root = ensureGitViewsRoot(realpathSync(homedir()));
480
+ const view = realpathSync(mkdtempSync(join(root, `${kind}-`)));
481
+ const admin = realpathSync(mkdtempSync(join(root, `${kind}-gitdir-`)));
482
+ const dispose = () => { rmSync(view, { recursive: true, force: true }); rmSync(admin, { recursive: true, force: true }); };
483
+ const initial = new Map();
484
+ const priorWorktrees = new Set();
230
485
  try {
231
486
  let config = regularFile(join(dirs.common, "config"));
232
487
  if (config === null)
@@ -235,7 +490,7 @@ function readView(checkout) {
235
490
  writeFileSync(configPath, config, { mode: 0o600 });
236
491
  const plain = { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } };
237
492
  if (/worktreeconfig/i.test(config)) {
238
- const flag = spawnSync("git", ["config", "--file", configPath, "--no-includes", "--type=bool", "--get", "extensions.worktreeConfig"], plain);
493
+ const flag = spawnSync(gitFile, ["config", "--file", configPath, "--no-includes", "--type=bool", "--get", "extensions.worktreeConfig"], plain);
239
494
  if (flag.status !== 0 && flag.status !== 1)
240
495
  throw new Error("the checkout's git config couldn't be read");
241
496
  if (flag.stdout.trim() === "true") {
@@ -247,37 +502,534 @@ function readView(checkout) {
247
502
  config = `${config}\n${own}`;
248
503
  writeFileSync(configPath, config, { mode: 0o600 });
249
504
  }
250
- const unset = spawnSync("git", ["config", "--file", configPath, "--unset-all", "extensions.worktreeConfig"], plain);
505
+ const unset = spawnSync(gitFile, ["config", "--file", configPath, "--unset-all", "extensions.worktreeConfig"], plain);
251
506
  if (unset.status !== 0 && unset.status !== 5)
252
507
  throw new Error("the checkout's git config couldn't be read");
253
508
  }
254
- if (configIncludes(configPath))
255
- throw new Error("the checkout's git config includes another file");
509
+ const include = includeIn(configPath, gitFile);
510
+ if (include !== null)
511
+ throw new Error(`the checkout's git config includes another file (${include})`);
256
512
  mkdirSync(join(view, "info"));
257
- const exclude = join(dirs.common, "info", "exclude");
258
- if (existsSync(exclude)) {
259
- const text = regularFile(exclude);
513
+ // A main checkout's sparse-checkout patterns are in its own (common) info folder; a linked one's in its admin's.
514
+ for (const name of kind === "write" ? ["exclude", "attributes", "sparse-checkout"] : ["exclude", "sparse-checkout"]) {
515
+ if (name === "sparse-checkout" && dirs.linked)
516
+ continue;
517
+ const path = join(dirs.common, "info", name);
518
+ if (!existsSync(path))
519
+ continue;
520
+ const text = regularFile(path);
521
+ // A write that dropped the operator's exclude or attributes could stage what they keep out: refused instead.
522
+ if (text === null && kind === "write")
523
+ throw new Error(`the checkout's info/${name} couldn't be read as a plain file`);
260
524
  if (text !== null)
261
- writeFileSync(join(view, "info", "exclude"), text, { mode: 0o600 });
525
+ writeFileSync(join(view, "info", name), text, { mode: 0o600 });
262
526
  }
263
- for (const name of readdirSync(dirs.common)) {
264
- if (name === "config" || name === "info" || name === "hooks")
527
+ if (kind === "write") {
528
+ const folder = join(dirs.common, "worktrees");
529
+ if (!existsSync(folder)) {
530
+ try {
531
+ mkdirSync(folder);
532
+ }
533
+ catch { /* git reports it if it needs the folder */ }
534
+ }
535
+ try {
536
+ for (const id of readdirSync(folder))
537
+ priorWorktrees.add(id);
538
+ }
539
+ catch { /* none yet */ }
540
+ }
541
+ for (const name of new Set([...readdirSync(dirs.common), ...COMMON_NAMES])) {
542
+ if (name === "config" || name === "info" || name === "hooks" || name === "commondir")
265
543
  continue;
266
544
  symlinkSync(join(dirs.common, name), join(view, name));
267
545
  }
546
+ // GIT_DIR is the plane's too (git's ref store reads `commondir` from GIT_DIR as it goes, empirically on 2.50.1):
547
+ // its `commondir` names the view and its `gitdir` this checkout, both the plane's; HEAD is a copy (git won't take
548
+ // it as a link) carried back after the run; the worktree's other files are linked (the index and logs even before
549
+ // they exist), so git's lockfiles write them in place.
550
+ // (A folder under a dangling link can't be made by git: the worktree's `logs` is made first, as git would.)
551
+ if (kind === "write" && !existsSync(join(dirs.gitdir, "logs"))) {
552
+ try {
553
+ mkdirSync(join(dirs.gitdir, "logs"));
554
+ }
555
+ catch { /* git reports it */ }
556
+ }
557
+ // Only what git needs of the worktree's own folder is linked (ADMIN_LINKED, and a split index's shared files), each
558
+ // checked to be a real entry directly in it, never a link (Codex review of a0fa135d).
559
+ const linked = [...ADMIN_LINKED, ...readdirSync(dirs.gitdir).filter(name => name.startsWith("sharedindex."))];
560
+ for (const name of new Set(linked)) {
561
+ const path = join(dirs.gitdir, name);
562
+ let held = null;
563
+ try {
564
+ held = lstatSync(path);
565
+ }
566
+ catch {
567
+ held = null;
568
+ }
569
+ if (held !== null && (held.isSymbolicLink() || dirname(realpathSync(path)) !== dirs.gitdir))
570
+ throw new Error(`the checkout's git directory has ${name} as a link`);
571
+ symlinkSync(path, join(admin, name));
572
+ initial.set(name, null);
573
+ }
574
+ const head = regularFile(join(dirs.gitdir, "HEAD"));
575
+ if (head === null)
576
+ throw new Error("the checkout's HEAD couldn't be read as a plain file");
577
+ // The worktree's own state files (WORKTREE_STATE: HEAD, a merge's, cherry-pick's, rebase's or bisect's pseudo-refs
578
+ // and messages) are copies (git won't take them as links), carried back after the run; its state folders, when
579
+ // there, are linked.
580
+ for (const name of WORKTREE_STATE) {
581
+ const path = join(dirs.gitdir, name);
582
+ if (!existsSync(path) && name !== "HEAD")
583
+ continue;
584
+ const text = name === "HEAD" ? head : regularFile(path);
585
+ if (text === null)
586
+ throw new Error(`the checkout's ${name} couldn't be read as a plain file`);
587
+ writeFileSync(join(admin, name), text, { mode: 0o600 });
588
+ initial.set(name, text);
589
+ }
590
+ for (const name of WORKTREE_STATE_DIRS) {
591
+ const path = join(dirs.gitdir, name);
592
+ let held = null;
593
+ try {
594
+ held = lstatSync(path);
595
+ }
596
+ catch {
597
+ continue;
598
+ }
599
+ if (held.isSymbolicLink() || !held.isDirectory())
600
+ throw new Error(`the checkout's git directory has ${name} as a link`);
601
+ symlinkSync(path, join(admin, name));
602
+ initial.set(name, null);
603
+ }
604
+ writeFileSync(join(admin, "commondir"), `${view}\n`, { mode: 0o600 });
605
+ writeFileSync(join(admin, "gitdir"), `${join(dirs.checkout, ".git")}\n`, { mode: 0o600 });
606
+ // A main checkout's own info (its sparse-checkout) is the view's frozen one; a linked one's is its own.
607
+ symlinkSync(dirs.linked ? join(dirs.gitdir, "info") : join(view, "info"), join(admin, "info"));
608
+ if (!pinned(dirs).ok)
609
+ throw new Error("the checkout's repository has a folder that is a link");
268
610
  }
269
611
  catch (error) {
270
612
  dispose();
271
613
  throw error;
272
614
  }
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 };
615
+ // The folders down to the worktree's own, as they are now: carry-back writes only while each is still the same one.
616
+ let anchor;
617
+ try {
618
+ anchor = anchorOf(dirs.gitdir);
619
+ }
620
+ catch {
621
+ dispose();
622
+ throw new Error("the checkout's repository has a folder that is a link");
623
+ }
624
+ // Where git names a view (rev-parse --git-dir, --git-common-dir), the caller is told the repository's own folder;
625
+ // where `worktree list` names the common view as the main worktree, that worktree (git's rule: the common dir less
626
+ // `/.git`).
627
+ const mainWorktree = dirname(dirs.common);
628
+ const restore = (output, args) => {
629
+ if (!output.includes(view) && !output.includes(admin))
630
+ return output;
631
+ const asWorktree = args.includes("worktree");
632
+ return output.split(admin).join(dirs.gitdir).split(`${view}/`).join(`${dirs.common}/`).split(view).join(asWorktree ? mainWorktree : dirs.common);
633
+ };
634
+ const settle = () => {
635
+ const now = pinned(dirs);
636
+ let bound = false;
637
+ try {
638
+ const again = boundDirs(checkout, binding);
639
+ bound = again.gitdir === dirs.gitdir && again.common === dirs.common;
640
+ }
641
+ catch {
642
+ bound = false;
643
+ }
644
+ if (!now.ok || !bound) {
645
+ // Too late to prevent: recorded and told (git-incident.ts), never rolled back where the swap leads.
646
+ if (kind === "write") {
647
+ 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 }],
648
+ record: join(dirname(ensureGitViewsRoot(realpathSync(homedir()))), "git-incidents.jsonl") });
649
+ }
650
+ return "the checkout's repository changed while git ran";
651
+ }
652
+ if (kind === "write") {
653
+ // The worktree's own state git rewrote in the plane's GIT_DIR goes back to it, and what it removed is removed
654
+ // there; anything else git left there the plane can't keep, and says so.
655
+ const left = readdirSync(admin).filter(name => !initial.has(name) && !WORKTREE_STATE.includes(name) && !["commondir", "gitdir", "info"].includes(name));
656
+ try {
657
+ carryBack(admin, dirs.gitdir, initial, anchor);
658
+ }
659
+ catch (error) {
660
+ if (!(error instanceof AnchorMoved))
661
+ return "the checkout's git state couldn't be carried back after git ran";
662
+ gitIncident({ checkout: dirs.checkout, repository: dirs.common, swapped: [{ path: error.message, leadsTo: null }], record: join(dirname(ensureGitViewsRoot(realpathSync(homedir()))), "git-incidents.jsonl") });
663
+ return "the checkout's repository changed while git ran";
664
+ }
665
+ if (left.length > 0)
666
+ return `git left state Toolroll can't keep in the checkout (${left.slice(0, 5).join(", ")})`;
667
+ const relinked = relinkAdded(view, dirs.common, priorWorktrees);
668
+ if (relinked !== null)
669
+ return relinked;
670
+ }
671
+ return null;
672
+ };
673
+ return { env: { GIT_DIR: admin, GIT_COMMON_DIR: view, GIT_WORK_TREE: dirs.checkout }, restore, dispose, dirs, settle };
674
+ }
675
+ /** A worktree's own state files git may write (git's per-worktree names: HEAD and the pseudo-refs and messages of a
676
+ * merge, cherry-pick, revert, rebase or bisect), and its state folders. */
677
+ const WORKTREE_STATE = ["HEAD", "ORIG_HEAD", "MERGE_HEAD", "MERGE_MSG", "MERGE_MODE", "MERGE_RR", "AUTO_MERGE", "FETCH_HEAD", "COMMIT_EDITMSG",
678
+ "CHERRY_PICK_HEAD", "REVERT_HEAD", "REBASE_HEAD", "SQUASH_MSG", "TAG_EDITMSG", "BISECT_LOG", "BISECT_START", "BISECT_TERMS", "BISECT_EXPECTED_REV",
679
+ "BISECT_ANCESTORS_OK", "BISECT_NAMES", "BISECT_RUN", "BISECT_HEAD"];
680
+ const WORKTREE_STATE_DIRS = ["sequencer", "rebase-merge", "rebase-apply"];
681
+ /**
682
+ * Whether every path git may write through a view is still the repository's own (Codex review of a0fa135d: git
683
+ * writes deeper than the top-level folders, through a link at `refs/heads/x`, `logs/…` or `objects/xx` as well). The
684
+ * walk, by lstat, never following a link: every name of the common folder git may write (COMMON_NAMES: refs, logs,
685
+ * reftable, packed-refs, worktrees with each worktree's own folder, rr-cache, modules, …) all the way down, HEAD and
686
+ * the index; the worktree's own state (WORKTREE_STATE: its HEAD, index, refs, logs, a merge's, rebase's or bisect's
687
+ * state, a split index's shared files); and the object store (the common `objects`, and a submodule git directory's
688
+ * own under modules/): its fan-out folders' entries for links, `info` and `pack` all the way down, but not each loose
689
+ * object (a big repository has very many, and hard-linked objects are legitimate). Any link is refused, so is a folder
690
+ * 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
691
+ * 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).
692
+ *
693
+ * Checked when the view is made and again after git ran; between the two, a process that outlived the agent's turn
694
+ * can still swap one of these for a link and have git write through it mid-command. That window is an accepted
695
+ * residual: git takes paths, not handles. Seen after the run, it is an incident (git-incident.ts).
696
+ */
697
+ function pinned(dirs) {
698
+ const swapped = [];
699
+ const seen = new Set();
700
+ let budget = 400_000;
701
+ const stores = objectStores(dirs.common);
702
+ /** `deep`: every entry all the way down, each file held to one hard link. `shallow`: the folder's own entries
703
+ * checked for links only. `store`: an object store (objects/: its fan-out folders shallow, `info` and `pack` deep
704
+ * without the hard-link test). A link, or a folder that can't be listed, is swapped. */
705
+ const check = (path, mode) => {
706
+ if (seen.has(path))
707
+ return;
708
+ if (budget-- <= 0)
709
+ return;
710
+ seen.add(path);
711
+ let held;
712
+ try {
713
+ held = lstatSync(path);
714
+ }
715
+ catch {
716
+ return;
717
+ }
718
+ if (held.isSymbolicLink()) {
719
+ let real = null;
720
+ try {
721
+ real = realpathSync(path);
722
+ }
723
+ catch {
724
+ real = null;
725
+ }
726
+ swapped.push({ path, leadsTo: real });
727
+ return;
728
+ }
729
+ if (held.isFile() && mode === "deep" && held.nlink > 1) {
730
+ swapped.push({ path, leadsTo: null });
731
+ return;
732
+ }
733
+ if (!held.isDirectory())
734
+ return;
735
+ let entries;
736
+ // A folder that can't be listed hides what's in it: it counts as swapped (fail closed).
737
+ try {
738
+ entries = readdirSync(path);
739
+ }
740
+ catch {
741
+ swapped.push({ path, leadsTo: null });
742
+ return;
743
+ }
744
+ for (const name of entries) {
745
+ const child = join(path, name);
746
+ if (mode === "shallow") {
747
+ try {
748
+ if (lstatSync(child).isSymbolicLink())
749
+ check(child, "shallow");
750
+ }
751
+ catch { /* gone */ }
752
+ continue;
753
+ }
754
+ if (mode === "store") {
755
+ check(child, name === "info" || name === "pack" ? "deep-unlinked" : "shallow");
756
+ continue;
757
+ }
758
+ // The object store exemption by exact place, never by name (stores, enumerated before the walk).
759
+ check(child, stores.has(child) ? "store" : mode);
760
+ }
761
+ };
762
+ // The common folder's every name git may write, all the way down (refs, logs, reftable, packed-refs, worktrees with
763
+ // each worktree's own folder, rr-cache, modules, the rest of COMMON_NAMES), and the worktree's own state.
764
+ for (const name of COMMON_NAMES)
765
+ if (name !== "objects")
766
+ check(join(dirs.common, name), "deep");
767
+ for (const name of ["HEAD", "index"])
768
+ check(join(dirs.common, name), "deep");
769
+ for (const name of [...WORKTREE_STATE, ...WORKTREE_STATE_DIRS, "index", "refs", "logs", "reftable"])
770
+ check(join(dirs.gitdir, name), "deep");
771
+ try {
772
+ for (const name of readdirSync(dirs.gitdir))
773
+ if (name.startsWith("sharedindex."))
774
+ check(join(dirs.gitdir, name), "deep");
775
+ }
776
+ catch {
777
+ swapped.push({ path: dirs.gitdir, leadsTo: null });
778
+ }
779
+ // The object store: the folder and its fan-out folders for links (not each loose object: a big repository has very
780
+ // many, and `clone --local` hard-links them legitimately; git writes a new object to a fresh temporary file linked
781
+ // into place, never through an existing one), `info` and `pack` all the way down.
782
+ for (const store of stores)
783
+ check(store, "store");
784
+ if (budget <= 0)
785
+ swapped.push({ path: dirs.common, leadsTo: null });
786
+ // A path that is no longer its own real path (a folder on the way swapped) counts too.
787
+ for (const path of [dirs.common, dirs.gitdir]) {
788
+ try {
789
+ if (realpathSync(path) !== path)
790
+ swapped.push({ path, leadsTo: realpathSync(path) });
791
+ }
792
+ catch {
793
+ swapped.push({ path, leadsTo: null });
794
+ }
795
+ }
796
+ return { ok: swapped.length === 0, swapped };
797
+ }
798
+ /**
799
+ * The object stores the walk exempts from its full check, enumerated before it, never decided by a name met on the
800
+ * way: the common `objects`, and `<gitdir>/objects` for each submodule git directory under modules/ — a folder with
801
+ * its own config, HEAD, refs/ and objects/, reached through no component named refs, logs, reftable, objects, info,
802
+ * worktrees or hooks (so `modules/x/refs/objects` with a planted HEAD is no store).
803
+ */
804
+ function objectStores(common) {
805
+ const stores = new Set([join(common, "objects")]);
806
+ const NOT_A_PATH = new Set(["refs", "logs", "reftable", "objects", "info", "worktrees", "hooks"]);
807
+ const visit = (dir, depth) => {
808
+ if (depth > 8)
809
+ return;
810
+ let entries = [];
811
+ try {
812
+ entries = readdirSync(dir, { withFileTypes: true });
813
+ }
814
+ catch {
815
+ return;
816
+ }
817
+ for (const entry of entries) {
818
+ if (!entry.isDirectory() || NOT_A_PATH.has(entry.name))
819
+ continue;
820
+ const at = join(dir, entry.name);
821
+ const own = (name, folder) => { try {
822
+ const held = lstatSync(join(at, name));
823
+ return folder ? held.isDirectory() : held.isFile();
824
+ }
825
+ catch {
826
+ return false;
827
+ } };
828
+ if (own("config", false) && own("HEAD", false) && own("refs", true) && own("objects", true)) {
829
+ stores.add(join(at, "objects"));
830
+ visit(join(at, "modules"), depth + 1);
831
+ }
832
+ else
833
+ visit(at, depth + 1);
834
+ }
835
+ };
836
+ visit(join(common, "modules"), 0);
837
+ return stores;
838
+ }
839
+ function anchorOf(path) {
840
+ const anchor = [];
841
+ for (let at = path;; at = dirname(at)) {
842
+ const held = lstatSync(at);
843
+ if (held.isSymbolicLink() || !held.isDirectory())
844
+ throw new AnchorMoved(at);
845
+ anchor.unshift({ path: at, dev: held.dev, ino: held.ino });
846
+ if (dirname(at) === at)
847
+ return anchor;
848
+ }
849
+ }
850
+ class AnchorMoved extends Error {
851
+ }
852
+ /** Throws AnchorMoved unless every folder of `anchor` is still the one it was (Node has no openat, so each component
853
+ * is looked at again right before each rename or unlink). */
854
+ function holdAnchor(anchor) {
855
+ for (const one of anchor) {
856
+ let held;
857
+ try {
858
+ held = lstatSync(one.path);
859
+ }
860
+ catch {
861
+ throw new AnchorMoved(one.path);
862
+ }
863
+ if (held.isSymbolicLink() || !held.isDirectory() || held.dev !== one.dev || held.ino !== one.ino)
864
+ throw new AnchorMoved(one.path);
865
+ }
866
+ }
867
+ /** Carry the WORKTREE_STATE files git rewrote or removed in the plane's GIT_DIR back to the worktree's own folder: a
868
+ * new file of a random name, created exclusively (never through a link), then renamed into place; before each rename
869
+ * or unlink, every folder down to the worktree's own is proven the same one it was when the view was made. */
870
+ function carryBack(admin, gitdir, initial, anchor) {
871
+ for (const name of WORKTREE_STATE) {
872
+ const here = join(admin, name);
873
+ let held = null;
874
+ try {
875
+ held = lstatSync(here);
876
+ }
877
+ catch {
878
+ held = null;
879
+ }
880
+ const was = initial.get(name);
881
+ if (held === null) {
882
+ // Removed by git: removed in the worktree's own folder too (a merge's state once it concluded).
883
+ if (initial.get(name) != null) {
884
+ holdAnchor(anchor);
885
+ rmSync(join(gitdir, name), { force: true });
886
+ }
887
+ continue;
888
+ }
889
+ if (held.isSymbolicLink() || !held.isFile())
890
+ continue;
891
+ const text = readFileSync(here);
892
+ if (was !== undefined && was !== null && text.toString("utf8") === was)
893
+ continue;
894
+ holdAnchor(anchor);
895
+ const temporary = join(gitdir, `.toolroll-${randomBytes(16).toString("hex")}`);
896
+ const fd = openSync(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
897
+ try {
898
+ writeSync(fd, text);
899
+ }
900
+ finally {
901
+ closeSync(fd);
902
+ }
903
+ try {
904
+ holdAnchor(anchor);
905
+ renameSync(temporary, join(gitdir, name));
906
+ }
907
+ catch (error) {
908
+ rmSync(temporary, { force: true });
909
+ throw error;
910
+ }
911
+ }
912
+ }
913
+ /**
914
+ * A worktree `worktree add` made through a view, pointed at its repository instead of the view. Before git 2.48 the new
915
+ * checkout's `.git` file names the common directory as given (the view, removed right after the run: every later
916
+ * command in that checkout would find no repository); later versions name its real path, and this finds nothing to do.
917
+ * Only a worktree folder this run made (not there when the view was made) whose checkout's `.git` is a plain file
918
+ * reading exactly `gitdir: <view>/worktrees/<id>` (a name private to this run, in a folder every fence denies) is
919
+ * rewritten: a new file created exclusively (never through a link) and renamed into place, then bound to the repository
920
+ * (boundDirs) as any checkout is. Before it writes, the repository's own record of the worktree must name this very
921
+ * checkout and repository, and every folder down to the checkout is anchored (anchorOf) and proven the same one before
922
+ * the open, the re-read and the rename, as carryBack does. A failure after the rename isn't undone (the old content
923
+ * names a view that no longer exists, so the checkout is unusable either way); it refuses. Null when every such
924
+ * checkout is bound, or why one isn't.
925
+ */
926
+ function relinkAdded(view, common, prior) {
927
+ let ids;
928
+ try {
929
+ ids = readdirSync(join(common, "worktrees")).filter(id => !prior.has(id));
930
+ }
931
+ catch {
932
+ return null;
933
+ }
934
+ for (const id of ids) {
935
+ const named = regularFile(join(common, "worktrees", id, "gitdir"))?.trim();
936
+ if (named === undefined || !named.startsWith("/") || basename(named) !== ".git")
937
+ continue;
938
+ const stale = `gitdir: ${join(view, "worktrees", id)}`;
939
+ let folder;
940
+ try {
941
+ folder = realpathSync(dirname(named));
942
+ }
943
+ catch {
944
+ continue;
945
+ }
946
+ const dotgit = join(folder, ".git");
947
+ const read = () => { try {
948
+ return lstatSync(dotgit).isFile() ? regularFile(dotgit) : null;
949
+ }
950
+ catch {
951
+ return null;
952
+ } };
953
+ if (read()?.trim() !== stale)
954
+ continue;
955
+ // The repository's record of this worktree names this checkout and this repository, before anything is written.
956
+ const admin = join(common, "worktrees", id);
957
+ let recorded;
958
+ try {
959
+ const commondir = regularFile(join(admin, "commondir"))?.trim();
960
+ recorded = lstatSync(admin).isDirectory() && commondir !== undefined && commondir !== ""
961
+ && realpathSync(resolve(admin, commondir)) === realpathSync(common) && realpathSync(named) === dotgit;
962
+ }
963
+ catch {
964
+ recorded = false;
965
+ }
966
+ if (!recorded)
967
+ return "the new checkout's record in its repository doesn't name it";
968
+ let anchor;
969
+ try {
970
+ anchor = anchorOf(folder);
971
+ }
972
+ catch {
973
+ return "the new checkout's folder changed before it could be pointed at its repository";
974
+ }
975
+ const temporary = join(folder, `.toolroll-${randomBytes(16).toString("hex")}`);
976
+ try {
977
+ holdAnchor(anchor);
978
+ const fd = openSync(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o644);
979
+ try {
980
+ writeSync(fd, `gitdir: ${admin}\n`);
981
+ }
982
+ finally {
983
+ closeSync(fd);
984
+ }
985
+ // Checked again at the moment of use: the same folders, and still the plain file git wrote.
986
+ holdAnchor(anchor);
987
+ if (read()?.trim() !== stale) {
988
+ rmSync(temporary, { force: true });
989
+ return "the new checkout's .git changed before it could be pointed at its repository";
990
+ }
991
+ holdAnchor(anchor);
992
+ renameSync(temporary, dotgit);
993
+ }
994
+ catch {
995
+ rmSync(temporary, { force: true });
996
+ return "the new checkout's .git couldn't be pointed at its repository";
997
+ }
998
+ try {
999
+ boundDirs(folder, { repo: dirname(common) });
1000
+ }
1001
+ catch (error) {
1002
+ return error instanceof Error ? error.message : "the new checkout isn't bound to its repository";
1003
+ }
1004
+ }
1005
+ return null;
276
1006
  }
1007
+ /** The checkout git would find from `cwd`: the nearest folder, `cwd` or above, with a `.git`; null when there is
1008
+ * none (git's own "not a git repository"). A `.git` that can't even be looked at counts as found (the view refuses it). */
1009
+ function checkoutRootOf(cwd) {
1010
+ for (let dir = resolve(cwd);; dir = dirname(dir)) {
1011
+ try {
1012
+ lstatSync(join(dir, ".git"));
1013
+ return dir;
1014
+ }
1015
+ catch (error) {
1016
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR")
1017
+ return dir;
1018
+ }
1019
+ if (dirname(dir) === dir)
1020
+ return null;
1021
+ }
1022
+ }
1023
+ 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 };
1024
+ /** Settings fixed on every write through a view: `worktree add` records absolute paths (relative ones would need a
1025
+ * config write, which a view keeps); no transport at all (a caller that fetches allows its one protocol by name, as the
1026
+ * nightly check's local fetch does with protocol.file.allow); and no split index (its shared index files would be
1027
+ * written in the plane's GIT_DIR). */
1028
+ export const WRITE_VIEW_FLAGS = ["-c", "worktree.useRelativePaths=false", "-c", "protocol.allow=never", "-c", "core.splitIndex=false"];
277
1029
  /** Whether the checkout's .lfsconfig (read as git parses it, that file only) sets any git-lfs key that could name a
278
1030
  * program (anything but INERT_LFS). A file that
279
1031
  * isn't a plain one, or doesn't parse, counts as naming one. */
280
- export function lfsExtensionIn(checkout) {
1032
+ export function lfsExtensionIn(checkout, gitFile = "git") {
281
1033
  const path = join(checkout, ".lfsconfig");
282
1034
  try {
283
1035
  if (!lstatSync(path).isFile())
@@ -286,13 +1038,15 @@ export function lfsExtensionIn(checkout) {
286
1038
  catch {
287
1039
  return false;
288
1040
  }
289
- 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));
1041
+ const parsed = spawnSync(gitFile, ["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" } });
1042
+ return parsed.status !== 0 || parsed.stdout.split("\0").filter(Boolean).some(name => lfsCommand(name) || ["include", "includeif"].includes(keyParts(name).section));
291
1043
  }
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") {
1044
+ /** Every command runs against a view of the checkout's repository (gitView) whose config and info are copies the
1045
+ * plane made, inspected there, so neither can change between the inspection and the run. `mode` "read" is for reading
1046
+ * a checkout an agent may still be writing (the code page): every command also takes readFlags(), and the view holds
1047
+ * no info/attributes. "build" is the plane's staging and committing of a build's work, which writes the repository
1048
+ * through the view (gitView). */
1049
+ export function checkoutGit(inner = run, mode = "build", binding = {}) {
296
1050
  return async (file, args, options) => {
297
1051
  if (file !== "git")
298
1052
  return inner(file, args, options);
@@ -302,39 +1056,54 @@ export function checkoutGit(inner = run, mode = "build") {
302
1056
  // git-lfs also reads .lfsconfig from the checkout: one that names an extension (a command it would run) is refused.
303
1057
  if (lfsExtensionIn(cwd))
304
1058
  return refused("the checkout's .lfsconfig sets a git-lfs key that can run a command");
305
- let view = null;
306
- if (mode === "read") {
1059
+ const once = async () => {
1060
+ let view;
307
1061
  try {
308
- view = readView(cwd);
1062
+ view = gitView(cwd, mode === "read" ? "read" : "write", binding);
309
1063
  }
310
1064
  catch (error) {
311
1065
  return refused(error instanceof Error ? error.message : "the checkout couldn't be read");
312
1066
  }
313
- }
1067
+ try {
1068
+ const viewed = { ...options, env: { ...(options?.env ?? {}), ...view.env } };
1069
+ // The checkout's own config may name no command.
1070
+ const own = await inner("git", ["--no-optional-locks", "config", "--list", "--show-scope", "-z"], viewed);
1071
+ if (own.code !== 0)
1072
+ return refused("its config couldn't be read");
1073
+ const key = commandSetting(own.stdout, { lfsChecked: true });
1074
+ if (key !== null)
1075
+ return refused(`the checkout's git config sets ${key}, which can run a command`);
1076
+ // A git without --no-lazy-fetch: a repository that could fetch lazily is refused, from the view's frozen config.
1077
+ if (!lazyFetchSupported() && lazyFetchSetting(own.stdout) !== null)
1078
+ return refused(PARTIAL_CLONE_REFUSAL);
1079
+ // The operator's own config contributes values only (carriedSettings); git then reads no global or system config.
1080
+ // A read carries no filter: it reads attributes from the empty tree anyway.
1081
+ const carried = mode === "read" ? withoutFilters(carriedSettings(own.stdout)) : carriedSettings(own.stdout);
1082
+ const env = { ...viewed.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" };
1083
+ // Never into a submodule or nested repository, whose own config this never read: status and diff skip them.
1084
+ const safeArgs = withoutUnsupportedFlags(args);
1085
+ const command = safeArgs.findIndex(arg => arg === "status" || arg === "diff");
1086
+ if (command >= 0 && !safeArgs.includes("--ignore-submodules=all"))
1087
+ safeArgs.splice(command + 1, 0, "--ignore-submodules=all");
1088
+ const flags = mode === "read" ? readFlags() : WRITE_VIEW_FLAGS;
1089
+ const ran = await inner("git", [...TEARDOWN_FLAGS, ...flags, "-c", "diff.ignoreSubmodules=all", "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", ...carried, ...safeArgs], { ...options, env });
1090
+ const why = view.settle();
1091
+ if (why !== null)
1092
+ return refused(why);
1093
+ return { ...ran, stdout: view.restore(ran.stdout, args), stderr: view.restore(ran.stderr, args) };
1094
+ }
1095
+ finally {
1096
+ view.dispose();
1097
+ }
1098
+ };
1099
+ if (mode !== "build")
1100
+ return once();
1101
+ // A write, one at a time on a checkout (git-views.ts withCheckoutLock: re-entrant along one call chain).
314
1102
  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;
1103
+ return await withCheckoutLock(realpathSync(homedir()), realpathSync(cwd), once);
335
1104
  }
336
- finally {
337
- view?.dispose();
1105
+ catch (error) {
1106
+ return refused(error instanceof Error ? error.message : "the checkout is busy");
338
1107
  }
339
1108
  };
340
1109
  }
@@ -352,34 +1121,44 @@ export const PAGE_READ_FLAGS = [
352
1121
  /** The read flags, for every read of an agent-written checkout, whatever the HTTP method (security review of
353
1122
  * 01bf3114): the worktree's attributes come from the empty tree. Git still reads the repository's info/attributes and
354
1123
  * 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
1124
+ * 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
1125
  * a file they would normalize can read as changed (fail closed: shipping then asks for a clean commit). */
357
- export function readFlags() {
358
- return PAGE_READ_FLAGS;
1126
+ export function readFlags(gitFile = "git") {
1127
+ return lazyFetchSupported(gitFile) ? PAGE_READ_FLAGS : PAGE_READ_FLAGS.filter(flag => flag !== "--no-lazy-fetch");
359
1128
  }
360
1129
  /**
361
1130
  * checkoutGit, synchronously, for reads a page shows from a checkout an agent writes (docs/plans/get-side-effects.md):
362
1131
  * 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
1132
+ * 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
1133
  * or info/attributes change after they were read), submodules skipped, bounded. A refusal throws.
365
1134
  */
366
1135
  export function checkoutGitSync(cwd, args, options = {}) {
367
- if (checkoutIncludes(cwd))
1136
+ const ran = viewedGitSync(cwd, args, options);
1137
+ if (ran.status !== 0)
1138
+ throw new Error(`git ${args[0] ?? ""} failed`);
1139
+ return ran.stdout;
1140
+ }
1141
+ export function viewedGitSync(cwd, args, options = {}) {
1142
+ const gitFile = options.git ?? "git";
1143
+ if (checkoutIncludes(cwd, gitFile))
368
1144
  throw new Error("the checkout's git config includes another file, or couldn't be read as a plain file");
369
- if (lfsExtensionIn(cwd))
1145
+ if (lfsExtensionIn(cwd, gitFile))
370
1146
  throw new Error("the checkout's .lfsconfig sets a git-lfs key that can run a command");
371
1147
  // 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);
1148
+ const view = gitView(cwd, "read", options.binding, gitFile);
373
1149
  try {
374
1150
  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
1151
  const bounded = { cwd, env, encoding: "utf8", timeout: options.timeoutMs ?? 60_000, maxBuffer: options.maxBuffer ?? 1_048_576, stdio: ["ignore", "pipe", "pipe"] };
376
- const own = spawnSync("git", ["--no-optional-locks", "config", "--list", "--show-scope", "-z"], bounded);
1152
+ const own = spawnSync(gitFile, ["--no-optional-locks", "config", "--list", "--show-scope", "-z"], bounded);
377
1153
  if (own.status !== 0)
378
1154
  throw new Error("its config couldn't be read");
379
- const key = commandSetting(own.stdout);
1155
+ const key = commandSetting(own.stdout, { lfsChecked: true });
380
1156
  if (key !== null)
381
1157
  throw new Error(`the checkout's git config sets ${key}, which can run a command`);
382
- const safeArgs = [...args];
1158
+ // A git without --no-lazy-fetch: a repository that could fetch lazily is refused, from the view's frozen config.
1159
+ if (!lazyFetchSupported(gitFile) && lazyFetchSetting(own.stdout) !== null)
1160
+ throw new Error(PARTIAL_CLONE_REFUSAL);
1161
+ const safeArgs = withoutUnsupportedFlags(args, gitFile);
383
1162
  const command = safeArgs.findIndex(arg => arg === "status" || arg === "diff");
384
1163
  if (command >= 0 && !safeArgs.includes("--ignore-submodules=all"))
385
1164
  safeArgs.splice(command + 1, 0, "--ignore-submodules=all");
@@ -388,12 +1167,131 @@ export function checkoutGitSync(cwd, args, options = {}) {
388
1167
  safeArgs.splice(command + 1, 0, "--no-ext-diff", "--no-textconv");
389
1168
  // The operator's own values, but no filter: PAGE_READ_FLAGS reads attributes from the empty tree anyway.
390
1169
  const carried = withoutFilters(carriedSettings(own.stdout));
391
- const ran = spawnSync("git", [...TEARDOWN_FLAGS, ...readFlags(), "-c", "diff.ignoreSubmodules=all", ...carried, ...safeArgs], bounded);
392
- if (ran.status !== 0)
393
- throw new Error(`git ${args[0] ?? ""} failed`);
394
- return args.includes("rev-parse") ? view.restore(ran.stdout) : ran.stdout;
1170
+ const argv = [...TEARDOWN_FLAGS, ...readFlags(gitFile), "-c", "diff.ignoreSubmodules=all", ...carried, ...safeArgs];
1171
+ if (options.buffer === true) {
1172
+ // stdin is the caller's own bytes when given (cat-file --batch), else closed.
1173
+ const ran = spawnSync(gitFile, argv, { ...bounded, encoding: "buffer", ...(options.input === undefined ? {} : { input: options.input, stdio: ["pipe", "pipe", "pipe"] }) });
1174
+ const why = view.settle();
1175
+ if (why !== null)
1176
+ throw new Error(why);
1177
+ return { status: ran.error === undefined ? ran.status : null, stdout: ran.stdout };
1178
+ }
1179
+ const ran = spawnSync(gitFile, argv, bounded);
1180
+ const why = view.settle();
1181
+ if (why !== null)
1182
+ throw new Error(why);
1183
+ return { status: ran.error === undefined ? ran.status : null, stdout: view.restore(ran.stdout ?? "", args) };
395
1184
  }
396
1185
  finally {
397
1186
  view.dispose();
398
1187
  }
399
1188
  }
1189
+ /**
1190
+ * checkoutGit ("build": the repository written through its view) for callers that name the repository with a leading
1191
+ * `git -C <dir>` instead of a cwd: worktree add and remove in a project's repository, a merge in a checkout of it.
1192
+ * The project's repository is shared with every build, which can write its config, info and hooks; nothing here runs
1193
+ * what they name. Anything but git goes to `inner` as given.
1194
+ */
1195
+ export function hardenedGit(inner = run, mode = "build", binding = {}) {
1196
+ const hardened = checkoutGit(inner, mode, binding);
1197
+ return (file, args, options) => {
1198
+ if (file !== "git")
1199
+ return inner(file, args, options);
1200
+ let cwd = options?.cwd;
1201
+ let rest = [...args];
1202
+ while (rest[0] === "-C" && rest.length > 1) {
1203
+ cwd = resolve(cwd ?? process.cwd(), rest[1]);
1204
+ rest = rest.slice(2);
1205
+ }
1206
+ return hardened("git", rest, { ...options, ...(cwd === undefined ? {} : { cwd }) });
1207
+ };
1208
+ }
1209
+ /**
1210
+ * Git older than 2.45 has no `--no-lazy-fetch` (and no GIT_NO_LAZY_FETCH): there, every hardened read leaves the flag
1211
+ * out and instead refuses a repository whose config makes lazy fetching possible (lazyFetchSetting), while
1212
+ * `protocol.allow=never` stays on every read and write, so a lazy fetch that slipped past would still find no
1213
+ * transport. Asked once per process, as the plane's other plain git probes run: no system or global config.
1214
+ */
1215
+ const lazyFetchByGit = new Map();
1216
+ let lazyFetchOverride;
1217
+ /** The git executable `file` names, as the real path it runs (a bare name looked up on PATH), or `file` itself. */
1218
+ function gitExecutablePath(file) {
1219
+ const candidates = file.includes("/") ? [file] : (process.env["PATH"] ?? "").split(delimiter).filter(dir => isAbsolute(dir)).map(dir => join(dir, file));
1220
+ for (const candidate of candidates) {
1221
+ try {
1222
+ accessSync(candidate, fsConstants.X_OK);
1223
+ return realpathSync(candidate);
1224
+ }
1225
+ catch { /* next */ }
1226
+ }
1227
+ return file;
1228
+ }
1229
+ /** Asked once per git executable (eval-harness review 2, follow-up): a fixed git given to viewedGitSync is probed
1230
+ * itself, never answered for by the one PATH names. */
1231
+ export function lazyFetchSupported(gitFile = "git") {
1232
+ if (lazyFetchOverride !== undefined)
1233
+ return lazyFetchOverride;
1234
+ const executable = gitExecutablePath(gitFile);
1235
+ let known = lazyFetchByGit.get(executable);
1236
+ if (known === undefined) {
1237
+ const probe = spawnSync(executable, ["--no-lazy-fetch", "version"], { cwd: "/", encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "pipe"],
1238
+ env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
1239
+ known = probe.status === 0;
1240
+ lazyFetchByGit.set(executable, known);
1241
+ }
1242
+ return known;
1243
+ }
1244
+ /** For tests: forget the probes' answers, or set one for every executable (null forgets). */
1245
+ export function setLazyFetchSupportedForTest(value) { lazyFetchOverride = value ?? undefined; lazyFetchByGit.clear(); }
1246
+ /** The words for a partial clone refused on a git without `--no-lazy-fetch`. */
1247
+ export const PARTIAL_CLONE_REFUSAL = "this project is a partial clone; protected reads need git 2.45 or newer";
1248
+ /** `--no-lazy-fetch` where the installed git has it, else nothing (and lazyFetchSetting refuses partial clones). */
1249
+ export function noLazyFetch() { return lazyFetchSupported() ? ["--no-lazy-fetch"] : []; }
1250
+ /** A caller's own arguments with `--no-lazy-fetch` taken out when the installed git doesn't know it. */
1251
+ function withoutUnsupportedFlags(args, gitFile = "git") {
1252
+ return lazyFetchSupported(gitFile) ? [...args] : args.filter(arg => arg !== "--no-lazy-fetch");
1253
+ }
1254
+ /**
1255
+ * The repository setting (local or worktree scope of a `git config --list --show-scope -z` listing) that makes lazy
1256
+ * fetching possible, or null: `extensions.partialClone` set at all, any `remote.<name>.promisor` not plainly false, or
1257
+ * any `remote.<name>.partialCloneFilter` set (git makes that remote a promisor). Fails closed: a listing that can't be
1258
+ * read names a setting. `remote.<name>.vcs` (a foreign-VCS helper) is named too, though the classifier already refuses
1259
+ * it (commandSetting: not in the remote allowlist), as it does every `protocol.*` key and every include.
1260
+ */
1261
+ export function lazyFetchSetting(listing) {
1262
+ try {
1263
+ const fields = listing.split("\0");
1264
+ for (let at = 0; at + 1 < fields.length; at += 2) {
1265
+ const scope = fields[at];
1266
+ if (scope !== "local" && scope !== "worktree")
1267
+ continue;
1268
+ const entry = fields[at + 1];
1269
+ const newline = entry.indexOf("\n");
1270
+ const key = newline < 0 ? entry : entry.slice(0, newline);
1271
+ const value = newline < 0 ? null : entry.slice(newline + 1);
1272
+ const { section, subsection, variable } = keyParts(key);
1273
+ if (section === "extensions" && variable === "partialclone")
1274
+ return key.toLowerCase();
1275
+ if (section === "remote" && subsection !== null && (variable === "partialclonefilter" || variable === "vcs"))
1276
+ return key.toLowerCase();
1277
+ if (section === "remote" && subsection !== null && variable === "promisor" && !/^(false|no|off|0)$/i.test((value ?? "true").trim()))
1278
+ return key.toLowerCase();
1279
+ }
1280
+ return null;
1281
+ }
1282
+ catch {
1283
+ return "a setting that couldn't be read";
1284
+ }
1285
+ }
1286
+ /**
1287
+ * For the reads that run git directly on a repository rather than through a view: with `--no-lazy-fetch` they run as
1288
+ * they always have (the flag stops any fetch). Without it, they must not check a repository's config and then run git
1289
+ * on it (the config could change in between, or an include could set what the check never saw): `inner`, wrapped so
1290
+ * every git command runs through a frozen read view (checkoutGit "read"), whose own config listing (includes refused,
1291
+ * command and protocol settings refused, a partial clone refused) is the config git then runs with.
1292
+ */
1293
+ export function lazyFetchSafeGit(inner = run) {
1294
+ // On every git version (eval-harness review 1, finding 7): with --no-lazy-fetch the direct read still took the
1295
+ // repository's config and its includes as git found them, and an include naming a FIFO could hang the plane.
1296
+ return checkoutGit(inner, "read");
1297
+ }