@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.
- package/README.md +21 -2
- package/bin/agent-kit.mjs +13 -0
- package/package.json +29 -4
- package/src/callers.mjs +204 -0
- package/src/checks/claude-app.mjs +55 -0
- package/src/checks/kit-owned-trees.mjs +78 -0
- package/src/checks/labels.mjs +100 -0
- package/src/checks/pins.mjs +76 -0
- package/src/checks/result.mjs +74 -0
- package/src/checks/rulesets.mjs +346 -0
- package/src/checks/secrets-and-variables.mjs +252 -0
- package/src/cli.mjs +229 -0
- package/src/doctor.mjs +405 -0
- package/src/exec.mjs +64 -0
- package/src/github.mjs +88 -0
- package/src/init.mjs +627 -0
- package/src/kit-owned-paths.mjs +48 -0
- package/src/kit-tag.mjs +257 -0
- package/src/local-tree.mjs +240 -0
- package/src/local-writes.mjs +307 -0
- package/src/prompt.mjs +42 -0
- package/src/safe-paths.mjs +68 -0
- package/src/tags.mjs +55 -0
- package/src/templates.mjs +133 -0
- package/src/upgrade.mjs +288 -0
- package/src/workflow-call.mjs +44 -0
- package/src/yaml-subset.mjs +118 -0
package/src/kit-tag.mjs
ADDED
|
@@ -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
|
+
}
|