silvermoon 0.0.3 → 0.1.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.
@@ -0,0 +1,327 @@
1
+ import {
2
+ DEFAULT_LANGUAGE,
3
+ resolveOutputLanguage,
4
+ } from "./language.js";
5
+
6
+ const ACTIVE_STATES = new Set(["preparing", "implementing", "deploying"]);
7
+ const IDEA_STATES = [
8
+ "preparing",
9
+ "implementing",
10
+ "deploying",
11
+ "completed",
12
+ "abandoned",
13
+ ];
14
+
15
+ function isChinese(language) {
16
+ return language?.toLowerCase().startsWith("zh");
17
+ }
18
+
19
+ export function localize(language, english, chinese) {
20
+ return isChinese(language) ? chinese : english;
21
+ }
22
+
23
+ export function diagnosticProblem(diagnostic, language = "en-US") {
24
+ const location = diagnostic.path ? ` (${diagnostic.path})` : "";
25
+ return {
26
+ type: diagnostic.code.replaceAll(".", "-"),
27
+ summary: localize(
28
+ language,
29
+ `${diagnostic.message}${location}`.trim(),
30
+ diagnostic.path
31
+ ? `在 ${diagnostic.path} 发现 ${diagnostic.code}:${diagnostic.message}`
32
+ : `发现 ${diagnostic.code}:${diagnostic.message}`,
33
+ ),
34
+ };
35
+ }
36
+
37
+ export function diagnosticInstruction(diagnostic, language = "en-US") {
38
+ return localize(
39
+ language,
40
+ diagnostic.remediation,
41
+ diagnostic.path
42
+ ? `修复 ${diagnostic.path} 的 ${diagnostic.code}:${diagnostic.remediation}`
43
+ : `修复 ${diagnostic.code}:${diagnostic.remediation}`,
44
+ );
45
+ }
46
+
47
+ export function outcome(type, status, summary) {
48
+ return { type, status, summary };
49
+ }
50
+
51
+ export function summarizeIdeas(ideas) {
52
+ const counts = Object.fromEntries(IDEA_STATES.map((state) => [state, 0]));
53
+ const activeIdeas = [];
54
+ for (const idea of ideas) {
55
+ counts[idea.state] += 1;
56
+ if (ACTIVE_STATES.has(idea.state)) {
57
+ const reference = { id: idea.id, state: idea.state };
58
+ if (idea.alias !== undefined) reference.alias = idea.alias;
59
+ activeIdeas.push(reference);
60
+ }
61
+ }
62
+ activeIdeas.sort((left, right) => left.id.localeCompare(right.id));
63
+ return { counts, activeIdeas };
64
+ }
65
+
66
+ export function resolvedConfiguration(config, preferredLanguage) {
67
+ return {
68
+ primaryRepository: config.primaryRepository,
69
+ primaryBranch: config.primaryBranch,
70
+ preferredLanguage,
71
+ };
72
+ }
73
+
74
+ export function createEnvelope(intention, observation, outcomes, instructions) {
75
+ return { intention, observation, outcomes, instructions };
76
+ }
77
+
78
+ export function dialogueReadyObservation(observation, state, details = {}) {
79
+ const { root, version, configuration, outputLanguage, problems } = observation;
80
+ return {
81
+ state,
82
+ root,
83
+ version,
84
+ configuration,
85
+ outputLanguage,
86
+ problems,
87
+ ...details,
88
+ };
89
+ }
90
+
91
+ function renderIntention(intention, language, contentLanguage) {
92
+ if (intention.command === "whats-next") {
93
+ return intention.args.idea === null
94
+ ? localize(
95
+ language,
96
+ "Determine the available next work.",
97
+ "查看当前可推进的工作。",
98
+ )
99
+ : localize(
100
+ language,
101
+ `Determine what is next for idea ${intention.args.idea}.`,
102
+ `查看 idea ${intention.args.idea} 接下来应做什么。`,
103
+ );
104
+ }
105
+ if (intention.command === "create-idea") {
106
+ return intention.args.language === null
107
+ ? localize(
108
+ language,
109
+ `Create one new idea and use ${contentLanguage} for natural-language content.`,
110
+ `创建一个新 idea,并使用 ${contentLanguage} 撰写自然语言内容。`,
111
+ )
112
+ : localize(
113
+ language,
114
+ `Create one new idea with language ${intention.args.language}.`,
115
+ `使用 ${intention.args.language} 创建一个新 idea。`,
116
+ );
117
+ }
118
+ throw new Error(`Cannot render dialogue for ${intention.command}`);
119
+ }
120
+
121
+ function renderVersion(version) {
122
+ if (!version) return null;
123
+ if (version.type === "commit" || version.type === "remote") {
124
+ return version.commit === null
125
+ ? version.type
126
+ : `${version.type} ${version.commit}`;
127
+ }
128
+ return version.type;
129
+ }
130
+
131
+ function codeSpan(value) {
132
+ const text = String(value);
133
+ let longest = 0;
134
+ for (const [run] of text.matchAll(/`+/g)) {
135
+ longest = Math.max(longest, run.length);
136
+ }
137
+ const fence = "`".repeat(longest + 1);
138
+ return longest === 0 ? `${fence}${text}${fence}` : `${fence} ${text} ${fence}`;
139
+ }
140
+
141
+ function renderObservation(observation, language) {
142
+ if (observation.state === "idea-selected") {
143
+ const idea = observation.selectedIdea;
144
+ const alias = idea.alias === undefined ? "" : ` (${idea.alias})`;
145
+ return localize(
146
+ language,
147
+ `The project and repository are ready. Selected idea: ${codeSpan(idea.id)}${alias}; state: ${idea.state}.`,
148
+ `项目与 repository 已就绪。当前 idea:${codeSpan(idea.id)}${alias};状态:${idea.state}。`,
149
+ );
150
+ }
151
+ if (observation.state === "idea-created") {
152
+ return localize(
153
+ language,
154
+ `The local repository was ready before creation. Created idea: ${codeSpan(observation.createdIdea.id)}; path: ${codeSpan(observation.createdIdea.path)}; state: preparing.`,
155
+ `创建前项目与本地 repository 已就绪。新建 idea:${codeSpan(observation.createdIdea.id)};路径:${codeSpan(observation.createdIdea.path)};状态:preparing。`,
156
+ );
157
+ }
158
+ if (observation.state === "idea-create-failed") {
159
+ return localize(
160
+ language,
161
+ "The repository was ready before creation, but creating the idea failed.",
162
+ "创建前项目与 repository 已就绪,但创建 idea 失败。",
163
+ );
164
+ }
165
+ const state = {
166
+ "project-setup-required": localize(
167
+ language,
168
+ "The project requires Silvermoon setup.",
169
+ "项目需要完成 Silvermoon 整备。",
170
+ ),
171
+ "repository-sync-required": localize(
172
+ language,
173
+ "The project is configured, but the repository requires synchronization.",
174
+ "项目配置已就绪,但 repository 仍需整备或同步。",
175
+ ),
176
+ "repository-preparation-required": localize(
177
+ language,
178
+ "The project is ready, but the local repository must be prepared before creating an idea.",
179
+ "项目已就绪,但创建 idea 前仍需整备本地 repository。",
180
+ ),
181
+ "navigation-ready": localize(
182
+ language,
183
+ observation.ideas?.activeIdeas.length > 0
184
+ ? "The project and repository are ready, and work is available."
185
+ : "The project and repository are ready, with no active idea.",
186
+ observation.ideas?.activeIdeas.length > 0
187
+ ? "项目与 repository 已就绪,当前有工作可以推进。"
188
+ : "项目与 repository 已就绪,当前没有 active idea。",
189
+ ),
190
+ "idea-not-found": localize(
191
+ language,
192
+ "The project and repository are ready, but the requested idea was not found.",
193
+ "项目与 repository 已就绪,但未找到指定的 idea。",
194
+ ),
195
+ }[observation.state];
196
+ const lines = [
197
+ state,
198
+ `- ${localize(language, "root", "根目录")}: ${codeSpan(observation.root)}`,
199
+ ];
200
+ if (observation.observedThrough) {
201
+ lines.push(
202
+ `- ${localize(language, "observed through", "观测至")}: ${observation.observedThrough}`,
203
+ );
204
+ }
205
+ const version = renderVersion(observation.version);
206
+ if (version) {
207
+ lines.push(`- ${localize(language, "version", "版本")}: ${codeSpan(version)}`);
208
+ }
209
+ if (observation.configuration) {
210
+ lines.push(
211
+ `- ${localize(language, "primary", "主仓库")}: ${codeSpan(`${observation.configuration.primaryRepository}#${observation.configuration.primaryBranch}`)}`,
212
+ );
213
+ lines.push(
214
+ `- ${localize(language, "content language", "内容语言")}: ${observation.configuration.preferredLanguage}`,
215
+ );
216
+ }
217
+ if (observation.ideas) {
218
+ const counts = observation.ideas.counts;
219
+ lines.push(
220
+ `- ${localize(language, "ideas", "ideas")}: preparing=${counts.preparing}, implementing=${counts.implementing}, `
221
+ + `deploying=${counts.deploying}, completed=${counts.completed}, abandoned=${counts.abandoned}`,
222
+ );
223
+ }
224
+ const sections = [`${lines[0]}\n\n${lines.slice(1).join("\n")}`];
225
+ const candidates = observation.state === "idea-not-found"
226
+ ? observation.candidates
227
+ : observation.ideas?.activeIdeas;
228
+ if (candidates?.length > 0) {
229
+ const active = candidates.map((idea) => {
230
+ const alias = idea.alias === undefined ? "" : ` (${idea.alias})`;
231
+ return `- ${codeSpan(idea.id)}${alias} ${idea.state}`;
232
+ });
233
+ sections.push(`### ${localize(language, "Ideas you can continue", "可继续推进的想法")}\n\n${active.join("\n")}`);
234
+ }
235
+ if (observation.problems.length > 0) {
236
+ const problems = observation.problems.map((problem) =>
237
+ `- [${problem.type}] ${problem.summary}`
238
+ );
239
+ sections.push(`### ${localize(language, "Issues to address", "需要处理的问题")}\n\n${problems.join("\n")}`);
240
+ }
241
+ return sections.join("\n\n");
242
+ }
243
+
244
+ function renderOutcomes(outcomes, language) {
245
+ return outcomes
246
+ .map((item) => {
247
+ const status = item.status === "success"
248
+ ? localize(language, "success", "成功")
249
+ : localize(language, "failure", "失败");
250
+ return `- ${status} [${item.type}]: ${item.summary}`;
251
+ })
252
+ .join("\n");
253
+ }
254
+
255
+ export function renderDialogue(report) {
256
+ const contentLanguage =
257
+ report.observation.configuration?.preferredLanguage
258
+ ?? DEFAULT_LANGUAGE;
259
+ const language = report.observation.outputLanguage
260
+ ?? resolveOutputLanguage({
261
+ content: contentLanguage,
262
+ override: report.intention.command === "whats-next"
263
+ ? report.intention.args.language ?? undefined
264
+ : undefined,
265
+ }).tag;
266
+ const visibleOutcomes = report.outcomes.filter((item) =>
267
+ !(item.type === "fetch-primary"
268
+ && item.status === "success"
269
+ && ["navigation-ready", "idea-selected", "idea-not-found", "idea-created", "idea-create-failed"]
270
+ .includes(report.observation.state))
271
+ );
272
+ const sections = [
273
+ [
274
+ localize(language, "Current instruction", "本次指示"),
275
+ renderIntention(report.intention, language, contentLanguage),
276
+ ],
277
+ [
278
+ localize(language, "Project status", "项目现状"),
279
+ renderObservation(report.observation, language),
280
+ ],
281
+ ...(visibleOutcomes.length > 0
282
+ ? [[
283
+ localize(language, "Actions and results", "本次操作及结果"),
284
+ renderOutcomes(visibleOutcomes, language),
285
+ ]]
286
+ : []),
287
+ [
288
+ localize(language, "Suggested next steps", "下一步建议"),
289
+ report.instructions,
290
+ ],
291
+ ];
292
+ return sections.map(([heading, body]) => `## ${heading}\n\n${body}`).join("\n\n");
293
+ }
294
+
295
+ export function renderCheck(report) {
296
+ const language = report.observation.outputLanguage
297
+ ?? resolveOutputLanguage({
298
+ content: report.observation.configuration?.preferredLanguage,
299
+ override: report.intention.args.language ?? undefined,
300
+ }).tag;
301
+ const { target } = report.intention.args;
302
+ const version = renderVersion(report.observation.version);
303
+ const label = target.type === "commit"
304
+ ? `${target.type} ${target.revision}`
305
+ : target.type;
306
+ const valid = report.observation.state === "project-ready";
307
+ const lines = [
308
+ `## ${localize(language, "Check", "检查")}`,
309
+ "",
310
+ `- ${localize(language, "Target", "目标")}: ${codeSpan(label)}`,
311
+ ];
312
+ if (version) lines.push(`- ${localize(language, "Version", "版本")}: ${codeSpan(version)}`);
313
+ lines.push(
314
+ `- ${localize(language, "Result", "结果")}: ${valid
315
+ ? localize(language, "valid", "通过")
316
+ : localize(language, "invalid or unavailable", "未通过或无法验证")}`,
317
+ );
318
+ if (report.observation.problems.length > 0) {
319
+ lines.push("", `### ${localize(language, "Issues to address", "需要处理的问题")}`, "");
320
+ for (const problem of report.observation.problems) {
321
+ lines.push(`- [${problem.type}] ${problem.summary}`);
322
+ }
323
+ }
324
+ return lines.join("\n");
325
+ }
326
+
327
+ export { ACTIVE_STATES, IDEA_STATES };
package/src/git.js CHANGED
@@ -4,15 +4,33 @@ import { mkdtempSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
 
7
+ import { traceAsync, traceSync } from "./trace.js";
8
+
7
9
  let commandObserver = null;
8
10
 
9
- export function runGit(root, args, { env } = {}) {
11
+ export function runGit(root, args, { env, input } = {}) {
10
12
  if (commandObserver) commandObserver([...args]);
11
- const result = spawnSync("git", ["-C", root, ...args], {
12
- encoding: "utf8",
13
- env,
14
- windowsHide: true,
15
- });
13
+ const result = traceSync(
14
+ "git.command",
15
+ {
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
+ encoding: "utf8",
22
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env },
23
+ input,
24
+ windowsHide: true,
25
+ }),
26
+ (completed) => ({
27
+ attributes: {
28
+ exitCode: completed.status,
29
+ signal: completed.signal,
30
+ },
31
+ status: completed.status === 0 ? "ok" : "error",
32
+ }),
33
+ );
16
34
  return {
17
35
  error: result.error ?? null,
18
36
  ok: result.status === 0,
@@ -59,45 +77,31 @@ function requireGit(root, args, label, options) {
59
77
  return result.stdout;
60
78
  }
61
79
 
62
- export function gitObjectIdLength(root) {
63
- const format = requireGit(
80
+ export function inspectTreePaths(root, tree, paths) {
81
+ if (paths.length === 0) return new Map();
82
+ const input = `${paths.map((path) => `${tree}:${path}`).join("\n")}\n`;
83
+ const output = requireGit(
64
84
  root,
65
- ["rev-parse", "--show-object-format"],
66
- "Cannot determine Git object format",
85
+ ["cat-file", "--batch-check=%(objectname) %(objecttype)"],
86
+ "Cannot inspect snapshot world trees",
87
+ { input },
67
88
  );
68
- if (format === "sha1") return 40;
69
- if (format === "sha256") return 64;
70
- throw new Error(`Unsupported Git object format: ${format}`);
71
- }
72
-
73
- export function worktreePathTree(root, path) {
74
- const directory = mkdtempSync(join(tmpdir(), "silvermoon-index-"));
75
- const env = { ...process.env, GIT_INDEX_FILE: join(directory, "index") };
76
- try {
77
- const populated = runGit(root, ["read-tree", "HEAD"], { env });
78
- if (!populated.ok) {
79
- requireGit(root, ["read-tree", "--empty"], "Cannot initialize snapshot index", { env });
80
- }
81
- requireGit(
82
- root,
83
- ["add", "--all", "--", path],
84
- `Cannot snapshot ${path}`,
85
- { env },
89
+ const lines = output.split(/\r?\n/);
90
+ if (lines.length !== paths.length) {
91
+ throw new Error(
92
+ `Cannot inspect snapshot world trees: expected ${paths.length} result(s), received ${lines.length}`,
86
93
  );
87
- const tree = requireGit(root, ["write-tree"], "Cannot write snapshot tree", { env });
88
- const object = requireGit(
89
- root,
90
- ["rev-parse", `${tree}:${path}`],
91
- `Cannot resolve tree for ${path}`,
92
- );
93
- const type = requireGit(root, ["cat-file", "-t", object], `Cannot inspect ${path}`);
94
- if (type !== "tree") throw new Error(`${path} does not resolve to a Git tree`);
95
- return object;
96
- } finally {
97
- rmSync(directory, { recursive: true, force: true });
98
94
  }
95
+ return new Map(paths.map((path, index) => {
96
+ const [object, type, ...unexpected] = lines[index].split(" ");
97
+ if (!object || !type || unexpected.length > 0) {
98
+ throw new Error(`Cannot parse snapshot world tree result for ${path}`);
99
+ }
100
+ return [path, { object: type === "missing" ? null : object, type }];
101
+ }));
99
102
  }
100
- export function worktreeSnapshot(root) {
103
+
104
+ export function worktreeSnapshot(root, { paths } = {}) {
101
105
  const directory = mkdtempSync(join(tmpdir(), "silvermoon-index-"));
102
106
  const env = { ...process.env, GIT_INDEX_FILE: join(directory, "index") };
103
107
  try {
@@ -105,7 +109,9 @@ export function worktreeSnapshot(root) {
105
109
  if (!populated.ok) {
106
110
  requireGit(root, ["read-tree", "--empty"], "Cannot initialize snapshot index", { env });
107
111
  }
108
- requireGit(root, ["add", "--all"], "Cannot snapshot worktree", { env });
112
+ const add = ["add", "--all"];
113
+ if (paths !== undefined) add.push("--", ...paths);
114
+ requireGit(root, add, "Cannot snapshot worktree", { env });
109
115
  return {
110
116
  tree: requireGit(root, ["write-tree"], "Cannot write worktree snapshot", { env }),
111
117
  };
@@ -114,10 +120,6 @@ export function worktreeSnapshot(root) {
114
120
  }
115
121
  }
116
122
 
117
- function paths(stdout) {
118
- return stdout.split(/\r?\n/).filter(Boolean);
119
- }
120
-
121
123
  const CHANGE_KINDS = {
122
124
  A: "added",
123
125
  C: "copied",
@@ -195,6 +197,87 @@ export function parseWorktreeChanges(source) {
195
197
  return result;
196
198
  }
197
199
 
200
+ export function inspectWorktreeChanges(root) {
201
+ const source = requireGit(
202
+ root,
203
+ ["status", "--porcelain=v2", "--untracked-files=all", "-z"],
204
+ "Cannot inspect worktree changes",
205
+ );
206
+ return parseWorktreeChanges(`${source}\0`);
207
+ }
208
+
209
+ export function resolveHead(root) {
210
+ const result = runGit(root, ["rev-parse", "--verify", "HEAD^{commit}"]);
211
+ return result.ok ? result.stdout : null;
212
+ }
213
+
214
+ export function inspectCurrentBranch(root) {
215
+ const branch = runGit(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
216
+ if (!branch.ok) {
217
+ return { branch: null, remote: null, repository: null, upstreamBranch: null };
218
+ }
219
+ const remote = runGit(root, ["config", "--get", `branch.${branch.stdout}.remote`]);
220
+ const merge = runGit(root, ["config", "--get", `branch.${branch.stdout}.merge`]);
221
+ if (!remote.ok || !merge.ok || !merge.stdout.startsWith("refs/heads/")) {
222
+ return {
223
+ branch: branch.stdout,
224
+ remote: remote.ok ? remote.stdout : null,
225
+ repository: null,
226
+ upstreamBranch: merge.ok && merge.stdout.startsWith("refs/heads/")
227
+ ? merge.stdout.slice("refs/heads/".length)
228
+ : null,
229
+ };
230
+ }
231
+ const repository = remote.stdout === "."
232
+ ? null
233
+ : runGit(root, ["config", "--get", `remote.${remote.stdout}.url`]);
234
+ return {
235
+ branch: branch.stdout,
236
+ remote: remote.stdout,
237
+ repository: repository?.ok ? repository.stdout : null,
238
+ upstreamBranch: merge.stdout.slice("refs/heads/".length),
239
+ };
240
+ }
241
+
242
+ export function compareCommits(root, local, remote) {
243
+ if (local === remote) return "aligned";
244
+ const localAncestor = runGit(
245
+ root,
246
+ ["merge-base", "--is-ancestor", local, remote],
247
+ );
248
+ if (localAncestor.ok) {
249
+ return "behind";
250
+ }
251
+ if (localAncestor.status !== 1) {
252
+ throw new Error(
253
+ `Cannot compare local and primary commits: `
254
+ + `${sanitizeGitMessage(localAncestor.stderr || localAncestor.error?.message)}`,
255
+ );
256
+ }
257
+ const remoteAncestor = runGit(
258
+ root,
259
+ ["merge-base", "--is-ancestor", remote, local],
260
+ );
261
+ if (remoteAncestor.ok) {
262
+ return "ahead";
263
+ }
264
+ if (remoteAncestor.status !== 1) {
265
+ throw new Error(
266
+ `Cannot compare local and primary commits: `
267
+ + `${sanitizeGitMessage(remoteAncestor.stderr || remoteAncestor.error?.message)}`,
268
+ );
269
+ }
270
+ const shallow = runGit(root, ["rev-parse", "--is-shallow-repository"]);
271
+ if (!shallow.ok) {
272
+ throw new Error(
273
+ `Cannot determine repository history depth: `
274
+ + `${sanitizeGitMessage(shallow.stderr || shallow.error?.message)}`,
275
+ );
276
+ }
277
+ if (shallow.stdout === "true") return "unknown";
278
+ return "diverged";
279
+ }
280
+
198
281
  export function resolveCommit(root, revision) {
199
282
  return requireGit(
200
283
  root,
@@ -203,51 +286,41 @@ export function resolveCommit(root, revision) {
203
286
  );
204
287
  }
205
288
 
206
- export function commitChangedPaths(root, commit) {
207
- const parent = runGit(root, ["rev-parse", "--verify", `${commit}^1`]);
208
- const changed = parent.ok
209
- ? requireGit(root, ["diff", "--name-only", parent.stdout, commit, "--"], `Cannot inspect commit ${commit}`)
210
- : requireGit(
211
- root,
212
- ["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", commit, "--"],
213
- `Cannot inspect root commit ${commit}`,
214
- );
215
- return paths(changed);
216
- }
217
-
218
289
  export function indexSnapshot(root) {
219
290
  return {
220
- paths: paths(requireGit(root, ["diff", "--cached", "--name-only", "--"], "Cannot inspect staged changes")),
221
291
  tree: requireGit(root, ["write-tree"], "Cannot snapshot the index"),
222
292
  };
223
293
  }
224
294
 
225
295
  export function fetchRepositoryBranch(root, repository, branch) {
226
296
  const remoteRef = `refs/heads/${branch}`;
227
- for (let attempt = 0; attempt < 3; attempt += 1) {
228
- const advertised = requireGit(
229
- root,
230
- ["ls-remote", "--refs", repository, remoteRef],
231
- `Cannot inspect ${branch}`,
232
- );
233
- const [primary, advertisedRef] = advertised.split(/\s+/);
234
- if (!primary || advertisedRef !== remoteRef) {
235
- throw new Error(`Cannot resolve remote branch ${branch}`);
236
- }
237
- requireGit(
238
- root,
239
- [
240
- "fetch",
241
- "--no-tags",
242
- "--no-write-fetch-head",
243
- repository,
244
- remoteRef,
245
- ],
246
- `Cannot fetch ${branch}`,
297
+ const fetched = requireGit(
298
+ root,
299
+ [
300
+ "fetch",
301
+ "--porcelain",
302
+ "--no-tags",
303
+ repository,
304
+ remoteRef,
305
+ ],
306
+ `Cannot fetch ${branch}`,
307
+ );
308
+ const updates = fetched
309
+ .split(/\r?\n/)
310
+ .map((line) => line.trim().split(/\s+/))
311
+ .filter((fields) =>
312
+ fields.length === 4
313
+ && fields[3] === "FETCH_HEAD"
314
+ && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(fields[2])
247
315
  );
248
- if (runGit(root, ["cat-file", "-e", `${primary}^{commit}`]).ok) return primary;
316
+ if (updates.length !== 1) {
317
+ throw new Error(`Cannot determine fetched commit for branch ${branch}`);
249
318
  }
250
- throw new Error(`Remote branch ${branch} moved repeatedly while fetching`);
319
+ const primary = updates[0][2];
320
+ if (!runGit(root, ["cat-file", "-e", `${primary}^{commit}`]).ok) {
321
+ throw new Error(`Fetched branch ${branch} does not resolve to a commit`);
322
+ }
323
+ return primary;
251
324
  }
252
325
 
253
326
  export function fetchPrimary(root, config) {
@@ -264,21 +337,23 @@ export async function withTemporaryWorktree(root, commit, callback) {
264
337
  }
265
338
 
266
339
  export async function withTemporaryTree(root, tree, callback) {
267
- const temporaryRoot = await mkdtemp(join(tmpdir(), "silvermoon-tree-"));
268
- const directory = join(temporaryRoot, "snapshot");
269
- const env = { ...process.env, GIT_INDEX_FILE: join(temporaryRoot, "index") };
270
- try {
271
- await mkdir(directory);
272
- requireGit(root, ["read-tree", tree], "Cannot populate isolated index", { env });
273
- const prefix = `${directory.replaceAll("\\", "/")}/`;
274
- requireGit(
275
- root,
276
- ["checkout-index", "--all", "--force", `--prefix=${prefix}`],
277
- "Cannot populate isolated tree snapshot",
278
- { env },
279
- );
280
- return await callback(directory, tree);
281
- } finally {
282
- await rm(temporaryRoot, { recursive: true, force: true });
283
- }
340
+ return traceAsync("snapshot.materialize", {}, async () => {
341
+ const temporaryRoot = await mkdtemp(join(tmpdir(), "silvermoon-tree-"));
342
+ const directory = join(temporaryRoot, "snapshot");
343
+ const env = { ...process.env, GIT_INDEX_FILE: join(temporaryRoot, "index") };
344
+ try {
345
+ await mkdir(directory);
346
+ requireGit(root, ["read-tree", tree], "Cannot populate isolated index", { env });
347
+ const prefix = `${directory.replaceAll("\\", "/")}/`;
348
+ requireGit(
349
+ root,
350
+ ["checkout-index", "--all", "--force", `--prefix=${prefix}`],
351
+ "Cannot populate isolated tree snapshot",
352
+ { env },
353
+ );
354
+ return await callback(directory, tree);
355
+ } finally {
356
+ await rm(temporaryRoot, { recursive: true, force: true });
357
+ }
358
+ });
284
359
  }