@mnaimfaizy/agent-kit 0.0.0-stage → 1.0.0-beta.1

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.
@@ -0,0 +1,257 @@
1
+ // Fetches Agent-kit tags into a throwaway bare repository and reads files,
2
+ // file lists and tree hashes from them. Nothing is bundled in the npm
3
+ // package: the git tag Consumers pin in `uses:` is the only source of kit
4
+ // files (ADR 0001). One repository can hold two tags, so `upgrade` can diff
5
+ // them.
6
+
7
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { REPO_PATTERN } from "./checks/result.mjs";
11
+ import { firstLine } from "./exec.mjs";
12
+ import { parseKitOwnedPaths } from "./kit-owned-paths.mjs";
13
+ import { caseCollisions, checkSafePath, fileDirConflicts } from "./safe-paths.mjs";
14
+ import { FIRST_MANIFEST_TAG, isReleaseTag } from "./tags.mjs";
15
+
16
+ const SAFE_PATH = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\/?$/;
17
+ export const DEFAULT_SERVER = "https://github.com";
18
+ const SERVER = /^https:\/\/[A-Za-z0-9.-]+(?::\d+)?$/;
19
+
20
+ /** GITHUB_SERVER_URL when it is a valid server URL (set on GitHub Enterprise Server too), else github.com. */
21
+ export function serverFromEnv(env) {
22
+ const fromEnv = String(env.GITHUB_SERVER_URL ?? "").replace(/\/$/, "");
23
+ return SERVER.test(fromEnv) ? fromEnv : DEFAULT_SERVER;
24
+ }
25
+
26
+ /** The git modes of an executable and of a regular file. */
27
+ export const EXECUTABLE_MODE = "100755";
28
+ export const REGULAR_MODE = "100644";
29
+
30
+ /** File modes the CLI copies: a regular file, or an executable one. */
31
+ const FILE_MODES = new Set([REGULAR_MODE, EXECUTABLE_MODE]);
32
+ const MODE_NAMES = { 120000: "a symbolic link", 160000: "a submodule" };
33
+ const OID = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
34
+
35
+ export class KitTagError extends Error {}
36
+
37
+ /** A tag with no kit-owned-paths.txt (up to v1.0.0-alpha.10): the CLI refuses it. */
38
+ export class PreManifestTagError extends KitTagError {
39
+ constructor(repo, tag, serverUrl = DEFAULT_SERVER) {
40
+ super(
41
+ `${tag} has no kit-owned-paths.txt, so the CLI cannot handle it ` +
42
+ `(the manifest ships from ${FIRST_MANIFEST_TAG}). Adopt or upgrade ${tag} by hand ` +
43
+ `with that tag's ${serverUrl}/${repo}/blob/${tag}/docs/adopt.md, ` +
44
+ `or move your pins to ${FIRST_MANIFEST_TAG} or later.`,
45
+ );
46
+ this.repo = repo;
47
+ this.tag = tag;
48
+ }
49
+ }
50
+
51
+ function checkPath(path) {
52
+ if (!SAFE_PATH.test(path) || path.split("/").includes("..")) {
53
+ throw new KitTagError(`refusing to read ${JSON.stringify(path)} from the tag`);
54
+ }
55
+ return path.replace(/\/$/, "");
56
+ }
57
+
58
+ function checkArgs({ repo, serverUrl }) {
59
+ if (!REPO_PATTERN.test(repo)) {
60
+ throw new KitTagError(`not a GitHub repository name: ${JSON.stringify(repo)}`);
61
+ }
62
+ if (!SERVER.test(serverUrl)) {
63
+ throw new KitTagError(`not a GitHub server URL: ${JSON.stringify(serverUrl)}`);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Open a throwaway bare repository for `repo`, to fetch one or more tags
69
+ * into. Call `cleanup()` when done.
70
+ *
71
+ * @param {{
72
+ * runner: import("./exec.mjs").Runner,
73
+ * repo: string,
74
+ * serverUrl?: string, // https://github.com, or a GitHub Enterprise Server host
75
+ * remoteUrl?: string, // tests: fetch from here instead
76
+ * }} opts
77
+ */
78
+ export function openKitRepo({ runner, repo, serverUrl = DEFAULT_SERVER, remoteUrl }) {
79
+ checkArgs({ repo, serverUrl });
80
+ const url = remoteUrl ?? `${serverUrl}/${repo}.git`;
81
+ const tmp = mkdtempSync(join(tmpdir(), "agent-kit-tag-"));
82
+ const dir = join(tmp, "repo.git");
83
+ const noHooks = join(tmp, "no-hooks");
84
+ mkdirSync(noHooks);
85
+ const cleanup = () => rmSync(tmp, { recursive: true, force: true });
86
+ // No hooks run, whatever the adopter's global git config says.
87
+ const base = ["-C", dir, "-c", `core.hooksPath=${noHooks}`];
88
+ const git = (...args) => runner("git", [...base, ...args]);
89
+ const gitBytes = (...args) => runner("git", [...base, ...args], { encoding: "buffer" });
90
+ try {
91
+ const r = runner("git", ["init", "--bare", "--quiet", "--template=", dir]);
92
+ if (r.status !== 0) throw new KitTagError(`git init failed: ${firstLine(r.stderr)}`);
93
+ } catch (err) {
94
+ cleanup();
95
+ throw err;
96
+ }
97
+
98
+ /**
99
+ * Fetch `tag` into the repository. It must carry kit-owned-paths.txt, or
100
+ * this throws PreManifestTagError.
101
+ */
102
+ const fetchTag = (tag, { forDeletion = true } = {}) => {
103
+ if (!isReleaseTag(tag)) throw new KitTagError(`not a Release-line tag: ${JSON.stringify(tag)}`);
104
+ let r = git(
105
+ "-c",
106
+ "fetch.fsckObjects=true",
107
+ "fetch",
108
+ "--quiet",
109
+ "--depth=1",
110
+ "--no-tags",
111
+ "--",
112
+ url,
113
+ `+refs/tags/${tag}:refs/tags/${tag}`,
114
+ );
115
+ if (r.status !== 0) {
116
+ throw new KitTagError(
117
+ `could not fetch ${tag} from ${repo}: ${firstLine(r.stderr) || "git fetch failed"}`,
118
+ );
119
+ }
120
+ r = git("rev-parse", "--verify", "--quiet", `refs/tags/${tag}^{commit}`);
121
+ if (r.status !== 0) throw new KitTagError(`${tag} does not point at a commit`);
122
+ const commit = r.stdout.trim();
123
+
124
+ const readFile = (path) => {
125
+ const res = git("cat-file", "blob", `${commit}:${checkPath(path)}`);
126
+ return res.status === 0 ? res.stdout : null;
127
+ };
128
+ const treeHash = (path) => {
129
+ // `<rev>:<path>` takes the rest as a path, so `^{tree}` cannot follow it:
130
+ // resolve the object, then check its type.
131
+ const res = git("rev-parse", "--verify", "--quiet", `${commit}:${checkPath(path)}`);
132
+ if (res.status !== 0) return null;
133
+ const hash = res.stdout.trim();
134
+ const type = git("cat-file", "-t", hash);
135
+ return type.status === 0 && type.stdout.trim() === "tree" ? hash : null;
136
+ };
137
+
138
+ /**
139
+ * Every file under folder `path` at the tag, as `{ path, mode, oid }`.
140
+ * Throws KitTagError for anything that is not a regular or executable
141
+ * file (a symbolic link, a submodule), for a file path that is not safe
142
+ * to write on every platform, and for two paths that would be one file
143
+ * on a case-insensitive file system.
144
+ */
145
+ const listFiles = (path) => {
146
+ const folder = checkPath(path);
147
+ const res = git("ls-tree", "-r", "-z", "--full-tree", commit, "--", folder);
148
+ if (res.status !== 0) {
149
+ throw new KitTagError(`could not list ${folder}/ at ${tag}: ${firstLine(res.stderr)}`);
150
+ }
151
+ const files = [];
152
+ for (const entry of res.stdout.split("\0").filter(Boolean)) {
153
+ const m = /^(\d{6}) (\w+) ([0-9a-f]+)\t(.+)$/s.exec(entry);
154
+ if (!m) throw new KitTagError(`unexpected git ls-tree output at ${tag}`);
155
+ const [, mode, type, oid, file] = m;
156
+ if (!file.startsWith(`${folder}/`)) continue;
157
+ if (!FILE_MODES.has(mode) || type !== "blob" || !OID.test(oid)) {
158
+ const kind = MODE_NAMES[mode] ?? `mode ${mode}`;
159
+ throw new KitTagError(
160
+ `${tag} has ${JSON.stringify(file)} as ${kind}; the CLI copies only regular files`,
161
+ );
162
+ }
163
+ try {
164
+ checkSafePath(file, `${tag} file`);
165
+ } catch (err) {
166
+ throw new KitTagError(err.message);
167
+ }
168
+ files.push({ path: file, mode, oid });
169
+ }
170
+ const names = files.map((f) => f.path);
171
+ const collisions = caseCollisions(names);
172
+ if (collisions.length) {
173
+ throw new KitTagError(
174
+ `${tag} has files that differ only in case, which would overwrite each other on ` +
175
+ `Windows and macOS: ${collisions.map((g) => g.join(" and ")).join("; ")}`,
176
+ );
177
+ }
178
+ const conflicts = fileDirConflicts(names);
179
+ if (conflicts.length) {
180
+ throw new KitTagError(
181
+ `${tag} has ${conflicts[0][0]} as both a file and a folder (${conflicts[0][1]})`,
182
+ );
183
+ }
184
+ return files;
185
+ };
186
+
187
+ /** The exact bytes of a blob listed by listFiles, as a Buffer. */
188
+ const readBlob = (oid) => {
189
+ if (!OID.test(oid)) throw new KitTagError(`not a git object id: ${JSON.stringify(oid)}`);
190
+ const res = gitBytes("cat-file", "blob", oid);
191
+ if (res.status !== 0 || !Buffer.isBuffer(res.stdout)) {
192
+ throw new KitTagError(`could not read ${oid} at ${tag}: ${firstLine(res.stderr)}`);
193
+ }
194
+ return res.stdout;
195
+ };
196
+
197
+ const manifest = readFile("kit-owned-paths.txt");
198
+ if (manifest === null) throw new PreManifestTagError(repo, tag, serverUrl);
199
+ const kitOwnedPaths = parseKitOwnedPaths(manifest, { forDeletion });
200
+ return { repo, tag, commit, serverUrl, kitOwnedPaths, readFile, treeHash, listFiles, readBlob };
201
+ };
202
+
203
+ /**
204
+ * `git diff` of folder `path` between two fetched tags, as text. No
205
+ * external diff tool or textconv filter from the adopter's git config runs.
206
+ */
207
+ const diff = (oldSnapshot, newSnapshot, path) => {
208
+ const folder = checkPath(path);
209
+ const res = git(
210
+ "diff",
211
+ "--no-ext-diff",
212
+ "--no-textconv",
213
+ "--no-color",
214
+ oldSnapshot.commit,
215
+ newSnapshot.commit,
216
+ "--",
217
+ `${folder}/`,
218
+ );
219
+ if (res.status !== 0) throw new KitTagError(`git diff failed: ${firstLine(res.stderr)}`);
220
+ return res.stdout;
221
+ };
222
+
223
+ return { repo, serverUrl, fetchTag, diff, cleanup };
224
+ }
225
+
226
+ /**
227
+ * Fetch `tag` of `repo`. The tag must carry kit-owned-paths.txt, or this throws
228
+ * PreManifestTagError. Call `cleanup()` when done.
229
+ *
230
+ * @param {{
231
+ * runner: import("./exec.mjs").Runner,
232
+ * repo: string,
233
+ * tag: string,
234
+ * serverUrl?: string, // https://github.com, or a GitHub Enterprise Server host
235
+ * remoteUrl?: string, // tests: fetch from here instead
236
+ * forDeletion?: boolean // strict manifest shapes, for code that deletes paths
237
+ * }} opts
238
+ */
239
+ export function openKitTag({
240
+ runner,
241
+ repo,
242
+ tag,
243
+ serverUrl = DEFAULT_SERVER,
244
+ remoteUrl,
245
+ forDeletion = true,
246
+ }) {
247
+ checkArgs({ repo, serverUrl });
248
+ // Checked before any git runs, so a bad tag never reaches the command line.
249
+ if (!isReleaseTag(tag)) throw new KitTagError(`not a Release-line tag: ${JSON.stringify(tag)}`);
250
+ const kit = openKitRepo({ runner, repo, serverUrl, remoteUrl });
251
+ try {
252
+ return { ...kit.fetchTag(tag, { forDeletion }), cleanup: kit.cleanup };
253
+ } catch (err) {
254
+ kit.cleanup();
255
+ throw err;
256
+ }
257
+ }
@@ -0,0 +1,240 @@
1
+ // Git tree hashes of paths in the local working copy, as they would be if the
2
+ // working copy were committed now (tracked and untracked files, minus ignored
3
+ // ones, after git's own line-ending rules).
4
+ //
5
+ // Read-only: git runs against a temporary index built from HEAD, and writes
6
+ // any new objects to a temporary object directory that borrows the
7
+ // repository's objects through objects/info/alternates. The Consumer's index,
8
+ // object store and refs are never written. Because the temporary index holds
9
+ // only HEAD plus the Kit-owned paths, a merge conflict elsewhere in the
10
+ // repository does not get in the way.
11
+ //
12
+ // git runs no hooks (core.hooksPath is an empty folder), no fsmonitor, and no
13
+ // configured clean/process filter drivers, so hashing a checkout runs no
14
+ // command that the checkout's or the adopter's git config names.
15
+
16
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { isAbsolute, join, resolve } from "node:path";
19
+ import { firstLine } from "./exec.mjs";
20
+ import { EXECUTABLE_MODE, REGULAR_MODE } from "./kit-tag.mjs";
21
+
22
+ const MODES = new Set([EXECUTABLE_MODE, REGULAR_MODE]);
23
+
24
+ const SAFE_PATH = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\/$/;
25
+
26
+ export const SKIP_WORKTREE_REASON =
27
+ "some of its files are marked skip-worktree (for example by a sparse checkout) and HEAD has no copy of them to compare";
28
+ export const HEAD_COPY_NOTE =
29
+ "its files are marked skip-worktree (for example by a sparse checkout), so the copy committed at HEAD was compared";
30
+
31
+ export class LocalTreeError extends Error {}
32
+
33
+ /** `filter.<driver>.<key>` config names → the driver names. */
34
+ export function filterDrivers(configNames) {
35
+ const drivers = new Set();
36
+ for (const raw of configNames) {
37
+ const m = /^filter\.(.+)\.[^.]+$/i.exec(raw.trim());
38
+ if (m) drivers.add(m[1]);
39
+ }
40
+ return [...drivers];
41
+ }
42
+
43
+ /**
44
+ * True when core.fileMode is off (the default on Windows): git then cannot
45
+ * see an executable bit on disk and takes a file's mode from the index.
46
+ */
47
+ export function fileModeIsOff(runner, root) {
48
+ const r = runner("git", ["-C", root, "config", "--type=bool", "core.fileMode"]);
49
+ return r.status === 0 && r.stdout.trim() === "false";
50
+ }
51
+
52
+ /**
53
+ * `-c` options that keep git from running anything the checkout's or the
54
+ * adopter's git config names: no hooks (`noHooks` is an empty folder), no
55
+ * fsmonitor, no background maintenance, and no clean/process filter drivers.
56
+ */
57
+ export function gitSafetyArgs({ runner, root, noHooks }) {
58
+ const safety = [
59
+ "-c",
60
+ `core.hooksPath=${noHooks}`,
61
+ "-c",
62
+ "core.fsmonitor=false",
63
+ "-c",
64
+ "gc.auto=0",
65
+ "-c",
66
+ "maintenance.auto=false",
67
+ ];
68
+ const listed = runner("git", ["-C", root, "config", "--name-only", "--get-regexp", "^filter\\."]);
69
+ for (const driver of filterDrivers(listed.status === 0 ? listed.stdout.split("\n") : [])) {
70
+ if (/[=\r\n]/.test(driver)) {
71
+ throw new LocalTreeError(`cannot turn off git filter driver ${JSON.stringify(driver)}`);
72
+ }
73
+ safety.push("-c", `filter.${driver}.clean=`, "-c", `filter.${driver}.process=`);
74
+ safety.push("-c", `filter.${driver}.required=false`);
75
+ }
76
+ return safety;
77
+ }
78
+
79
+ /**
80
+ * @param {{ runner: import("./exec.mjs").Runner, cwd: string, paths: string[] }} opts
81
+ * @returns {{ root: string, hashes: Map<string, string | null>, skipped: Map<string, string>, notes: Map<string, string> }}
82
+ * A null hash means the path is missing locally; `skipped` holds paths that
83
+ * could not be compared, with the reason; `notes` explains how a path was compared.
84
+ */
85
+ export function localTreeHashes({ runner, cwd, paths }) {
86
+ for (const p of paths) {
87
+ if (!SAFE_PATH.test(p) || p.split("/").includes("..")) {
88
+ throw new LocalTreeError(`refusing to hash ${JSON.stringify(p)}`);
89
+ }
90
+ }
91
+ const top = runner("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
92
+ if (top.status !== 0) throw new LocalTreeError(`not a git repository: ${firstLine(top.stderr)}`);
93
+ const root = top.stdout.trim();
94
+ // --git-common-dir (not --path-format=absolute, which needs git 2.31) may
95
+ // be relative to `root`; the objects of a linked worktree live there too.
96
+ const common = runner("git", ["-C", root, "rev-parse", "--git-common-dir"]);
97
+ if (common.status !== 0) {
98
+ throw new LocalTreeError(`git rev-parse failed: ${firstLine(common.stderr)}`);
99
+ }
100
+ const commonDir = common.stdout.trim();
101
+ const realObjects = join(isAbsolute(commonDir) ? commonDir : resolve(root, commonDir), "objects");
102
+ if (/[\r\n]/.test(realObjects) || realObjects.startsWith('"')) {
103
+ throw new LocalTreeError("the repository path cannot be written to an alternates file");
104
+ }
105
+
106
+ const tmp = mkdtempSync(join(tmpdir(), "agent-kit-tree-"));
107
+ try {
108
+ const index = join(tmp, "index");
109
+ const objects = join(tmp, "objects");
110
+ const noHooks = join(tmp, "no-hooks");
111
+ mkdirSync(join(objects, "info"), { recursive: true });
112
+ mkdirSync(noHooks);
113
+ // An alternates file, not GIT_ALTERNATE_OBJECT_DIRECTORIES: that variable
114
+ // splits on ':' on POSIX, so a path containing one would break it.
115
+ writeFileSync(join(objects, "info", "alternates"), `${realObjects}\n`);
116
+
117
+ const safety = gitSafetyArgs({ runner, root, noHooks });
118
+ const base = ["-C", root, ...safety, "--literal-pathspecs"];
119
+ const env = { GIT_INDEX_FILE: index, GIT_OBJECT_DIRECTORY: objects };
120
+ const git = (...args) => runner("git", [...base, ...args], { env });
121
+ const real = (...args) => runner("git", [...base, ...args]);
122
+
123
+ const head = real("rev-parse", "--verify", "--quiet", "HEAD^{commit}");
124
+ // HEAD gives tracked files their recorded modes (core.fileMode=false on
125
+ // Windows would otherwise drop executable bits).
126
+ if (head.status === 0) {
127
+ const read = git("read-tree", "HEAD");
128
+ if (read.status !== 0) {
129
+ throw new LocalTreeError(`git read-tree HEAD failed: ${firstLine(read.stderr)}`);
130
+ }
131
+ }
132
+
133
+ // Without core.fileMode (the default on Windows) git cannot see an
134
+ // executable bit on disk and takes the mode from the index: so does this.
135
+ const trustIndexModes = fileModeIsOff(runner, root);
136
+
137
+ const hashes = new Map();
138
+ const skipped = new Map();
139
+ const notes = new Map();
140
+ for (const path of paths) {
141
+ // The real index (read only) knows skip-worktree entries (`S`), which
142
+ // have no files on disk. Everything else comes from the working copy
143
+ // (`git add`); only the S files are then reset to HEAD's copy, so local
144
+ // edits and new files elsewhere in the path still count.
145
+ const tags = real("ls-files", "-t", "-z", "--", path);
146
+ const entries = tags.status === 0 ? tags.stdout.split("\0").filter(Boolean) : [];
147
+ const skipFiles = entries.filter((e) => e.startsWith("S ")).map((e) => e.slice(2));
148
+ if (skipFiles.length) {
149
+ const inHead =
150
+ head.status === 0
151
+ ? real("ls-tree", "-r", "-z", "--name-only", "HEAD", "--", path)
152
+ : { status: 1, stdout: "" };
153
+ const committed = new Set(inHead.stdout.split("\0").filter(Boolean));
154
+ if (inHead.status !== 0 || skipFiles.some((f) => !committed.has(f))) {
155
+ skipped.set(path, SKIP_WORKTREE_REASON);
156
+ continue;
157
+ }
158
+ }
159
+ // --sparse: in a sparse checkout, a path outside the cone may still have
160
+ // stray files on disk, and plain `git add` refuses to touch it. git
161
+ // before 2.34 has no --sparse; it then gets a plain add.
162
+ let add = git("add", "--all", "--sparse", "--", path);
163
+ if (add.status !== 0 && /unknown option|usage: git add/i.test(add.stderr)) {
164
+ add = git("add", "--all", "--", path);
165
+ }
166
+ // Two refusals that still stage what can be staged, then exit 1: files
167
+ // force-added under an ignored folder, and (old git) a path outside
168
+ // the sparse-checkout definition.
169
+ const benign =
170
+ /ignored by one of your \.gitignore files|outside of your sparse-checkout definition/i.test(
171
+ add.stderr,
172
+ );
173
+ if (add.status !== 0 && !benign && existsSync(join(root, path))) {
174
+ throw new LocalTreeError(`git add ${path} failed: ${firstLine(add.stderr)}`);
175
+ }
176
+ for (let i = 0; i < skipFiles.length; i += 200) {
177
+ const reset = git("reset", "--quiet", "HEAD", "--", ...skipFiles.slice(i, i + 200));
178
+ if (reset.status !== 0) {
179
+ throw new LocalTreeError(
180
+ `git reset of skip-worktree files failed: ${firstLine(reset.stderr)}`,
181
+ );
182
+ }
183
+ }
184
+ if (trustIndexModes) {
185
+ const modes = (res) =>
186
+ new Map(
187
+ (res.status === 0 ? res.stdout.split("\0") : [])
188
+ .filter(Boolean)
189
+ .map((e) => [e.slice(e.indexOf("\t") + 1), e.slice(0, 6)]),
190
+ );
191
+ const inReal = modes(real("ls-files", "-s", "-z", "--", path));
192
+ const inTemp = modes(git("ls-files", "-s", "-z", "--", path));
193
+ // Both directions: the real index may have gained or lost the bit.
194
+ for (const [want, flag] of [
195
+ [EXECUTABLE_MODE, "--chmod=+x"],
196
+ [REGULAR_MODE, "--chmod=-x"],
197
+ ]) {
198
+ const fix = [...inReal]
199
+ .filter(
200
+ ([f, mode]) => mode === want && MODES.has(inTemp.get(f)) && inTemp.get(f) !== want,
201
+ )
202
+ .map(([f]) => f);
203
+ for (let i = 0; i < fix.length; i += 200) {
204
+ const chmod = git("update-index", flag, "--", ...fix.slice(i, i + 200));
205
+ if (chmod.status !== 0) {
206
+ throw new LocalTreeError(
207
+ `git update-index ${flag} failed: ${firstLine(chmod.stderr)}`,
208
+ );
209
+ }
210
+ }
211
+ }
212
+ }
213
+ const inIndex = git("ls-files", "-z", "--", path);
214
+ const staged = inIndex.status === 0 ? inIndex.stdout.split("\0").filter(Boolean) : [];
215
+ if (staged.length === 0) {
216
+ hashes.set(path, null);
217
+ continue;
218
+ }
219
+ if (skipFiles.length) {
220
+ // The HEAD-only wording only when nothing came from the working copy.
221
+ notes.set(
222
+ path,
223
+ staged.length === skipFiles.length
224
+ ? HEAD_COPY_NOTE
225
+ : `${skipFiles.length} of its ${staged.length} files are marked skip-worktree and were compared as committed at HEAD; the rest came from the working copy`,
226
+ );
227
+ }
228
+ const tree = git("write-tree", `--prefix=${path}`);
229
+ if (tree.status !== 0) {
230
+ throw new LocalTreeError(
231
+ `git write-tree --prefix=${path} failed: ${firstLine(tree.stderr)}`,
232
+ );
233
+ }
234
+ hashes.set(path, tree.stdout.trim());
235
+ }
236
+ return { root, hashes, skipped, notes };
237
+ } finally {
238
+ rmSync(tmp, { recursive: true, force: true });
239
+ }
240
+ }