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.
- package/CHANGELOG.md +241 -0
- package/README.md +11 -4
- package/THIRD_PARTY_NOTICES.md +5 -5
- package/dist/agent-fence.js +36 -8
- package/dist/approval-authority.js +6 -2
- package/dist/approval-rules-ui.d.ts +11 -0
- package/dist/approval-rules-ui.js +13 -1
- package/dist/assignment.js +3 -0
- package/dist/batch-checks.js +3 -1
- package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +31 -31
- package/dist/builder.d.ts +14 -0
- package/dist/builder.js +141 -42
- package/dist/chat-decide.js +4 -0
- package/dist/check-failure.d.ts +92 -0
- package/dist/check-failure.js +267 -0
- package/dist/check-progress.d.ts +28 -1
- package/dist/check-progress.js +105 -6
- package/dist/check-report.d.ts +52 -6
- package/dist/check-report.js +193 -8
- package/dist/checkout-cleanup.js +2 -2
- package/dist/cli.js +8 -5
- package/dist/coding-handoff.d.ts +1 -0
- package/dist/coding-handoff.js +20 -11
- package/dist/coding-workspace.js +7 -5
- package/dist/commit-extract.d.ts +6 -0
- package/dist/commit-extract.js +63 -8
- package/dist/console-chrome.d.ts +41 -0
- package/dist/console-chrome.js +166 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/release-check.d.ts +12 -0
- package/dist/contracts/release-check.js +17 -0
- package/dist/contracts/verification-receipt.d.ts +20 -2
- package/dist/contracts/verification-receipt.js +15 -3
- package/dist/contracts/verification-view.d.ts +8 -0
- package/dist/controller-supervisor.d.ts +8 -0
- package/dist/controller-supervisor.js +33 -1
- package/dist/demo.js +3 -2
- package/dist/deploy-gate.d.ts +3 -0
- package/dist/deploy-gate.js +8 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discover.js +6 -1
- package/dist/dispose.d.ts +3 -0
- package/dist/dispose.js +6 -3
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +10 -2
- package/dist/envelope.js +2 -1
- package/dist/evidence.js +4 -1
- package/dist/exec.d.ts +7 -0
- package/dist/exec.js +7 -1
- package/dist/first-run.js +3 -1
- package/dist/git-floor.d.ts +16 -0
- package/dist/git-floor.js +36 -0
- package/dist/git-incident.d.ts +49 -0
- package/dist/git-incident.js +69 -0
- package/dist/git-views.d.ts +20 -0
- package/dist/git-views.js +120 -0
- package/dist/grant.js +7 -1
- package/dist/guides.js +4 -3
- package/dist/operate.d.ts +13 -0
- package/dist/operate.js +779 -319
- package/dist/planner.js +5 -1
- package/dist/prepared-evidence.d.ts +2 -1
- package/dist/prepared-evidence.js +8 -3
- package/dist/project-knowledge.js +6 -2
- package/dist/project-learning.js +23 -3
- package/dist/proof.js +6 -0
- package/dist/pull-request-flow.js +4 -0
- package/dist/read-executor-worker.js +8 -4
- package/dist/read-executor.d.ts +18 -1
- package/dist/read-executor.js +8 -0
- package/dist/release-adapters.d.ts +71 -2
- package/dist/release-adapters.js +471 -12
- package/dist/release-authority.d.ts +46 -0
- package/dist/release-authority.js +68 -0
- package/dist/release-auto.d.ts +184 -0
- package/dist/release-auto.js +612 -0
- package/dist/release-card.d.ts +15 -3
- package/dist/release-card.js +25 -9
- package/dist/release-ci-hold.d.ts +41 -0
- package/dist/release-ci-hold.js +53 -0
- package/dist/release-cli.js +77 -14
- package/dist/release-coverage.js +5 -2
- package/dist/release-recheck.d.ts +1 -1
- package/dist/release-recheck.js +6 -1
- package/dist/release.d.ts +207 -6
- package/dist/release.js +495 -20
- package/dist/repository-context.js +4 -6
- package/dist/result-follow-ups.js +3 -1
- package/dist/review-context.js +8 -6
- package/dist/runner.js +2 -1
- package/dist/sandbox-runtime.d.ts +8 -0
- package/dist/sandbox-runtime.js +14 -2
- package/dist/scope.d.ts +31 -2
- package/dist/scope.js +51 -4
- package/dist/scout.js +5 -1
- package/dist/secret-mint.d.ts +1 -0
- package/dist/secret-mint.js +14 -0
- package/dist/serve.js +101 -51
- package/dist/server/chat.js +5 -5
- package/dist/server/render-tasks.d.ts +12 -0
- package/dist/server/render-tasks.js +25 -14
- package/dist/server/request-context.d.ts +6 -0
- package/dist/server/route-table.js +3 -0
- package/dist/server/runtime.d.ts +2 -0
- package/dist/server/settings.js +29 -2
- package/dist/server/task-views.js +15 -2
- package/dist/setup-guide.d.ts +2 -2
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +28 -1
- package/dist/store.d.ts +11 -1
- package/dist/store.js +44 -5
- package/dist/task-base.d.ts +4 -0
- package/dist/task-base.js +22 -2
- package/dist/teardown-git.d.ts +90 -13
- package/dist/teardown-git.js +1027 -129
- package/dist/test-infra.d.ts +26 -0
- package/dist/test-infra.js +321 -0
- package/dist/up-processes.d.ts +137 -0
- package/dist/up-processes.js +165 -0
- package/dist/up-role-child.d.ts +1 -0
- package/dist/up-role-child.js +77 -0
- package/dist/verification-evidence.d.ts +1 -0
- package/dist/verification-evidence.js +13 -1
- package/dist/worktree.d.ts +4 -0
- package/dist/worktree.js +13 -7
- package/package.json +2 -1
package/dist/teardown-git.js
CHANGED
|
@@ -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 {
|
|
16
|
-
import {
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
39
|
-
const
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
/**
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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
|
-
/**
|
|
216
|
-
*
|
|
217
|
-
function
|
|
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
|
|
220
|
-
return
|
|
221
|
-
const parsed = spawnSync(
|
|
222
|
-
|
|
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
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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",
|
|
525
|
+
writeFileSync(join(view, "info", name), text, { mode: 0o600 });
|
|
262
526
|
}
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
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(
|
|
290
|
-
return parsed.status !== 0 || parsed.stdout.split("\0").filter(Boolean).some(name => lfsCommand(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
|
-
/**
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
|
|
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
|
-
|
|
306
|
-
|
|
1059
|
+
const once = async () => {
|
|
1060
|
+
let view;
|
|
307
1061
|
try {
|
|
308
|
-
view =
|
|
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
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
|
392
|
-
if (
|
|
393
|
-
|
|
394
|
-
|
|
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
|
+
}
|