silvermoon 0.2.1 → 0.3.0-rc.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/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 +23 -11
- 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/skills/silvermoon/references/adoption.md +3 -2
- package/src/adoption.js +51 -35
- 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
package/src/create-idea.js
CHANGED
|
@@ -10,10 +10,7 @@ import {
|
|
|
10
10
|
import { createCommandRun } from "./domain.js";
|
|
11
11
|
import { inspectPhaseGuidance } from "./guidance.js";
|
|
12
12
|
import {
|
|
13
|
-
|
|
14
|
-
IDEA_TEMPLATE,
|
|
15
|
-
IMPLEMENTATION_TEMPLATE,
|
|
16
|
-
LEDGER_TEMPLATE,
|
|
13
|
+
ideaTemplates,
|
|
17
14
|
} from "./idea-templates.js";
|
|
18
15
|
import { isValidUlid, serializeIdeaStatus } from "./ideas.js";
|
|
19
16
|
import { canonicalizeLanguageTag } from "./language.js";
|
|
@@ -209,6 +206,7 @@ async function cleanupScaffold(cleanupPlan, operations) {
|
|
|
209
206
|
|
|
210
207
|
async function createScaffold({
|
|
211
208
|
canonicalLanguage,
|
|
209
|
+
contentLanguage,
|
|
212
210
|
generateId,
|
|
213
211
|
operations,
|
|
214
212
|
repositoryRoot,
|
|
@@ -263,11 +261,12 @@ async function createScaffold({
|
|
|
263
261
|
paths.innerPath,
|
|
264
262
|
paths.idealPath,
|
|
265
263
|
];
|
|
264
|
+
const templates = ideaTemplates(contentLanguage);
|
|
266
265
|
const files = [
|
|
267
|
-
[paths.ideaDocumentPath,
|
|
268
|
-
[paths.implementationDocumentPath,
|
|
269
|
-
[paths.deploymentDocumentPath,
|
|
270
|
-
[paths.ledgerPath,
|
|
266
|
+
[paths.ideaDocumentPath, templates.idea],
|
|
267
|
+
[paths.implementationDocumentPath, templates.implementation],
|
|
268
|
+
[paths.deploymentDocumentPath, templates.deployment],
|
|
269
|
+
[paths.ledgerPath, templates.ledger],
|
|
271
270
|
[paths.statusPath, serializeIdeaStatus({
|
|
272
271
|
version: 1,
|
|
273
272
|
id,
|
|
@@ -420,6 +419,7 @@ export async function createIdea({
|
|
|
420
419
|
},
|
|
421
420
|
() => createScaffold({
|
|
422
421
|
canonicalLanguage,
|
|
422
|
+
contentLanguage: observed.contentLanguage,
|
|
423
423
|
generateId,
|
|
424
424
|
operations,
|
|
425
425
|
repositoryRoot,
|
|
@@ -467,6 +467,18 @@ export async function createIdea({
|
|
|
467
467
|
}
|
|
468
468
|
|
|
469
469
|
const { createdIdea, paths } = completion.result;
|
|
470
|
+
const templates = ideaTemplates(observed.contentLanguage);
|
|
471
|
+
const languageInstruction = templates.localized
|
|
472
|
+
? localize(
|
|
473
|
+
observed.outputLanguage,
|
|
474
|
+
`Use ${observed.contentLanguage} for all natural-language content in the canonical idea documents and ledger.`,
|
|
475
|
+
`在 canonical idea 文档和 ledger 的所有自然语言内容中使用 ${observed.contentLanguage}。`,
|
|
476
|
+
)
|
|
477
|
+
: localize(
|
|
478
|
+
observed.outputLanguage,
|
|
479
|
+
`Silvermoon has no built-in ${observed.contentLanguage} scaffold, so the new files contain explicit ${templates.templateLanguage} fallback placeholders. Replace all natural-language placeholders with ${observed.contentLanguage} before continuing.`,
|
|
480
|
+
`Silvermoon 没有内置的 ${observed.contentLanguage} 脚手架,因此新文件包含明确的 ${templates.templateLanguage} fallback 占位。继续前,将所有自然语言占位替换为 ${observed.contentLanguage}。`,
|
|
481
|
+
);
|
|
470
482
|
return runtime.complete(
|
|
471
483
|
dialogueReadyObservation(readiness.observation, "idea-created", {
|
|
472
484
|
createdIdea,
|
|
@@ -477,8 +489,8 @@ export async function createIdea({
|
|
|
477
489
|
{
|
|
478
490
|
nextSteps: localize(
|
|
479
491
|
observed.outputLanguage,
|
|
480
|
-
|
|
481
|
-
|
|
492
|
+
`${languageInstruction} Describe the requested Ideal World in ${paths.ideaDocumentPath}; keep canonical headings, stable IDs, and placeholders in ${paths.implementationDocumentPath}, ${paths.deploymentDocumentPath}, and ${paths.ledgerPath} synchronized.`,
|
|
493
|
+
`${languageInstruction} 在 ${paths.ideaDocumentPath} 中描述请求的理想契约;保持 ${paths.implementationDocumentPath}、${paths.deploymentDocumentPath} 与 ${paths.ledgerPath} 中的 canonical 标题、稳定 ID 和占位同步。`,
|
|
482
494
|
),
|
|
483
495
|
},
|
|
484
496
|
);
|
|
@@ -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
|
|