@mnaimfaizy/agent-kit 1.0.0-alpha.12 → 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.
package/src/kit-tag.mjs CHANGED
@@ -1,6 +1,8 @@
1
- // Fetches one Agent-kit tag into a throwaway bare repository and reads files
2
- // and tree hashes from it. Nothing is bundled in the npm package: the git tag
3
- // Consumers pin in `uses:` is the only source of kit files (ADR 0001).
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.
4
6
 
5
7
  import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
6
8
  import { tmpdir } from "node:os";
@@ -8,12 +10,28 @@ import { join } from "node:path";
8
10
  import { REPO_PATTERN } from "./checks/result.mjs";
9
11
  import { firstLine } from "./exec.mjs";
10
12
  import { parseKitOwnedPaths } from "./kit-owned-paths.mjs";
13
+ import { caseCollisions, checkSafePath, fileDirConflicts } from "./safe-paths.mjs";
11
14
  import { FIRST_MANIFEST_TAG, isReleaseTag } from "./tags.mjs";
12
15
 
13
16
  const SAFE_PATH = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\/?$/;
14
17
  export const DEFAULT_SERVER = "https://github.com";
15
18
  const SERVER = /^https:\/\/[A-Za-z0-9.-]+(?::\d+)?$/;
16
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
+
17
35
  export class KitTagError extends Error {}
18
36
 
19
37
  /** A tag with no kit-owned-paths.txt (up to v1.0.0-alpha.10): the CLI refuses it. */
@@ -37,34 +55,28 @@ function checkPath(path) {
37
55
  return path.replace(/\/$/, "");
38
56
  }
39
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
+
40
67
  /**
41
- * Fetch `tag` of `repo`. The tag must carry kit-owned-paths.txt, or this throws
42
- * PreManifestTagError. Call `cleanup()` when done.
68
+ * Open a throwaway bare repository for `repo`, to fetch one or more tags
69
+ * into. Call `cleanup()` when done.
43
70
  *
44
71
  * @param {{
45
72
  * runner: import("./exec.mjs").Runner,
46
73
  * repo: string,
47
- * tag: string,
48
74
  * serverUrl?: string, // https://github.com, or a GitHub Enterprise Server host
49
75
  * remoteUrl?: string, // tests: fetch from here instead
50
- * forDeletion?: boolean // strict manifest shapes, for code that deletes paths
51
76
  * }} opts
52
77
  */
53
- export function openKitTag({
54
- runner,
55
- repo,
56
- tag,
57
- serverUrl = DEFAULT_SERVER,
58
- remoteUrl,
59
- forDeletion = true,
60
- }) {
61
- if (!REPO_PATTERN.test(repo)) {
62
- throw new KitTagError(`not a GitHub repository name: ${JSON.stringify(repo)}`);
63
- }
64
- if (!isReleaseTag(tag)) throw new KitTagError(`not a Release-line tag: ${JSON.stringify(tag)}`);
65
- if (!SERVER.test(serverUrl)) {
66
- throw new KitTagError(`not a GitHub server URL: ${JSON.stringify(serverUrl)}`);
67
- }
78
+ export function openKitRepo({ runner, repo, serverUrl = DEFAULT_SERVER, remoteUrl }) {
79
+ checkArgs({ repo, serverUrl });
68
80
  const url = remoteUrl ?? `${serverUrl}/${repo}.git`;
69
81
  const tmp = mkdtempSync(join(tmpdir(), "agent-kit-tag-"));
70
82
  const dir = join(tmp, "repo.git");
@@ -72,11 +84,26 @@ export function openKitTag({
72
84
  mkdirSync(noHooks);
73
85
  const cleanup = () => rmSync(tmp, { recursive: true, force: true });
74
86
  // No hooks run, whatever the adopter's global git config says.
75
- const git = (...args) => runner("git", ["-C", dir, "-c", `core.hooksPath=${noHooks}`, ...args]);
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" });
76
90
  try {
77
- let r = runner("git", ["init", "--bare", "--quiet", "--template=", dir]);
91
+ const r = runner("git", ["init", "--bare", "--quiet", "--template=", dir]);
78
92
  if (r.status !== 0) throw new KitTagError(`git init failed: ${firstLine(r.stderr)}`);
79
- r = git(
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",
80
107
  "fetch",
81
108
  "--quiet",
82
109
  "--depth=1",
@@ -108,12 +135,123 @@ export function openKitTag({
108
135
  return type.status === 0 && type.stdout.trim() === "tree" ? hash : null;
109
136
  };
110
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
+
111
197
  const manifest = readFile("kit-owned-paths.txt");
112
198
  if (manifest === null) throw new PreManifestTagError(repo, tag, serverUrl);
113
199
  const kitOwnedPaths = parseKitOwnedPaths(manifest, { forDeletion });
114
- return { repo, tag, commit, serverUrl, kitOwnedPaths, readFile, treeHash, cleanup };
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 };
115
253
  } catch (err) {
116
- cleanup();
254
+ kit.cleanup();
117
255
  throw err;
118
256
  }
119
257
  }
@@ -17,6 +17,9 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:
17
17
  import { tmpdir } from "node:os";
18
18
  import { isAbsolute, join, resolve } from "node:path";
19
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]);
20
23
 
21
24
  const SAFE_PATH = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\/$/;
22
25
 
@@ -37,6 +40,42 @@ export function filterDrivers(configNames) {
37
40
  return [...drivers];
38
41
  }
39
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
+
40
79
  /**
41
80
  * @param {{ runner: import("./exec.mjs").Runner, cwd: string, paths: string[] }} opts
42
81
  * @returns {{ root: string, hashes: Map<string, string | null>, skipped: Map<string, string>, notes: Map<string, string> }}
@@ -75,29 +114,7 @@ export function localTreeHashes({ runner, cwd, paths }) {
75
114
  // splits on ':' on POSIX, so a path containing one would break it.
76
115
  writeFileSync(join(objects, "info", "alternates"), `${realObjects}\n`);
77
116
 
78
- const safety = [
79
- "-c",
80
- `core.hooksPath=${noHooks}`,
81
- "-c",
82
- "core.fsmonitor=false",
83
- "-c",
84
- "gc.auto=0",
85
- ];
86
- const listed = runner("git", [
87
- "-C",
88
- root,
89
- "config",
90
- "--name-only",
91
- "--get-regexp",
92
- "^filter\\.",
93
- ]);
94
- for (const driver of filterDrivers(listed.status === 0 ? listed.stdout.split("\n") : [])) {
95
- if (/[=\r\n]/.test(driver)) {
96
- throw new LocalTreeError(`cannot turn off git filter driver ${JSON.stringify(driver)}`);
97
- }
98
- safety.push("-c", `filter.${driver}.clean=`, "-c", `filter.${driver}.process=`);
99
- safety.push("-c", `filter.${driver}.required=false`);
100
- }
117
+ const safety = gitSafetyArgs({ runner, root, noHooks });
101
118
  const base = ["-C", root, ...safety, "--literal-pathspecs"];
102
119
  const env = { GIT_INDEX_FILE: index, GIT_OBJECT_DIRECTORY: objects };
103
120
  const git = (...args) => runner("git", [...base, ...args], { env });
@@ -113,6 +130,10 @@ export function localTreeHashes({ runner, cwd, paths }) {
113
130
  }
114
131
  }
115
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
+
116
137
  const hashes = new Map();
117
138
  const skipped = new Map();
118
139
  const notes = new Map();
@@ -160,6 +181,35 @@ export function localTreeHashes({ runner, cwd, paths }) {
160
181
  );
161
182
  }
162
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
+ }
163
213
  const inIndex = git("ls-files", "-z", "--", path);
164
214
  const staged = inIndex.status === 0 ? inIndex.stdout.split("\0").filter(Boolean) : [];
165
215
  if (staged.length === 0) {
@@ -0,0 +1,307 @@
1
+ // What `init` and `upgrade` share for changing a Consumer's checkout: path
2
+ // checks, the uncommitted-changes check, reading Kit-owned files from a tag,
3
+ // and carrying out a plan. Both commands plan first and write second: every
4
+ // refusal (a Refusal) is raised while planning, before anything is written.
5
+
6
+ import {
7
+ chmodSync,
8
+ lstatSync,
9
+ mkdirSync,
10
+ mkdtempSync,
11
+ readdirSync,
12
+ rmSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+ import { firstLine } from "./exec.mjs";
18
+ import { EXECUTABLE_MODE } from "./kit-tag.mjs";
19
+ import { fileModeIsOff, gitSafetyArgs } from "./local-tree.mjs";
20
+ import { checkSafePath } from "./safe-paths.mjs";
21
+
22
+ /** A reason not to start. Nothing has been written when this is thrown. */
23
+ export class Refusal extends Error {
24
+ constructor(message, details = []) {
25
+ super(message);
26
+ this.details = details;
27
+ }
28
+ }
29
+
30
+ /** The most items a plan or a refusal lists before "…". */
31
+ export const MAX_LISTED = 20;
32
+
33
+ /** `items`, cut to MAX_LISTED with a closing "…" when there are more. */
34
+ export function listed(items) {
35
+ return items.length > MAX_LISTED ? [...items.slice(0, MAX_LISTED), "…"] : items;
36
+ }
37
+
38
+ /** Print a refusal and its details; the exit code is 1. */
39
+ export function reportRefusal(out, err) {
40
+ out(`REFUSED ${err.message}`);
41
+ for (const d of err.details ?? []) out(` ${d}`);
42
+ out("Nothing was changed.");
43
+ return 1;
44
+ }
45
+
46
+ export function lstatOrNull(path) {
47
+ try {
48
+ return lstatSync(path);
49
+ } catch (err) {
50
+ if (err.code === "ENOENT" || err.code === "ENOTDIR") return null;
51
+ throw err;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Refuse unless `rel` (a repository-relative path, no trailing slash) can be
57
+ * deleted and written without leaving `root`: it must be a safe path (the
58
+ * strict rules for names that come from a tag), no part of it may be a
59
+ * symbolic link or junction, and every existing parent must be a folder.
60
+ */
61
+ export function checkLocalPath(root, rel, { deleted = [] } = {}) {
62
+ try {
63
+ checkSafePath(rel);
64
+ } catch (err) {
65
+ throw new Refusal(err.message);
66
+ }
67
+ checkNoLinks(root, rel, { deleted });
68
+ }
69
+
70
+ /**
71
+ * Refuse unless no part of `rel` is a symbolic link or junction and every
72
+ * existing parent is a folder. For the Consumer's own files, rewritten in
73
+ * place, whose names need not follow the strict tag rules.
74
+ *
75
+ * `deleted` lists folders the plan deletes before it writes: what is on disk
76
+ * inside them now (a file the new tag turns into a folder, say) is gone by
77
+ * the time `rel` is written, so the walk stops there.
78
+ */
79
+ export function checkNoLinks(root, rel, { deleted = [] } = {}) {
80
+ const parts = rel.split("/");
81
+ for (let i = 1; i <= parts.length; i++) {
82
+ const sub = parts.slice(0, i).join("/");
83
+ if (deleted.includes(sub)) return;
84
+ const st = lstatOrNull(join(root, sub));
85
+ if (!st) return;
86
+ if (st.isSymbolicLink()) {
87
+ throw new Refusal(
88
+ `${sub} is a symbolic link or junction; the CLI does not delete or write through links. ` +
89
+ `Replace it with a plain folder or file, then run the command again.`,
90
+ );
91
+ }
92
+ if (i < parts.length && !st.isDirectory()) {
93
+ throw new Refusal(`${sub} is a file, but ${rel} needs it to be a folder.`);
94
+ }
95
+ }
96
+ }
97
+
98
+ /** Links inside `rel`: never followed or guessed at. */
99
+ function linksUnder(root, rel) {
100
+ const found = [];
101
+ const walk = (sub) => {
102
+ const st = lstatOrNull(join(root, sub));
103
+ if (!st) return;
104
+ if (st.isSymbolicLink()) found.push(sub);
105
+ else if (st.isDirectory()) {
106
+ for (const name of readdirSync(join(root, sub)).sort()) walk(`${sub}/${name}`);
107
+ }
108
+ };
109
+ walk(rel);
110
+ return found;
111
+ }
112
+
113
+ /** Refuse unless folder `rel` can be deleted whole: checkLocalPath, and no link inside it. */
114
+ export function checkDeletable(root, rel) {
115
+ checkLocalPath(root, rel);
116
+ const links = linksUnder(root, rel);
117
+ if (links.length) {
118
+ throw new Refusal(
119
+ `${rel}/ holds a symbolic link or junction, which the kit never ships: ${links[0]}. ` +
120
+ `Remove it by hand, then run the command again.`,
121
+ );
122
+ }
123
+ }
124
+
125
+ /** A temporary empty hooks folder and the safe git options for `root`; call done(). */
126
+ function safeGit(runner, root) {
127
+ const tmp = mkdtempSync(join(tmpdir(), "agent-kit-git-"));
128
+ const noHooks = join(tmp, "no-hooks");
129
+ mkdirSync(noHooks);
130
+ try {
131
+ return {
132
+ args: ["-C", root, ...gitSafetyArgs({ runner, root, noHooks }), "--literal-pathspecs"],
133
+ done: () => rmSync(tmp, { recursive: true, force: true }),
134
+ };
135
+ } catch (err) {
136
+ rmSync(tmp, { recursive: true, force: true });
137
+ throw err;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Files under `paths` with uncommitted changes (modified, staged, deleted or
143
+ * untracked), as "XY path", and, apart, the ignored files and folders there
144
+ * (deleting the paths deletes those too). git runs no hooks, fsmonitor or
145
+ * filter drivers, and takes no optional locks.
146
+ *
147
+ * @returns {{ changed: string[], ignored: string[] }}
148
+ */
149
+ export function uncommittedChanges({ runner, root, paths }) {
150
+ const git = safeGit(runner, root);
151
+ try {
152
+ const r = runner("git", [
153
+ ...git.args,
154
+ "--no-optional-locks",
155
+ "status",
156
+ "--porcelain=v1",
157
+ "-z",
158
+ "--untracked-files=all",
159
+ "--ignored=matching",
160
+ "--ignore-submodules=all",
161
+ "--no-renames",
162
+ "--",
163
+ ...paths,
164
+ ]);
165
+ if (r.status !== 0) throw new Refusal(`git status failed: ${firstLine(r.stderr)}`);
166
+ const entries = r.stdout.split("\0").filter(Boolean);
167
+ // git status cannot see local edits to files marked assume-unchanged
168
+ // (a lower-case tag) or skip-worktree (S): count those as changed.
169
+ const flagged = runner("git", [...git.args, "ls-files", "-v", "-z", "--", ...paths]);
170
+ if (flagged.status !== 0)
171
+ throw new Refusal(`git ls-files failed: ${firstLine(flagged.stderr)}`);
172
+ const hidden = flagged.stdout
173
+ .split("\0")
174
+ .filter((e) => /^(?:[a-z]|S) /.test(e))
175
+ .map(
176
+ (e) =>
177
+ `${e[0]} ${e.slice(2)} (marked ${e[0] === "S" ? "skip-worktree" : "assume-unchanged"})`,
178
+ );
179
+ return {
180
+ changed: [
181
+ ...entries
182
+ .filter((e) => !e.startsWith("!!"))
183
+ .map((e) => `${e.slice(0, 2).trim() || "?"} ${e.slice(3)}`),
184
+ ...hidden,
185
+ ],
186
+ ignored: entries.filter((e) => e.startsWith("!!")).map((e) => e.slice(3)),
187
+ };
188
+ } finally {
189
+ git.done();
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Every file of every Kit-owned path at the tag, with its bytes, ready to
195
+ * write. Refuses a listed path with no files.
196
+ *
197
+ * @returns {{ path: string, mode: string, bytes: Buffer }[]}
198
+ */
199
+ export function kitFileWrites(snap) {
200
+ const writes = [];
201
+ for (const path of snap.kitOwnedPaths) {
202
+ const listed = snap.listFiles(path);
203
+ if (listed.length === 0) {
204
+ throw new Refusal(
205
+ `${snap.tag}'s kit-owned-paths.txt lists ${path}, but the tag has no files there.`,
206
+ );
207
+ }
208
+ for (const f of listed) {
209
+ writes.push({ path: f.path, mode: f.mode, bytes: snap.readBlob(f.oid) });
210
+ }
211
+ }
212
+ return writes;
213
+ }
214
+
215
+ /**
216
+ * Carry out a plan: delete each folder in `deletes`, write each file in
217
+ * `writes` (paths from a tag), then each file in `rewrites` (the Consumer's
218
+ * own files, rewritten in place, whose names need not follow the tag rules).
219
+ * Only file-system errors can stop it part-way.
220
+ *
221
+ * @param {string} root
222
+ * @param {{ deletes?: string[], writes: { path: string, mode?: string, bytes: Buffer | string }[], rewrites?: { path: string, bytes: Buffer | string }[] }} plan
223
+ */
224
+ export function applyPlan(root, { deletes = [], writes, rewrites = [] }) {
225
+ // Checked again here: time may have passed at the confirmation prompt.
226
+ for (const rel of deletes) checkDeletable(root, rel);
227
+ for (const w of writes) checkLocalPath(root, w.path, { deleted: deletes });
228
+ for (const w of rewrites) checkNoLinks(root, w.path);
229
+ for (const rel of deletes) rmSync(join(root, rel), { recursive: true, force: true });
230
+ for (const w of [...writes, ...rewrites]) {
231
+ const target = join(root, w.path);
232
+ mkdirSync(dirname(target), { recursive: true });
233
+ const executable = w.mode === EXECUTABLE_MODE;
234
+ writeFileSync(target, w.bytes, { mode: executable ? 0o755 : 0o644 });
235
+ if (executable) chmodSync(target, 0o755);
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Without core.fileMode (the default on Windows) git cannot see an
241
+ * executable bit on disk, and `git add` keeps the mode the index already
242
+ * has (100644 for a new file). So stage the tag's mode: +x for each
243
+ * executable file written, and -x for each regular file the index still
244
+ * records as executable. Returns a line to print, or null when nothing was
245
+ * needed.
246
+ *
247
+ * @param {{ runner: import("./exec.mjs").Runner, root: string, writes: { path: string, mode?: string }[] }} opts
248
+ */
249
+ export function stageFileModes({ runner, root, writes }) {
250
+ if (writes.length === 0 || !fileModeIsOff(runner, root)) return null;
251
+ const git = safeGit(runner, root);
252
+ try {
253
+ const failed = [];
254
+ const add = writes.filter((w) => w.mode === EXECUTABLE_MODE).map((w) => w.path);
255
+ const regular = writes.filter((w) => w.mode !== EXECUTABLE_MODE).map((w) => w.path);
256
+ const regularSet = new Set(regular);
257
+ const drop = [];
258
+ for (const batch of batches(regular)) {
259
+ const indexed = runner("git", [...git.args, "ls-files", "-s", "-z", "--", ...batch]);
260
+ if (indexed.status !== 0) {
261
+ failed.push(`--chmod=-x (git ls-files: ${firstLine(indexed.stderr)}): ${batch.join(", ")}`);
262
+ continue;
263
+ }
264
+ drop.push(
265
+ ...indexed.stdout
266
+ .split("\0")
267
+ .filter((e) => e.startsWith(`${EXECUTABLE_MODE} `))
268
+ .map((e) => e.slice(e.indexOf("\t") + 1))
269
+ .filter((f) => regularSet.has(f)),
270
+ );
271
+ }
272
+ for (const [flag, files] of [
273
+ ["--chmod=+x", add],
274
+ ["--chmod=-x", drop],
275
+ ]) {
276
+ for (const batch of batches(files)) {
277
+ // --sparse: a path outside a sparse-checkout cone is refused without
278
+ // it. git before 2.34 has no --sparse; it then gets a plain add.
279
+ let r = runner("git", [...git.args, "add", "--sparse", flag, "--", ...batch]);
280
+ if (r.status !== 0 && /unknown option|usage: git add/i.test(r.stderr)) {
281
+ r = runner("git", [...git.args, "add", flag, "--", ...batch]);
282
+ }
283
+ if (r.status !== 0) failed.push(`${flag} (${firstLine(r.stderr)}): ${batch.join(", ")}`);
284
+ }
285
+ }
286
+ if (failed.length) {
287
+ return (
288
+ "Could not stage the tag's file modes; after `git add`, run " +
289
+ `\`git update-index <flag> -- <file>\` for: ${failed.join("; ")}`
290
+ );
291
+ }
292
+ if (add.length + drop.length === 0) return null;
293
+ return (
294
+ `Staged the tag's file mode for ${add.length + drop.length} file(s) ` +
295
+ "(core.fileMode is off here, so git cannot see it on disk)."
296
+ );
297
+ } finally {
298
+ git.done();
299
+ }
300
+ }
301
+
302
+ /** Command-line-sized slices of a file list. */
303
+ function batches(files, size = 200) {
304
+ const out = [];
305
+ for (let i = 0; i < files.length; i += size) out.push(files.slice(i, i + size));
306
+ return out;
307
+ }