@mnaimfaizy/agent-kit 1.0.0-alpha.12 → 1.0.0-beta.2
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/README.md +9 -6
- package/bin/agent-kit.mjs +3 -1
- package/package.json +1 -1
- package/src/callers.mjs +38 -0
- package/src/checks/kit-owned-trees.mjs +47 -4
- package/src/checks/result.mjs +8 -2
- package/src/cli.mjs +150 -18
- package/src/doctor.mjs +43 -6
- package/src/exec.mjs +17 -4
- package/src/init.mjs +627 -0
- package/src/kit-tag.mjs +175 -28
- package/src/local-tree.mjs +142 -27
- package/src/local-writes.mjs +278 -0
- package/src/prompt.mjs +42 -0
- package/src/safe-paths.mjs +68 -0
- package/src/tags.mjs +15 -5
- package/src/templates.mjs +133 -0
- package/src/upgrade.mjs +288 -0
package/src/kit-tag.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
// Fetches
|
|
2
|
-
// and tree hashes from
|
|
3
|
-
// Consumers pin in `uses:` is the only source of kit
|
|
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,46 +55,64 @@ 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
|
-
*
|
|
42
|
-
*
|
|
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
|
|
54
|
-
|
|
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");
|
|
71
83
|
const noHooks = join(tmp, "no-hooks");
|
|
72
84
|
mkdirSync(noHooks);
|
|
73
|
-
|
|
85
|
+
// The retries are a fallback only: with background maintenance off (below)
|
|
86
|
+
// no git process is left writing in `tmp` once its command has returned.
|
|
87
|
+
const cleanup = () =>
|
|
88
|
+
rmSync(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
89
|
+
// No background maintenance: `git fetch` ends with `git maintenance run
|
|
90
|
+
// --auto`, which newer git detaches (maintenance.autoDetach, gc.autoDetach).
|
|
91
|
+
// The detached process outlives the fetch and can still be writing in the
|
|
92
|
+
// repository (objects/maintenance.lock, gc.pid) when cleanup() removes it,
|
|
93
|
+
// which failed cleanup with ENOTEMPTY.
|
|
94
|
+
const noBackground = ["-c", "maintenance.auto=false", "-c", "gc.auto=0"];
|
|
74
95
|
// No hooks run, whatever the adopter's global git config says.
|
|
75
|
-
const
|
|
96
|
+
const base = ["-C", dir, "-c", `core.hooksPath=${noHooks}`, ...noBackground];
|
|
97
|
+
const git = (...args) => runner("git", [...base, ...args]);
|
|
98
|
+
const gitBytes = (...args) => runner("git", [...base, ...args], { encoding: "buffer" });
|
|
76
99
|
try {
|
|
77
|
-
|
|
100
|
+
const r = runner("git", [...noBackground, "init", "--bare", "--quiet", "--template=", dir]);
|
|
78
101
|
if (r.status !== 0) throw new KitTagError(`git init failed: ${firstLine(r.stderr)}`);
|
|
79
|
-
|
|
102
|
+
} catch (err) {
|
|
103
|
+
cleanup();
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Fetch `tag` into the repository. It must carry kit-owned-paths.txt, or
|
|
109
|
+
* this throws PreManifestTagError.
|
|
110
|
+
*/
|
|
111
|
+
const fetchTag = (tag, { forDeletion = true } = {}) => {
|
|
112
|
+
if (!isReleaseTag(tag)) throw new KitTagError(`not a Release-line tag: ${JSON.stringify(tag)}`);
|
|
113
|
+
let r = git(
|
|
114
|
+
"-c",
|
|
115
|
+
"fetch.fsckObjects=true",
|
|
80
116
|
"fetch",
|
|
81
117
|
"--quiet",
|
|
82
118
|
"--depth=1",
|
|
@@ -108,12 +144,123 @@ export function openKitTag({
|
|
|
108
144
|
return type.status === 0 && type.stdout.trim() === "tree" ? hash : null;
|
|
109
145
|
};
|
|
110
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Every file under folder `path` at the tag, as `{ path, mode, oid }`.
|
|
149
|
+
* Throws KitTagError for anything that is not a regular or executable
|
|
150
|
+
* file (a symbolic link, a submodule), for a file path that is not safe
|
|
151
|
+
* to write on every platform, and for two paths that would be one file
|
|
152
|
+
* on a case-insensitive file system.
|
|
153
|
+
*/
|
|
154
|
+
const listFiles = (path) => {
|
|
155
|
+
const folder = checkPath(path);
|
|
156
|
+
const res = git("ls-tree", "-r", "-z", "--full-tree", commit, "--", folder);
|
|
157
|
+
if (res.status !== 0) {
|
|
158
|
+
throw new KitTagError(`could not list ${folder}/ at ${tag}: ${firstLine(res.stderr)}`);
|
|
159
|
+
}
|
|
160
|
+
const files = [];
|
|
161
|
+
for (const entry of res.stdout.split("\0").filter(Boolean)) {
|
|
162
|
+
const m = /^(\d{6}) (\w+) ([0-9a-f]+)\t(.+)$/s.exec(entry);
|
|
163
|
+
if (!m) throw new KitTagError(`unexpected git ls-tree output at ${tag}`);
|
|
164
|
+
const [, mode, type, oid, file] = m;
|
|
165
|
+
if (!file.startsWith(`${folder}/`)) continue;
|
|
166
|
+
if (!FILE_MODES.has(mode) || type !== "blob" || !OID.test(oid)) {
|
|
167
|
+
const kind = MODE_NAMES[mode] ?? `mode ${mode}`;
|
|
168
|
+
throw new KitTagError(
|
|
169
|
+
`${tag} has ${JSON.stringify(file)} as ${kind}; the CLI copies only regular files`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
checkSafePath(file, `${tag} file`);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
throw new KitTagError(err.message);
|
|
176
|
+
}
|
|
177
|
+
files.push({ path: file, mode, oid });
|
|
178
|
+
}
|
|
179
|
+
const names = files.map((f) => f.path);
|
|
180
|
+
const collisions = caseCollisions(names);
|
|
181
|
+
if (collisions.length) {
|
|
182
|
+
throw new KitTagError(
|
|
183
|
+
`${tag} has files that differ only in case, which would overwrite each other on ` +
|
|
184
|
+
`Windows and macOS: ${collisions.map((g) => g.join(" and ")).join("; ")}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const conflicts = fileDirConflicts(names);
|
|
188
|
+
if (conflicts.length) {
|
|
189
|
+
throw new KitTagError(
|
|
190
|
+
`${tag} has ${conflicts[0][0]} as both a file and a folder (${conflicts[0][1]})`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return files;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/** The exact bytes of a blob listed by listFiles, as a Buffer. */
|
|
197
|
+
const readBlob = (oid) => {
|
|
198
|
+
if (!OID.test(oid)) throw new KitTagError(`not a git object id: ${JSON.stringify(oid)}`);
|
|
199
|
+
const res = gitBytes("cat-file", "blob", oid);
|
|
200
|
+
if (res.status !== 0 || !Buffer.isBuffer(res.stdout)) {
|
|
201
|
+
throw new KitTagError(`could not read ${oid} at ${tag}: ${firstLine(res.stderr)}`);
|
|
202
|
+
}
|
|
203
|
+
return res.stdout;
|
|
204
|
+
};
|
|
205
|
+
|
|
111
206
|
const manifest = readFile("kit-owned-paths.txt");
|
|
112
207
|
if (manifest === null) throw new PreManifestTagError(repo, tag, serverUrl);
|
|
113
208
|
const kitOwnedPaths = parseKitOwnedPaths(manifest, { forDeletion });
|
|
114
|
-
return { repo, tag, commit, serverUrl, kitOwnedPaths, readFile, treeHash,
|
|
209
|
+
return { repo, tag, commit, serverUrl, kitOwnedPaths, readFile, treeHash, listFiles, readBlob };
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* `git diff` of folder `path` between two fetched tags, as text. No
|
|
214
|
+
* external diff tool or textconv filter from the adopter's git config runs.
|
|
215
|
+
*/
|
|
216
|
+
const diff = (oldSnapshot, newSnapshot, path) => {
|
|
217
|
+
const folder = checkPath(path);
|
|
218
|
+
const res = git(
|
|
219
|
+
"diff",
|
|
220
|
+
"--no-ext-diff",
|
|
221
|
+
"--no-textconv",
|
|
222
|
+
"--no-color",
|
|
223
|
+
oldSnapshot.commit,
|
|
224
|
+
newSnapshot.commit,
|
|
225
|
+
"--",
|
|
226
|
+
`${folder}/`,
|
|
227
|
+
);
|
|
228
|
+
if (res.status !== 0) throw new KitTagError(`git diff failed: ${firstLine(res.stderr)}`);
|
|
229
|
+
return res.stdout;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
return { repo, serverUrl, fetchTag, diff, cleanup };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Fetch `tag` of `repo`. The tag must carry kit-owned-paths.txt, or this throws
|
|
237
|
+
* PreManifestTagError. Call `cleanup()` when done.
|
|
238
|
+
*
|
|
239
|
+
* @param {{
|
|
240
|
+
* runner: import("./exec.mjs").Runner,
|
|
241
|
+
* repo: string,
|
|
242
|
+
* tag: string,
|
|
243
|
+
* serverUrl?: string, // https://github.com, or a GitHub Enterprise Server host
|
|
244
|
+
* remoteUrl?: string, // tests: fetch from here instead
|
|
245
|
+
* forDeletion?: boolean // strict manifest shapes, for code that deletes paths
|
|
246
|
+
* }} opts
|
|
247
|
+
*/
|
|
248
|
+
export function openKitTag({
|
|
249
|
+
runner,
|
|
250
|
+
repo,
|
|
251
|
+
tag,
|
|
252
|
+
serverUrl = DEFAULT_SERVER,
|
|
253
|
+
remoteUrl,
|
|
254
|
+
forDeletion = true,
|
|
255
|
+
}) {
|
|
256
|
+
checkArgs({ repo, serverUrl });
|
|
257
|
+
// Checked before any git runs, so a bad tag never reaches the command line.
|
|
258
|
+
if (!isReleaseTag(tag)) throw new KitTagError(`not a Release-line tag: ${JSON.stringify(tag)}`);
|
|
259
|
+
const kit = openKitRepo({ runner, repo, serverUrl, remoteUrl });
|
|
260
|
+
try {
|
|
261
|
+
return { ...kit.fetchTag(tag, { forDeletion }), cleanup: kit.cleanup };
|
|
115
262
|
} catch (err) {
|
|
116
|
-
cleanup();
|
|
263
|
+
kit.cleanup();
|
|
117
264
|
throw err;
|
|
118
265
|
}
|
|
119
266
|
}
|
package/src/local-tree.mjs
CHANGED
|
@@ -13,10 +13,21 @@
|
|
|
13
13
|
// configured clean/process filter drivers, so hashing a checkout runs no
|
|
14
14
|
// command that the checkout's or the adopter's git config names.
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
existsSync,
|
|
18
|
+
lstatSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
mkdtempSync,
|
|
21
|
+
readdirSync,
|
|
22
|
+
rmSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
} from "node:fs";
|
|
17
25
|
import { tmpdir } from "node:os";
|
|
18
26
|
import { isAbsolute, join, resolve } from "node:path";
|
|
19
27
|
import { firstLine } from "./exec.mjs";
|
|
28
|
+
import { EXECUTABLE_MODE, REGULAR_MODE } from "./kit-tag.mjs";
|
|
29
|
+
|
|
30
|
+
const MODES = new Set([EXECUTABLE_MODE, REGULAR_MODE]);
|
|
20
31
|
|
|
21
32
|
const SAFE_PATH = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\/$/;
|
|
22
33
|
|
|
@@ -37,11 +48,98 @@ export function filterDrivers(configNames) {
|
|
|
37
48
|
return [...drivers];
|
|
38
49
|
}
|
|
39
50
|
|
|
51
|
+
/**
|
|
52
|
+
* True when core.fileMode is off (the default on Windows): git then cannot
|
|
53
|
+
* see an executable bit on disk and takes a file's mode from the index.
|
|
54
|
+
*/
|
|
55
|
+
export function fileModeIsOff(runner, root) {
|
|
56
|
+
const r = runner("git", ["-C", root, "config", "--type=bool", "core.fileMode"]);
|
|
57
|
+
return r.status === 0 && r.stdout.trim() === "false";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `-c` options that keep git from running anything the checkout's or the
|
|
62
|
+
* adopter's git config names: no hooks (`noHooks` is an empty folder), no
|
|
63
|
+
* fsmonitor, no background maintenance, and no clean/process filter drivers.
|
|
64
|
+
*/
|
|
65
|
+
export function gitSafetyArgs({ runner, root, noHooks }) {
|
|
66
|
+
const safety = [
|
|
67
|
+
"-c",
|
|
68
|
+
`core.hooksPath=${noHooks}`,
|
|
69
|
+
"-c",
|
|
70
|
+
"core.fsmonitor=false",
|
|
71
|
+
"-c",
|
|
72
|
+
"gc.auto=0",
|
|
73
|
+
"-c",
|
|
74
|
+
"maintenance.auto=false",
|
|
75
|
+
];
|
|
76
|
+
const listed = runner("git", ["-C", root, "config", "--name-only", "--get-regexp", "^filter\\."]);
|
|
77
|
+
for (const driver of filterDrivers(listed.status === 0 ? listed.stdout.split("\n") : [])) {
|
|
78
|
+
if (/[=\r\n]/.test(driver)) {
|
|
79
|
+
throw new LocalTreeError(`cannot turn off git filter driver ${JSON.stringify(driver)}`);
|
|
80
|
+
}
|
|
81
|
+
safety.push("-c", `filter.${driver}.clean=`, "-c", `filter.${driver}.process=`);
|
|
82
|
+
safety.push("-c", `filter.${driver}.required=false`);
|
|
83
|
+
}
|
|
84
|
+
return safety;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `lstat`, or null when nothing is there. */
|
|
88
|
+
export function lstatOrNull(path) {
|
|
89
|
+
try {
|
|
90
|
+
return lstatSync(path);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (err.code === "ENOENT" || err.code === "ENOTDIR") return null;
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The first symbolic link or junction at, above or inside the folder `path`
|
|
99
|
+
* (relative to `root`, ending in /), as a path without a trailing slash, or
|
|
100
|
+
* null. git looks straight through a Windows junction, stops at a symlink
|
|
101
|
+
* with "beyond a symbolic link", and stores a symlink inside a folder as a
|
|
102
|
+
* link entry, so this is checked before git ever sees the path.
|
|
103
|
+
*/
|
|
104
|
+
export function linkAt(root, path) {
|
|
105
|
+
const folder = path.replace(/\/$/, "");
|
|
106
|
+
const parts = folder.split("/");
|
|
107
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
108
|
+
const sub = parts.slice(0, i).join("/");
|
|
109
|
+
const st = lstatOrNull(join(root, sub));
|
|
110
|
+
if (!st) return null;
|
|
111
|
+
if (st.isSymbolicLink()) return sub;
|
|
112
|
+
}
|
|
113
|
+
return linksUnder(root, folder)[0] ?? null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Every symbolic link or junction at or inside `rel` (relative to `root`, no
|
|
118
|
+
* trailing slash), in sorted walk order: never followed or guessed at. doctor
|
|
119
|
+
* reports what it finds and upgrade refuses to delete through it, so both
|
|
120
|
+
* agree on what is linked.
|
|
121
|
+
*/
|
|
122
|
+
export function linksUnder(root, rel) {
|
|
123
|
+
const found = [];
|
|
124
|
+
const walk = (sub) => {
|
|
125
|
+
const st = lstatOrNull(join(root, sub));
|
|
126
|
+
if (!st) return;
|
|
127
|
+
if (st.isSymbolicLink()) found.push(sub);
|
|
128
|
+
else if (st.isDirectory()) {
|
|
129
|
+
for (const name of readdirSync(join(root, sub)).sort()) walk(`${sub}/${name}`);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
walk(rel);
|
|
133
|
+
return found;
|
|
134
|
+
}
|
|
135
|
+
|
|
40
136
|
/**
|
|
41
137
|
* @param {{ runner: import("./exec.mjs").Runner, cwd: string, paths: string[] }} opts
|
|
42
|
-
* @returns {{ root: string, hashes: Map<string, string | null>, skipped: Map<string, string>, notes: Map<string, string> }}
|
|
138
|
+
* @returns {{ root: string, hashes: Map<string, string | null>, skipped: Map<string, string>, notes: Map<string, string>, links: Map<string, string> }}
|
|
43
139
|
* A null hash means the path is missing locally; `skipped` holds paths that
|
|
44
|
-
* could not be compared, with the reason; `notes` explains how a path was compared
|
|
140
|
+
* could not be compared, with the reason; `notes` explains how a path was compared;
|
|
141
|
+
* `links` maps a path that is, sits under, or holds a symbolic link or junction to
|
|
142
|
+
* that link. A linked path is never hashed.
|
|
45
143
|
*/
|
|
46
144
|
export function localTreeHashes({ runner, cwd, paths }) {
|
|
47
145
|
for (const p of paths) {
|
|
@@ -75,29 +173,7 @@ export function localTreeHashes({ runner, cwd, paths }) {
|
|
|
75
173
|
// splits on ':' on POSIX, so a path containing one would break it.
|
|
76
174
|
writeFileSync(join(objects, "info", "alternates"), `${realObjects}\n`);
|
|
77
175
|
|
|
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
|
-
}
|
|
176
|
+
const safety = gitSafetyArgs({ runner, root, noHooks });
|
|
101
177
|
const base = ["-C", root, ...safety, "--literal-pathspecs"];
|
|
102
178
|
const env = { GIT_INDEX_FILE: index, GIT_OBJECT_DIRECTORY: objects };
|
|
103
179
|
const git = (...args) => runner("git", [...base, ...args], { env });
|
|
@@ -113,10 +189,20 @@ export function localTreeHashes({ runner, cwd, paths }) {
|
|
|
113
189
|
}
|
|
114
190
|
}
|
|
115
191
|
|
|
192
|
+
// Without core.fileMode (the default on Windows) git cannot see an
|
|
193
|
+
// executable bit on disk and takes the mode from the index: so does this.
|
|
194
|
+
const trustIndexModes = fileModeIsOff(runner, root);
|
|
195
|
+
|
|
116
196
|
const hashes = new Map();
|
|
117
197
|
const skipped = new Map();
|
|
118
198
|
const notes = new Map();
|
|
199
|
+
const links = new Map();
|
|
119
200
|
for (const path of paths) {
|
|
201
|
+
const link = linkAt(root, path);
|
|
202
|
+
if (link !== null) {
|
|
203
|
+
links.set(path, link);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
120
206
|
// The real index (read only) knows skip-worktree entries (`S`), which
|
|
121
207
|
// have no files on disk. Everything else comes from the working copy
|
|
122
208
|
// (`git add`); only the S files are then reset to HEAD's copy, so local
|
|
@@ -160,6 +246,35 @@ export function localTreeHashes({ runner, cwd, paths }) {
|
|
|
160
246
|
);
|
|
161
247
|
}
|
|
162
248
|
}
|
|
249
|
+
if (trustIndexModes) {
|
|
250
|
+
const modes = (res) =>
|
|
251
|
+
new Map(
|
|
252
|
+
(res.status === 0 ? res.stdout.split("\0") : [])
|
|
253
|
+
.filter(Boolean)
|
|
254
|
+
.map((e) => [e.slice(e.indexOf("\t") + 1), e.slice(0, 6)]),
|
|
255
|
+
);
|
|
256
|
+
const inReal = modes(real("ls-files", "-s", "-z", "--", path));
|
|
257
|
+
const inTemp = modes(git("ls-files", "-s", "-z", "--", path));
|
|
258
|
+
// Both directions: the real index may have gained or lost the bit.
|
|
259
|
+
for (const [want, flag] of [
|
|
260
|
+
[EXECUTABLE_MODE, "--chmod=+x"],
|
|
261
|
+
[REGULAR_MODE, "--chmod=-x"],
|
|
262
|
+
]) {
|
|
263
|
+
const fix = [...inReal]
|
|
264
|
+
.filter(
|
|
265
|
+
([f, mode]) => mode === want && MODES.has(inTemp.get(f)) && inTemp.get(f) !== want,
|
|
266
|
+
)
|
|
267
|
+
.map(([f]) => f);
|
|
268
|
+
for (let i = 0; i < fix.length; i += 200) {
|
|
269
|
+
const chmod = git("update-index", flag, "--", ...fix.slice(i, i + 200));
|
|
270
|
+
if (chmod.status !== 0) {
|
|
271
|
+
throw new LocalTreeError(
|
|
272
|
+
`git update-index ${flag} failed: ${firstLine(chmod.stderr)}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
163
278
|
const inIndex = git("ls-files", "-z", "--", path);
|
|
164
279
|
const staged = inIndex.status === 0 ? inIndex.stdout.split("\0").filter(Boolean) : [];
|
|
165
280
|
if (staged.length === 0) {
|
|
@@ -183,7 +298,7 @@ export function localTreeHashes({ runner, cwd, paths }) {
|
|
|
183
298
|
}
|
|
184
299
|
hashes.set(path, tree.stdout.trim());
|
|
185
300
|
}
|
|
186
|
-
return { root, hashes, skipped, notes };
|
|
301
|
+
return { root, hashes, skipped, notes, links };
|
|
187
302
|
} finally {
|
|
188
303
|
rmSync(tmp, { recursive: true, force: true });
|
|
189
304
|
}
|