silvermoon 0.2.2 → 0.3.0-rc.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/LICENSE +21 -0
- package/README.md +115 -121
- package/README.zh-CN.md +96 -95
- package/bin/silvermoon.js +7 -2
- package/docs/getting-started.md +21 -9
- package/docs/maintaining.md +83 -6
- package/docs/npm-package-releases.md +33 -4
- package/docs/operations.md +11 -7
- package/docs/reference.md +131 -32
- package/docs/repository-tasks.md +43 -15
- package/package.json +46 -8
- package/skills/silvermoon/SKILL.md +224 -123
- package/src/adoption.js +30 -16
- package/src/cli.js +148 -39
- package/src/config.js +76 -16
- package/src/create-idea.js +22 -10
- package/src/git-snapshot.js +212 -0
- package/src/git.js +209 -18
- package/src/guidance.js +65 -23
- package/src/idea-layout.js +59 -23
- package/src/idea-metadata.js +48 -0
- package/src/idea-templates.js +133 -0
- package/src/index.js +64 -53
- package/src/language.js +4 -0
- package/src/list-ideas.js +118 -55
- package/src/observation.js +9 -1
- package/src/repository.js +2 -8
- package/src/response.js +93 -95
- package/src/subprocess.js +27 -0
- package/src/trace.js +37 -2
- package/src/tui-table.js +71 -0
- package/src/tui.js +205 -0
- package/src/whatsnext.js +71 -18
- package/assets/silvermoon-avatar.svg +0 -395
- package/assets/silvermoon.svg +0 -176
- package/docs/assets/silvermoon-avatar.svg +0 -395
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { posix } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
inspectTreeLineage,
|
|
6
|
+
readGitBlob,
|
|
7
|
+
readGitBlobs,
|
|
8
|
+
} from "./git.js";
|
|
9
|
+
import { traceSync } from "./trace.js";
|
|
10
|
+
|
|
11
|
+
const REGULAR_FILE_MODES = new Set(["100644", "100755"]);
|
|
12
|
+
|
|
13
|
+
function fileSystemError(code, path, message) {
|
|
14
|
+
return Object.assign(new Error(`${message}: ${path}`), { code, path });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class SnapshotMetadata {
|
|
18
|
+
constructor(entry, name) {
|
|
19
|
+
this.entry = entry;
|
|
20
|
+
this.name = name;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
isDirectory() {
|
|
24
|
+
return this.entry.type === "tree" && this.entry.mode === "040000";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
isFile() {
|
|
28
|
+
return this.entry.type === "blob"
|
|
29
|
+
&& REGULAR_FILE_MODES.has(this.entry.mode);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
isSymbolicLink() {
|
|
33
|
+
return this.entry.type === "blob" && this.entry.mode === "120000";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizedRelativePath(root, path) {
|
|
38
|
+
const normalizedRoot = resolve(root);
|
|
39
|
+
const absolutePath = resolve(path);
|
|
40
|
+
const value = relative(normalizedRoot, absolutePath);
|
|
41
|
+
if (
|
|
42
|
+
value === ""
|
|
43
|
+
|| (!isAbsolute(value) && value !== ".." && !value.startsWith(`..${sep}`))
|
|
44
|
+
) {
|
|
45
|
+
return value.split(sep).join("/");
|
|
46
|
+
}
|
|
47
|
+
throw fileSystemError("ENOENT", path, "Snapshot path is outside the root");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function childName(path) {
|
|
51
|
+
const separator = path.lastIndexOf("/");
|
|
52
|
+
return separator < 0 ? path : path.slice(separator + 1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createGitSnapshotFileSystem({
|
|
56
|
+
gitRoot,
|
|
57
|
+
preload = () => false,
|
|
58
|
+
root = gitRoot,
|
|
59
|
+
tree,
|
|
60
|
+
}) {
|
|
61
|
+
return traceSync(
|
|
62
|
+
"snapshot.open",
|
|
63
|
+
{},
|
|
64
|
+
() => {
|
|
65
|
+
const entries = inspectTreeLineage(gitRoot, tree, ".");
|
|
66
|
+
const entriesByPath = new Map(entries.map((entry) => [entry.name, entry]));
|
|
67
|
+
const childrenByPath = new Map();
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const parent = posix.dirname(entry.name);
|
|
70
|
+
const key = parent === "." ? "" : parent;
|
|
71
|
+
const children = childrenByPath.get(key) ?? [];
|
|
72
|
+
children.push(entry);
|
|
73
|
+
childrenByPath.set(key, children);
|
|
74
|
+
}
|
|
75
|
+
const preloadObjects = entries
|
|
76
|
+
.filter((entry) =>
|
|
77
|
+
entry.mode === "120000"
|
|
78
|
+
|| (entry.type === "blob" && preload(entry))
|
|
79
|
+
)
|
|
80
|
+
.map(({ object }) => object);
|
|
81
|
+
const blobs = readGitBlobs(gitRoot, preloadObjects);
|
|
82
|
+
|
|
83
|
+
async function blob(entry) {
|
|
84
|
+
const cached = blobs.get(entry.object);
|
|
85
|
+
if (cached !== undefined) return cached;
|
|
86
|
+
const loaded = readGitBlob(gitRoot, entry.object);
|
|
87
|
+
blobs.set(entry.object, loaded);
|
|
88
|
+
return loaded;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function resolveEntry(path, { followFinal = true } = {}) {
|
|
92
|
+
const requested = normalizedRelativePath(root, path);
|
|
93
|
+
if (requested === "") {
|
|
94
|
+
return {
|
|
95
|
+
entry: {
|
|
96
|
+
mode: "040000",
|
|
97
|
+
name: "",
|
|
98
|
+
object: tree,
|
|
99
|
+
size: null,
|
|
100
|
+
type: "tree",
|
|
101
|
+
},
|
|
102
|
+
path: "",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let parts = requested.split("/");
|
|
107
|
+
let resolvedParts = [];
|
|
108
|
+
const followed = new Set();
|
|
109
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
110
|
+
const candidate = [...resolvedParts, parts[index]].join("/");
|
|
111
|
+
const entry = entriesByPath.get(candidate);
|
|
112
|
+
if (entry === undefined) {
|
|
113
|
+
throw fileSystemError("ENOENT", path, "Snapshot path does not exist");
|
|
114
|
+
}
|
|
115
|
+
const shouldFollow = entry.mode === "120000"
|
|
116
|
+
&& (followFinal || index < parts.length - 1);
|
|
117
|
+
if (!shouldFollow) {
|
|
118
|
+
resolvedParts.push(parts[index]);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (followed.has(candidate) || followed.size >= 40) {
|
|
122
|
+
throw fileSystemError("ELOOP", path, "Snapshot symlink cycle");
|
|
123
|
+
}
|
|
124
|
+
followed.add(candidate);
|
|
125
|
+
const target = (await blob(entry)).toString("utf8");
|
|
126
|
+
if (
|
|
127
|
+
target.includes("\0")
|
|
128
|
+
|| target.startsWith("/")
|
|
129
|
+
|| /^[A-Za-z]:[\\/]/.test(target)
|
|
130
|
+
) {
|
|
131
|
+
throw fileSystemError(
|
|
132
|
+
"ENOENT",
|
|
133
|
+
path,
|
|
134
|
+
"Snapshot symlink target is outside the root",
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const normalizedTarget = posix.normalize(
|
|
138
|
+
posix.join(posix.dirname(candidate), target.replaceAll("\\", "/")),
|
|
139
|
+
);
|
|
140
|
+
if (
|
|
141
|
+
normalizedTarget === ".."
|
|
142
|
+
|| normalizedTarget.startsWith("../")
|
|
143
|
+
) {
|
|
144
|
+
throw fileSystemError(
|
|
145
|
+
"ENOENT",
|
|
146
|
+
path,
|
|
147
|
+
"Snapshot symlink target is outside the root",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
parts = [
|
|
151
|
+
...normalizedTarget.split("/").filter(Boolean),
|
|
152
|
+
...parts.slice(index + 1),
|
|
153
|
+
];
|
|
154
|
+
resolvedParts = [];
|
|
155
|
+
index = -1;
|
|
156
|
+
}
|
|
157
|
+
const resolvedPath = resolvedParts.join("/");
|
|
158
|
+
return { entry: entriesByPath.get(resolvedPath), path: resolvedPath };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const filesystem = {
|
|
162
|
+
async lstat(path) {
|
|
163
|
+
const resolved = await resolveEntry(path, { followFinal: false });
|
|
164
|
+
return new SnapshotMetadata(resolved.entry, childName(resolved.path));
|
|
165
|
+
},
|
|
166
|
+
async readFile(path, encoding) {
|
|
167
|
+
const resolved = await resolveEntry(path);
|
|
168
|
+
const metadata = new SnapshotMetadata(
|
|
169
|
+
resolved.entry,
|
|
170
|
+
childName(resolved.path),
|
|
171
|
+
);
|
|
172
|
+
if (!metadata.isFile()) {
|
|
173
|
+
throw fileSystemError("EISDIR", path, "Snapshot path is not a file");
|
|
174
|
+
}
|
|
175
|
+
const content = await blob(resolved.entry);
|
|
176
|
+
return encoding === undefined ? Buffer.from(content) : content.toString(encoding);
|
|
177
|
+
},
|
|
178
|
+
async readdir(path, options = {}) {
|
|
179
|
+
const resolved = await resolveEntry(path);
|
|
180
|
+
const metadata = new SnapshotMetadata(
|
|
181
|
+
resolved.entry,
|
|
182
|
+
childName(resolved.path),
|
|
183
|
+
);
|
|
184
|
+
if (!metadata.isDirectory()) {
|
|
185
|
+
throw fileSystemError("ENOTDIR", path, "Snapshot path is not a directory");
|
|
186
|
+
}
|
|
187
|
+
const children = childrenByPath.get(resolved.path) ?? [];
|
|
188
|
+
if (options.withFileTypes === true) {
|
|
189
|
+
return children.map((entry) =>
|
|
190
|
+
new SnapshotMetadata(entry, childName(entry.name))
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return children.map(({ name }) => childName(name));
|
|
194
|
+
},
|
|
195
|
+
snapshotEntry(path) {
|
|
196
|
+
return entriesByPath.get(path) ?? null;
|
|
197
|
+
},
|
|
198
|
+
snapshotEntries(path) {
|
|
199
|
+
return [...(childrenByPath.get(path) ?? [])];
|
|
200
|
+
},
|
|
201
|
+
async snapshotFile(path) {
|
|
202
|
+
const entry = entriesByPath.get(path);
|
|
203
|
+
if (entry === undefined || entry.type !== "blob") {
|
|
204
|
+
throw fileSystemError("ENOENT", path, "Snapshot blob does not exist");
|
|
205
|
+
}
|
|
206
|
+
return Buffer.from(await blob(entry));
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
return filesystem;
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
}
|
package/src/git.js
CHANGED
|
@@ -1,35 +1,32 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
3
2
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
3
|
import { tmpdir } from "node:os";
|
|
5
4
|
import { join } from "node:path";
|
|
6
5
|
|
|
7
|
-
import {
|
|
6
|
+
import { runSubprocess } from "./subprocess.js";
|
|
7
|
+
import { traceAsync } from "./trace.js";
|
|
8
8
|
|
|
9
9
|
let commandObserver = null;
|
|
10
10
|
|
|
11
11
|
function executeGit(root, args, { encoding, env, input } = {}) {
|
|
12
12
|
if (commandObserver) commandObserver([...args]);
|
|
13
|
-
return
|
|
14
|
-
"git
|
|
13
|
+
return runSubprocess(
|
|
14
|
+
"git",
|
|
15
|
+
root === null ? args : ["-C", root, ...args],
|
|
15
16
|
{
|
|
16
|
-
argumentCount: args.length - 1,
|
|
17
|
-
network: args[0] === "fetch" || args[0] === "ls-remote",
|
|
18
|
-
subcommand: args[0] ?? null,
|
|
19
|
-
},
|
|
20
|
-
() => spawnSync("git", ["-C", root, ...args], {
|
|
21
17
|
encoding,
|
|
22
18
|
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env },
|
|
23
19
|
input,
|
|
24
20
|
windowsHide: true,
|
|
25
|
-
}
|
|
26
|
-
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
spanName: "git.command",
|
|
27
24
|
attributes: {
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
argumentCount: args.length - 1,
|
|
26
|
+
network: args[0] === "fetch" || args[0] === "ls-remote",
|
|
27
|
+
subcommand: args[0] ?? null,
|
|
30
28
|
},
|
|
31
|
-
|
|
32
|
-
}),
|
|
29
|
+
},
|
|
33
30
|
);
|
|
34
31
|
}
|
|
35
32
|
|
|
@@ -98,6 +95,24 @@ function parseTreeEntries(source, label) {
|
|
|
98
95
|
return entries;
|
|
99
96
|
}
|
|
100
97
|
|
|
98
|
+
function parseLongTreeEntries(source, label) {
|
|
99
|
+
const entries = [];
|
|
100
|
+
for (const record of source.split("\0")) {
|
|
101
|
+
if (!record) continue;
|
|
102
|
+
const match = /^([0-7]{6}) (blob|tree|commit) ([0-9a-f]{40}|[0-9a-f]{64}) +(-|(?:0|[1-9]\d*))\t([\s\S]+)$/
|
|
103
|
+
.exec(record);
|
|
104
|
+
if (!match) throw new Error(`Cannot parse ${label} entry`);
|
|
105
|
+
entries.push({
|
|
106
|
+
mode: match[1],
|
|
107
|
+
type: match[2],
|
|
108
|
+
object: match[3],
|
|
109
|
+
size: match[4] === "-" ? null : Number(match[4]),
|
|
110
|
+
name: match[5],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return entries;
|
|
114
|
+
}
|
|
115
|
+
|
|
101
116
|
export function inspectTreeEntry(root, tree, path) {
|
|
102
117
|
const output = requireGit(
|
|
103
118
|
root,
|
|
@@ -121,6 +136,22 @@ export function listTreeEntries(root, tree, path) {
|
|
|
121
136
|
return parseTreeEntries(output, `snapshot directory ${path}`);
|
|
122
137
|
}
|
|
123
138
|
|
|
139
|
+
export function inspectTree(root, tree, path) {
|
|
140
|
+
return inspectTreeLineage(root, tree, path)
|
|
141
|
+
.filter((entry) =>
|
|
142
|
+
entry.name === path || entry.name.startsWith(`${path}/`)
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function inspectTreeLineage(root, tree, path) {
|
|
147
|
+
const output = requireGit(
|
|
148
|
+
root,
|
|
149
|
+
["ls-tree", "-r", "-t", "-l", "-z", tree, "--", path],
|
|
150
|
+
`Cannot inspect snapshot tree ${path}`,
|
|
151
|
+
);
|
|
152
|
+
return parseLongTreeEntries(output, `snapshot tree ${path}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
124
155
|
export function gitObjectSize(root, object) {
|
|
125
156
|
const output = requireGit(
|
|
126
157
|
root,
|
|
@@ -151,6 +182,54 @@ export function readGitBlob(root, object) {
|
|
|
151
182
|
return Buffer.from(result.stdout);
|
|
152
183
|
}
|
|
153
184
|
|
|
185
|
+
export function readGitBlobs(root, objects) {
|
|
186
|
+
const requested = [...new Set(objects)];
|
|
187
|
+
if (requested.length === 0) return new Map();
|
|
188
|
+
const result = executeGit(
|
|
189
|
+
root,
|
|
190
|
+
["cat-file", "--batch"],
|
|
191
|
+
{
|
|
192
|
+
encoding: null,
|
|
193
|
+
input: Buffer.from(`${requested.join("\n")}\n`, "utf8"),
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
if (result.status !== 0) {
|
|
197
|
+
const stderr = Buffer.isBuffer(result.stderr)
|
|
198
|
+
? result.stderr.toString("utf8")
|
|
199
|
+
: result.stderr;
|
|
200
|
+
throw new Error(
|
|
201
|
+
"Cannot read Git blobs: "
|
|
202
|
+
+ `${sanitizeGitMessage(stderr?.trim() || result.error?.message || "Git failed")}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const source = Buffer.from(result.stdout);
|
|
207
|
+
const blobs = new Map();
|
|
208
|
+
let offset = 0;
|
|
209
|
+
for (const object of requested) {
|
|
210
|
+
const headerEnd = source.indexOf(0x0a, offset);
|
|
211
|
+
if (headerEnd < 0) throw new Error(`Missing Git blob header for ${object}`);
|
|
212
|
+
const header = source.subarray(offset, headerEnd).toString("utf8");
|
|
213
|
+
const match = /^([0-9a-f]{40}|[0-9a-f]{64}) blob (0|[1-9]\d*)$/
|
|
214
|
+
.exec(header);
|
|
215
|
+
if (!match || match[1] !== object) {
|
|
216
|
+
throw new Error(`Cannot parse Git blob header for ${object}`);
|
|
217
|
+
}
|
|
218
|
+
const size = Number(match[2]);
|
|
219
|
+
const contentStart = headerEnd + 1;
|
|
220
|
+
const contentEnd = contentStart + size;
|
|
221
|
+
if (contentEnd >= source.length || source[contentEnd] !== 0x0a) {
|
|
222
|
+
throw new Error(`Incomplete Git blob content for ${object}`);
|
|
223
|
+
}
|
|
224
|
+
blobs.set(object, source.subarray(contentStart, contentEnd));
|
|
225
|
+
offset = contentEnd + 1;
|
|
226
|
+
}
|
|
227
|
+
if (offset !== source.length) {
|
|
228
|
+
throw new Error("Unexpected trailing Git blob batch output");
|
|
229
|
+
}
|
|
230
|
+
return blobs;
|
|
231
|
+
}
|
|
232
|
+
|
|
154
233
|
export function inspectTreePaths(root, tree, paths) {
|
|
155
234
|
if (paths.length === 0) return new Map();
|
|
156
235
|
const input = `${paths.map((path) => `${tree}:${path}`).join("\n")}\n`;
|
|
@@ -271,6 +350,88 @@ export function parseWorktreeChanges(source) {
|
|
|
271
350
|
return result;
|
|
272
351
|
}
|
|
273
352
|
|
|
353
|
+
export function parseRepositoryStatus(source) {
|
|
354
|
+
let branch = null;
|
|
355
|
+
let head = null;
|
|
356
|
+
for (const record of source.split("\0")) {
|
|
357
|
+
if (record.startsWith("# branch.oid ")) {
|
|
358
|
+
const value = record.slice("# branch.oid ".length);
|
|
359
|
+
if (value !== "(initial)") {
|
|
360
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value)) {
|
|
361
|
+
throw new Error(`Unsupported Git HEAD object ID: ${value}`);
|
|
362
|
+
}
|
|
363
|
+
head = value;
|
|
364
|
+
}
|
|
365
|
+
} else if (record.startsWith("# branch.head ")) {
|
|
366
|
+
const value = record.slice("# branch.head ".length);
|
|
367
|
+
branch = value === "(detached)" ? null : value;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
branch,
|
|
372
|
+
changes: parseWorktreeChanges(source),
|
|
373
|
+
head,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function escapeConfigKeyPattern(value) {
|
|
378
|
+
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function parseConfigRecords(source) {
|
|
382
|
+
const values = new Map();
|
|
383
|
+
for (const record of source.split("\0")) {
|
|
384
|
+
if (!record) continue;
|
|
385
|
+
const separator = record.indexOf("\n");
|
|
386
|
+
if (separator < 1) throw new Error("Cannot parse Git configuration record");
|
|
387
|
+
values.set(record.slice(0, separator), record.slice(separator + 1));
|
|
388
|
+
}
|
|
389
|
+
return values;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function inspectBranchConfiguration(root, branch) {
|
|
393
|
+
if (branch === null) {
|
|
394
|
+
return { branch: null, remote: null, repository: null, upstreamBranch: null };
|
|
395
|
+
}
|
|
396
|
+
const escapedBranch = escapeConfigKeyPattern(branch);
|
|
397
|
+
const result = runGit(root, [
|
|
398
|
+
"config",
|
|
399
|
+
"--null",
|
|
400
|
+
"--get-regexp",
|
|
401
|
+
`^(branch\\.${escapedBranch}\\.(remote|merge)|remote\\..*\\.url)$`,
|
|
402
|
+
]);
|
|
403
|
+
const values = result.ok ? parseConfigRecords(result.stdout) : new Map();
|
|
404
|
+
const remote = values.get(`branch.${branch}.remote`) ?? null;
|
|
405
|
+
const merge = values.get(`branch.${branch}.merge`) ?? null;
|
|
406
|
+
const upstreamBranch = merge?.startsWith("refs/heads/")
|
|
407
|
+
? merge.slice("refs/heads/".length)
|
|
408
|
+
: null;
|
|
409
|
+
const repository = remote === null || remote === "."
|
|
410
|
+
? null
|
|
411
|
+
: values.get(`remote.${remote}.url`) ?? null;
|
|
412
|
+
return { branch, remote, repository, upstreamBranch };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function inspectRepositoryState(root) {
|
|
416
|
+
const source = requireGit(
|
|
417
|
+
root,
|
|
418
|
+
[
|
|
419
|
+
"status",
|
|
420
|
+
"--porcelain=v2",
|
|
421
|
+
"--branch",
|
|
422
|
+
"--untracked-files=all",
|
|
423
|
+
"-z",
|
|
424
|
+
],
|
|
425
|
+
"Cannot inspect repository state",
|
|
426
|
+
);
|
|
427
|
+
const status = parseRepositoryStatus(`${source}\0`);
|
|
428
|
+
return {
|
|
429
|
+
changes: status.changes,
|
|
430
|
+
head: status.head,
|
|
431
|
+
branch: inspectBranchConfiguration(root, status.branch),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
274
435
|
export function inspectWorktreeChanges(root) {
|
|
275
436
|
const source = requireGit(
|
|
276
437
|
root,
|
|
@@ -360,6 +521,39 @@ export function resolveCommit(root, revision) {
|
|
|
360
521
|
);
|
|
361
522
|
}
|
|
362
523
|
|
|
524
|
+
export function resolveSnapshot(root, revision) {
|
|
525
|
+
if (typeof revision !== "string" || /[\0\r\n]/.test(revision)) {
|
|
526
|
+
throw new Error(`Cannot resolve snapshot ${JSON.stringify(revision)}`);
|
|
527
|
+
}
|
|
528
|
+
const expressions = [`${revision}^{commit}`, `${revision}^{tree}`];
|
|
529
|
+
const result = runGit(
|
|
530
|
+
root,
|
|
531
|
+
["cat-file", "--batch-check=%(objectname) %(objecttype)"],
|
|
532
|
+
{ input: `${expressions.join("\n")}\n` },
|
|
533
|
+
);
|
|
534
|
+
if (!result.ok) {
|
|
535
|
+
throw new Error(
|
|
536
|
+
`Cannot resolve snapshot ${revision}: `
|
|
537
|
+
+ `${sanitizeGitMessage(result.stderr || result.error?.message || "Git failed")}`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
const lines = result.stdout.split(/\r?\n/);
|
|
541
|
+
if (lines.length !== 2) {
|
|
542
|
+
throw new Error(`Cannot resolve snapshot ${revision}: unexpected Git output`);
|
|
543
|
+
}
|
|
544
|
+
const parsed = lines.map((line, index) => {
|
|
545
|
+
const match = /^([0-9a-f]{40}|[0-9a-f]{64}) (commit|tree)$/.exec(line);
|
|
546
|
+
if (!match) {
|
|
547
|
+
throw new Error(`Cannot resolve ${expressions[index]}`);
|
|
548
|
+
}
|
|
549
|
+
return { object: match[1], type: match[2] };
|
|
550
|
+
});
|
|
551
|
+
if (parsed[0].type !== "commit" || parsed[1].type !== "tree") {
|
|
552
|
+
throw new Error(`Cannot resolve snapshot ${revision}: invalid object types`);
|
|
553
|
+
}
|
|
554
|
+
return { commit: parsed[0].object, tree: parsed[1].object };
|
|
555
|
+
}
|
|
556
|
+
|
|
363
557
|
export function indexSnapshot(root) {
|
|
364
558
|
return {
|
|
365
559
|
tree: requireGit(root, ["write-tree"], "Cannot snapshot the index"),
|
|
@@ -391,9 +585,6 @@ export function fetchRepositoryBranch(root, repository, branch) {
|
|
|
391
585
|
throw new Error(`Cannot determine fetched commit for branch ${branch}`);
|
|
392
586
|
}
|
|
393
587
|
const primary = updates[0][2];
|
|
394
|
-
if (!runGit(root, ["cat-file", "-e", `${primary}^{commit}`]).ok) {
|
|
395
|
-
throw new Error(`Fetched branch ${branch} does not resolve to a commit`);
|
|
396
|
-
}
|
|
397
588
|
return primary;
|
|
398
589
|
}
|
|
399
590
|
|
package/src/guidance.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { TextDecoder } from "node:util";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
listTreeEntries,
|
|
7
|
-
readGitBlob,
|
|
4
|
+
inspectTree,
|
|
5
|
+
readGitBlobs,
|
|
8
6
|
} from "./git.js";
|
|
9
7
|
import {
|
|
10
8
|
GUIDANCE_PHASES,
|
|
@@ -50,22 +48,20 @@ function unexpectedEntry(name) {
|
|
|
50
48
|
);
|
|
51
49
|
}
|
|
52
50
|
|
|
53
|
-
function
|
|
51
|
+
function contentSizeDiagnostic(entry, phase) {
|
|
54
52
|
const path = phaseGuidancePath(phase);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
),
|
|
64
|
-
};
|
|
65
|
-
}
|
|
53
|
+
if (entry.size <= MAX_PHASE_GUIDANCE_BYTES) return null;
|
|
54
|
+
return error(
|
|
55
|
+
"guidance.file.too-large",
|
|
56
|
+
path,
|
|
57
|
+
`Phase guidance exceeds the ${MAX_PHASE_GUIDANCE_BYTES}-byte limit: ${path}`,
|
|
58
|
+
`Reduce ${path} to at most ${MAX_PHASE_GUIDANCE_BYTES} UTF-8 bytes.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
66
61
|
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
function inspectContent(bytes, entry, phase) {
|
|
63
|
+
const path = phaseGuidancePath(phase);
|
|
64
|
+
if (bytes.length !== entry.size) {
|
|
69
65
|
throw new Error(
|
|
70
66
|
`Git blob ${entry.object} changed size while reading ${path}`,
|
|
71
67
|
);
|
|
@@ -121,11 +117,20 @@ function inspectContent(gitRoot, entry, phase) {
|
|
|
121
117
|
}
|
|
122
118
|
|
|
123
119
|
async function inspectGuidanceInternal({
|
|
120
|
+
filesystem,
|
|
124
121
|
gitRoot,
|
|
125
122
|
phase,
|
|
126
123
|
snapshotTree,
|
|
127
124
|
}) {
|
|
128
|
-
const
|
|
125
|
+
const treeEntries = filesystem === undefined
|
|
126
|
+
? inspectTree(gitRoot, snapshotTree, GUIDANCE_ROOT)
|
|
127
|
+
: null;
|
|
128
|
+
const rootEntry = treeEntries === null
|
|
129
|
+
? filesystem.snapshotEntry(GUIDANCE_ROOT)
|
|
130
|
+
: treeEntries.find(({ name }) => name === GUIDANCE_ROOT);
|
|
131
|
+
if (rootEntry === undefined) {
|
|
132
|
+
return { state: "absent", diagnostics: [] };
|
|
133
|
+
}
|
|
129
134
|
if (rootEntry === null) {
|
|
130
135
|
return { state: "absent", diagnostics: [] };
|
|
131
136
|
}
|
|
@@ -133,7 +138,15 @@ async function inspectGuidanceInternal({
|
|
|
133
138
|
return { state: "invalid", diagnostics: [invalidDirectory()] };
|
|
134
139
|
}
|
|
135
140
|
|
|
136
|
-
const
|
|
141
|
+
const prefix = `${GUIDANCE_ROOT}/`;
|
|
142
|
+
const entries = (
|
|
143
|
+
treeEntries === null
|
|
144
|
+
? filesystem.snapshotEntries(GUIDANCE_ROOT)
|
|
145
|
+
: treeEntries.filter(({ name }) =>
|
|
146
|
+
name.startsWith(prefix) && !name.slice(prefix.length).includes("/")
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
.map((entry) => ({ ...entry, name: entry.name.slice(prefix.length) }));
|
|
137
150
|
const entriesByName = new Map(entries.map((entry) => [entry.name, entry]));
|
|
138
151
|
const phases = phase === undefined ? GUIDANCE_PHASES : [phase];
|
|
139
152
|
const diagnostics = [];
|
|
@@ -153,6 +166,7 @@ async function inspectGuidanceInternal({
|
|
|
153
166
|
|
|
154
167
|
let selectedGuidance;
|
|
155
168
|
let selectedPresent = false;
|
|
169
|
+
const contentEntries = [];
|
|
156
170
|
for (const candidate of phases) {
|
|
157
171
|
const path = phaseGuidancePath(candidate);
|
|
158
172
|
const name = path.slice(`${GUIDANCE_ROOT}/`.length);
|
|
@@ -163,8 +177,29 @@ async function inspectGuidanceInternal({
|
|
|
163
177
|
diagnostics.push(invalidFile(path));
|
|
164
178
|
continue;
|
|
165
179
|
}
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
180
|
+
const sizeDiagnostic = contentSizeDiagnostic(entry, candidate);
|
|
181
|
+
if (sizeDiagnostic) {
|
|
182
|
+
diagnostics.push(sizeDiagnostic);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
contentEntries.push({ candidate, entry });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const blobs = filesystem === undefined
|
|
189
|
+
? readGitBlobs(
|
|
190
|
+
gitRoot,
|
|
191
|
+
contentEntries.map(({ entry }) => entry.object),
|
|
192
|
+
)
|
|
193
|
+
: new Map(await Promise.all(contentEntries.map(async ({ candidate, entry }) => [
|
|
194
|
+
entry.object,
|
|
195
|
+
await filesystem.snapshotFile(phaseGuidancePath(candidate)),
|
|
196
|
+
])));
|
|
197
|
+
for (const { candidate, entry } of contentEntries) {
|
|
198
|
+
const bytes = blobs.get(entry.object);
|
|
199
|
+
if (bytes === undefined) {
|
|
200
|
+
throw new Error(`Missing Git blob content for ${entry.object}`);
|
|
201
|
+
}
|
|
202
|
+
const inspected = inspectContent(bytes, entry, candidate);
|
|
168
203
|
if (inspected.diagnostics) diagnostics.push(...inspected.diagnostics);
|
|
169
204
|
if (phase !== undefined && inspected.guidance) {
|
|
170
205
|
selectedGuidance = inspected.guidance;
|
|
@@ -183,6 +218,7 @@ async function inspectGuidanceInternal({
|
|
|
183
218
|
}
|
|
184
219
|
|
|
185
220
|
export async function inspectPhaseGuidance({
|
|
221
|
+
filesystem,
|
|
186
222
|
gitRoot,
|
|
187
223
|
phase,
|
|
188
224
|
snapshotTree,
|
|
@@ -194,6 +230,7 @@ export async function inspectPhaseGuidance({
|
|
|
194
230
|
async () => {
|
|
195
231
|
try {
|
|
196
232
|
return await inspectGuidanceInternal({
|
|
233
|
+
filesystem,
|
|
197
234
|
gitRoot,
|
|
198
235
|
phase,
|
|
199
236
|
snapshotTree,
|
|
@@ -214,6 +251,7 @@ export async function inspectPhaseGuidance({
|
|
|
214
251
|
}
|
|
215
252
|
|
|
216
253
|
export async function inspectAllGuidance({
|
|
254
|
+
filesystem,
|
|
217
255
|
gitRoot,
|
|
218
256
|
snapshotTree,
|
|
219
257
|
}) {
|
|
@@ -222,7 +260,11 @@ export async function inspectAllGuidance({
|
|
|
222
260
|
{ scope: "all" },
|
|
223
261
|
async () => {
|
|
224
262
|
try {
|
|
225
|
-
return await inspectGuidanceInternal({
|
|
263
|
+
return await inspectGuidanceInternal({
|
|
264
|
+
filesystem,
|
|
265
|
+
gitRoot,
|
|
266
|
+
snapshotTree,
|
|
267
|
+
});
|
|
226
268
|
} catch (caught) {
|
|
227
269
|
return {
|
|
228
270
|
state: "invalid",
|