@davideasden/pi-undo 0.1.1 → 0.2.0

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/src/diff-ui.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { renderDiff } from "@earendil-works/pi-coding-agent";
3
+ import { type Component, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+
5
+ import { buildFileLabel, type FileDiff, sanitizeDisplayText } from "./diff-view.ts";
6
+
7
+ const CLOSE_LABEL = "← Close";
8
+
9
+ /**
10
+ * 在 TUI 中交互浏览一次 Agent run 的逐文件 diff:
11
+ * 上层 select 列出改动文件,选中后用可滚动彩色查看器展示该文件 diff,返回后回到列表。
12
+ */
13
+ export async function browseDiff(context: ExtensionCommandContext, title: string, diffs: readonly FileDiff[]): Promise<void> {
14
+ const labels = new Map<string, FileDiff>();
15
+ const options: string[] = [];
16
+ for (const diff of diffs) {
17
+ const label = uniqueLabel(buildFileLabel(diff), labels);
18
+ labels.set(label, diff);
19
+ options.push(label);
20
+ }
21
+ options.push(CLOSE_LABEL);
22
+
23
+ for (;;) {
24
+ const choice = await context.ui.select(title, options);
25
+ if (choice === undefined || choice === CLOSE_LABEL) return;
26
+ const diff = labels.get(choice);
27
+ if (diff === undefined) return;
28
+ await showFileDiff(context, diff);
29
+ }
30
+ }
31
+
32
+ function uniqueLabel(label: string, used: ReadonlyMap<string, FileDiff>): string {
33
+ if (!used.has(label)) return label;
34
+ let index = 2;
35
+ while (used.has(`${label} (${index})`)) index += 1;
36
+ return `${label} (${index})`;
37
+ }
38
+
39
+ async function showFileDiff(context: ExtensionCommandContext, diff: FileDiff): Promise<void> {
40
+ await context.ui.custom<void>((tui, theme, _keybindings, done) => new DiffViewer(tui, theme, diff, done), {
41
+ overlay: true,
42
+ overlayOptions: { width: "90%", maxHeight: "90%" },
43
+ });
44
+ }
45
+
46
+ /** 只读、可滚动的单文件 diff 查看器;不修改任何会话或存储状态。 */
47
+ class DiffViewer implements Component {
48
+ private readonly lines: readonly string[];
49
+ private offset = 0;
50
+
51
+ constructor(
52
+ private readonly tui: { requestRender(force?: boolean): void },
53
+ private readonly theme: Theme,
54
+ private readonly diff: FileDiff,
55
+ private readonly done: (result: void) => void,
56
+ ) {
57
+ this.lines = this.buildLines();
58
+ }
59
+
60
+ invalidate(): void {}
61
+
62
+ private buildLines(): string[] {
63
+ if (this.diff.kind === "binary") return [this.theme.fg("dim", "Binary file changed; line-by-line diff unavailable.")];
64
+ if (this.diff.diff === "") return [this.theme.fg("dim", "No textual change (mode or type only).")];
65
+ return renderDiff(this.diff.diff).split("\n");
66
+ }
67
+
68
+ private viewportRows(): number {
69
+ const rows = process.stdout.rows ?? 24;
70
+ // 预留 overlay 边框、标题、footer 的空间。
71
+ return Math.max(3, Math.min(this.lines.length, rows - 8));
72
+ }
73
+
74
+ private maxOffset(): number {
75
+ return Math.max(0, this.lines.length - this.viewportRows());
76
+ }
77
+
78
+ handleInput(data: string): void {
79
+ const rows = this.viewportRows();
80
+ const previous = this.offset;
81
+ if (matchesKey(data, "escape") || matchesKey(data, "q") || matchesKey(data, "enter")) {
82
+ this.done();
83
+ return;
84
+ }
85
+ if (matchesKey(data, "up") || matchesKey(data, "k")) this.offset -= 1;
86
+ else if (matchesKey(data, "down") || matchesKey(data, "j")) this.offset += 1;
87
+ else if (matchesKey(data, "pageUp")) this.offset -= rows;
88
+ else if (matchesKey(data, "pageDown") || matchesKey(data, "space")) this.offset += rows;
89
+ else if (matchesKey(data, "home") || matchesKey(data, "g")) this.offset = 0;
90
+ else if (matchesKey(data, "end") || matchesKey(data, "shift+g")) this.offset = this.maxOffset();
91
+ this.offset = Math.max(0, Math.min(this.maxOffset(), this.offset));
92
+ if (this.offset !== previous) this.tui.requestRender();
93
+ }
94
+
95
+ render(width: number): string[] {
96
+ const rows = this.viewportRows();
97
+ // 终端 resize 会改变 viewportRows,重绘时必须重新夹紧滚动位置。
98
+ this.offset = Math.max(0, Math.min(this.maxOffset(), this.offset));
99
+ const stats = this.diff.kind === "binary"
100
+ ? "binary"
101
+ : `+${this.diff.additions} -${this.diff.deletions}`;
102
+ const safePath = sanitizeDisplayText(this.diff.path, 240);
103
+ const header = truncateToWidth(this.theme.fg("accent", `${this.diff.status.toUpperCase()} ${safePath}`) + this.theme.fg("dim", ` ${stats}`), width);
104
+ const out: string[] = [header, this.theme.fg("borderMuted", "─".repeat(Math.min(width, 80)))];
105
+ const visible = this.lines.slice(this.offset, this.offset + rows);
106
+ for (const line of visible) {
107
+ out.push(visibleWidth(line) > width ? truncateToWidth(line, width) : line);
108
+ }
109
+ for (let index = visible.length; index < rows; index += 1) out.push("");
110
+ const position = this.lines.length <= rows
111
+ ? "all"
112
+ : `${this.offset + 1}-${Math.min(this.offset + rows, this.lines.length)}/${this.lines.length}`;
113
+ out.push(this.theme.fg("dim", `↑↓/PgUp/PgDn scroll · ${position} · Esc/q close`));
114
+ return out;
115
+ }
116
+ }
@@ -0,0 +1,169 @@
1
+ import { generateDiffString } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { ManifestId, RestorePath, SnapshotManifest } from "./model.ts";
4
+
5
+ /** SnapshotStore 的只读子集;/diff 只需要读取 manifest 与 blob,不触碰任何恢复路径。 */
6
+ export interface DiffSource {
7
+ loadManifest(id: ManifestId): Promise<SnapshotManifest>;
8
+ listTree(id: ManifestId, root: string): Promise<readonly RestorePath[]>;
9
+ readBlob(id: ManifestId, root: string, blobId: string): Promise<Uint8Array>;
10
+ }
11
+
12
+ export type FileDiffStatus = "added" | "deleted" | "modified";
13
+ export type FileDiffKind = "text" | "binary" | "symlink";
14
+
15
+ export interface FileDiff {
16
+ readonly path: string;
17
+ readonly status: FileDiffStatus;
18
+ readonly kind: FileDiffKind;
19
+ readonly additions: number;
20
+ readonly deletions: number;
21
+ /** 带行号的展示 diff;二进制文件或无逐行差异时为空字符串。 */
22
+ readonly diff: string;
23
+ }
24
+
25
+ export interface CheckpointDiffRequest {
26
+ readonly beforeManifestId: ManifestId;
27
+ readonly afterManifestId: ManifestId;
28
+ readonly changedPaths: readonly string[];
29
+ }
30
+
31
+ interface LeafEntry {
32
+ readonly rootPath: string;
33
+ readonly entry: RestorePath;
34
+ }
35
+
36
+ interface LeafContent {
37
+ readonly binary: boolean;
38
+ readonly symlink: boolean;
39
+ readonly text: string;
40
+ }
41
+
42
+ /**
43
+ * 基于 checkpoint 的 before/after 快照计算逐文件 diff。
44
+ * changedPaths 中的目录项与两侧都不存在的路径会被跳过,只保留叶子文件与符号链接。
45
+ */
46
+ export async function computeCheckpointDiff(
47
+ source: DiffSource,
48
+ request: CheckpointDiffRequest,
49
+ ): Promise<FileDiff[]> {
50
+ const [before, after] = await Promise.all([
51
+ source.loadManifest(request.beforeManifestId),
52
+ source.loadManifest(request.afterManifestId),
53
+ ]);
54
+ const [beforePaths, afterPaths] = await Promise.all([
55
+ readLeafEntries(source, before),
56
+ readLeafEntries(source, after),
57
+ ]);
58
+
59
+ const result: FileDiff[] = [];
60
+ for (const path of request.changedPaths) {
61
+ const beforeLeaf = beforePaths.get(path);
62
+ const afterLeaf = afterPaths.get(path);
63
+ if (beforeLeaf === undefined && afterLeaf === undefined) continue;
64
+ const status: FileDiffStatus = beforeLeaf === undefined
65
+ ? "added"
66
+ : afterLeaf === undefined ? "deleted" : "modified";
67
+ const beforeContent = beforeLeaf === undefined
68
+ ? emptyContent()
69
+ : await readLeafContent(source, request.beforeManifestId, beforeLeaf);
70
+ const afterContent = afterLeaf === undefined
71
+ ? emptyContent()
72
+ : await readLeafContent(source, request.afterManifestId, afterLeaf);
73
+ if (beforeContent.binary || afterContent.binary) {
74
+ result.push({ path, status, kind: "binary", additions: 0, deletions: 0, diff: "" });
75
+ continue;
76
+ }
77
+ const kind: FileDiffKind = beforeContent.symlink || afterContent.symlink ? "symlink" : "text";
78
+ const { diff } = generateDiffString(beforeContent.text, afterContent.text);
79
+ result.push({ path, status, kind, ...countChanges(diff), diff });
80
+ }
81
+ return result;
82
+ }
83
+
84
+ async function readLeafEntries(source: DiffSource, manifest: SnapshotManifest): Promise<Map<string, LeafEntry>> {
85
+ const result = new Map<string, LeafEntry>();
86
+ for (const root of manifest.roots) {
87
+ if (root.state !== "active" || root.treeId === null) continue;
88
+ for (const entry of await source.listTree(manifest.manifestId, root.relativeRoot)) {
89
+ if (entry.kind === "directory") continue;
90
+ const path = root.relativeRoot === "." ? entry.relativePath : `${root.relativeRoot}/${entry.relativePath}`;
91
+ if (!result.has(path)) result.set(path, { rootPath: root.relativeRoot, entry });
92
+ }
93
+ }
94
+ return result;
95
+ }
96
+
97
+ async function readLeafContent(source: DiffSource, manifestId: ManifestId, leaf: LeafEntry): Promise<LeafContent> {
98
+ if (leaf.entry.kind === "symlink") {
99
+ return { binary: false, symlink: true, text: sanitizeDiffContent(leaf.entry.linkText ?? "") };
100
+ }
101
+ if (leaf.entry.blobId === null) {
102
+ return { binary: true, symlink: false, text: "" };
103
+ }
104
+ const bytes = await source.readBlob(manifestId, leaf.rootPath, leaf.entry.blobId);
105
+ const text = decodeLossless(bytes);
106
+ return text === null
107
+ ? { binary: true, symlink: false, text: "" }
108
+ : { binary: false, symlink: false, text: sanitizeDiffContent(text) };
109
+ }
110
+
111
+ function emptyContent(): LeafContent {
112
+ return { binary: false, symlink: false, text: "" };
113
+ }
114
+
115
+ function decodeLossless(bytes: Uint8Array): string | null {
116
+ if (bytes.includes(0)) return null;
117
+ const buffer = Buffer.from(bytes);
118
+ const text = buffer.toString("utf8");
119
+ return Buffer.from(text, "utf8").equals(buffer) ? text : null;
120
+ }
121
+
122
+ function sanitizeDiffContent(text: string): string {
123
+ return text
124
+ .replace(/\x1B/g, "[ESC]")
125
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001A\u001C-\u001F\u007F]/g, "?");
126
+ }
127
+
128
+ function countChanges(diff: string): { additions: number; deletions: number } {
129
+ let additions = 0;
130
+ let deletions = 0;
131
+ for (const line of diff.split("\n")) {
132
+ if (line.startsWith("+")) additions += 1;
133
+ else if (line.startsWith("-")) deletions += 1;
134
+ }
135
+ return { additions, deletions };
136
+ }
137
+
138
+ const STATUS_CHAR: Record<FileDiffStatus, string> = { added: "A", deleted: "D", modified: "M" };
139
+
140
+ /** 单个文件的一行摘要,用于文件清单与非 TUI 降级展示。 */
141
+ export function buildFileLabel(diff: FileDiff): string {
142
+ const stats = diff.kind === "binary"
143
+ ? "(binary)"
144
+ : diff.additions === 0 && diff.deletions === 0 ? "(no textual change)" : `+${diff.additions} -${diff.deletions}`;
145
+ return `${STATUS_CHAR[diff.status]} ${sanitizeDisplayText(diff.path, 240)} ${stats}`;
146
+ }
147
+
148
+ /** 整个 checkpoint 的一行摘要,用于非 TUI 模式的 notify。 */
149
+ export function formatDiffSummary(diffs: readonly FileDiff[]): string {
150
+ let additions = 0;
151
+ let deletions = 0;
152
+ for (const diff of diffs) {
153
+ additions += diff.additions;
154
+ deletions += diff.deletions;
155
+ }
156
+ const paths = diffs.map((diff) => sanitizeDisplayText(diff.path, 160)).join(", ");
157
+ return sanitizeDisplayText(`${diffs.length} file(s), +${additions} -${deletions}: ${paths}`, 500);
158
+ }
159
+
160
+ /** 清理可能进入终端 UI 的外部文本,避免 ANSI/控制序列注入。 */
161
+ export function sanitizeDisplayText(value: string, maxLength = 200): string {
162
+ return value
163
+ .replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "")
164
+ .replace(/\x1B\][^\u0007]*(?:\u0007|\x1B\\)/g, "")
165
+ .replace(/[\u0000-\u001F\u007F]+/g, " ")
166
+ .replace(/\s+/g, " ")
167
+ .trim()
168
+ .slice(0, maxLength);
169
+ }
package/src/pi-runtime.ts CHANGED
@@ -154,6 +154,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
154
154
  return {
155
155
  controller,
156
156
  reporter: new StatusReporter(context),
157
+ diffSource: store,
157
158
  recovery: startupRecovery.kind === "locked"
158
159
  ? { files: startupRecovery.files, opId: startupRecovery.opId }
159
160
  : undefined,
@@ -22,6 +22,8 @@ import {
22
22
  } from "./quarantine.ts";
23
23
  import { SnapshotStoreError, type SnapshotStore } from "./snapshot-store.ts";
24
24
 
25
+ const PREPARED_PLAN_CACHE_LIMIT = 16;
26
+
25
27
  export interface RestorePlan {
26
28
  currentManifestId: ManifestId;
27
29
  targetManifestId: ManifestId;
@@ -69,6 +71,12 @@ interface OwnedPath {
69
71
  readonly root: SnapshotRoot;
70
72
  }
71
73
 
74
+ interface PreparedRestorePlan {
75
+ readonly plan: RestorePlan;
76
+ readonly currentPaths: ReadonlyMap<string, OwnedPath>;
77
+ readonly targetPaths: ReadonlyMap<string, OwnedPath>;
78
+ }
79
+
72
80
  interface MutationContext {
73
81
  readonly phase: RestoreMutation["phase"];
74
82
  readonly sourceManifestId: ManifestId;
@@ -88,6 +96,7 @@ export class RestoreEngine {
88
96
  private readonly store: SnapshotStore;
89
97
  private readonly discovery: RootDiscovery;
90
98
  private readonly beforeMutation: RestoreEngineOptions["beforeMutation"];
99
+ private readonly preparedPlans = new Map<string, PreparedRestorePlan>();
91
100
 
92
101
  constructor(options: RestoreEngineOptions) {
93
102
  this.requestedWorkspaceRoot = resolve(options.workspaceRoot);
@@ -174,10 +183,33 @@ export class RestoreEngine {
174
183
  writePaths,
175
184
  ...(scope === undefined ? {} : { scopePaths: [...scope] }),
176
185
  };
177
- return {
186
+ const plan = {
178
187
  ...semanticPlan,
179
188
  planDigest: checksum(canonicalJson(semanticPlan)),
180
189
  };
190
+ this.rememberPreparedPlan(plan, currentPaths, targetPaths);
191
+ return plan;
192
+ }
193
+
194
+ private rememberPreparedPlan(
195
+ plan: RestorePlan,
196
+ currentPaths: ReadonlyMap<string, OwnedPath>,
197
+ targetPaths: ReadonlyMap<string, OwnedPath>,
198
+ ): void {
199
+ const cachedPlan = cloneRestorePlan(plan);
200
+ this.preparedPlans.set(preparedPlanKey(cachedPlan), { plan: cachedPlan, currentPaths, targetPaths });
201
+ while (this.preparedPlans.size > PREPARED_PLAN_CACHE_LIMIT) {
202
+ const oldest = this.preparedPlans.keys().next().value as string | undefined;
203
+ if (oldest === undefined) break;
204
+ this.preparedPlans.delete(oldest);
205
+ }
206
+ }
207
+
208
+ private takePreparedPlan(plan: RestorePlan): PreparedRestorePlan | undefined {
209
+ const key = preparedPlanKey(plan);
210
+ const prepared = this.preparedPlans.get(key);
211
+ if (prepared !== undefined) this.preparedPlans.delete(key);
212
+ return prepared;
181
213
  }
182
214
 
183
215
  async apply(
@@ -285,8 +317,10 @@ export class RestoreEngine {
285
317
  }
286
318
  assertCompatibleManifests(current, target);
287
319
  let expectedPlan: RestorePlan;
320
+ let prepared: PreparedRestorePlan | undefined;
288
321
  try {
289
322
  expectedPlan = await this.plan(current, target, plan.scopePaths);
323
+ prepared = this.takePreparedPlan(expectedPlan);
290
324
  } catch (error) {
291
325
  if (error instanceof SnapshotStoreError && error.code === "object_missing") {
292
326
  return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
@@ -307,10 +341,9 @@ export class RestoreEngine {
307
341
  } catch {
308
342
  return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
309
343
  }
310
- const [currentPaths, targetPaths] = await Promise.all([
311
- this.readOwnedPaths(current),
312
- this.readOwnedPaths(target),
313
- ]);
344
+ const [currentPaths, targetPaths] = prepared === undefined
345
+ ? await Promise.all([this.readOwnedPaths(current), this.readOwnedPaths(target)])
346
+ : [prepared.currentPaths, prepared.targetPaths];
314
347
  const quarantine = new QuarantineManager({
315
348
  workspaceRoot: this.requestedWorkspaceRoot,
316
349
  journal: options.mutationJournal,
@@ -817,12 +850,11 @@ export class RestoreEngine {
817
850
  }
818
851
  }
819
852
 
820
- const live = await this.store.capture(topology, undefined, {
853
+ const livePaths = await this.store.listVisibleLeafPaths(topology, {
821
854
  excludePaths: mutationJournal === undefined ? undefined : [...await mutationJournal.activeArtifacts()],
822
855
  });
823
- await this.store.assertComplete(live.manifestId);
824
- for (const [path, owned] of await this.readOwnedPaths(live)) {
825
- if (owned.entry.kind !== "directory" && !allowedPaths.has(path)) {
856
+ for (const path of livePaths) {
857
+ if (!allowedPaths.has(path)) {
826
858
  throw new Error(`complete coverage 发现 manifest 集合外路径:${path}`);
827
859
  }
828
860
  }
@@ -1025,6 +1057,22 @@ export class RestoreEngine {
1025
1057
  }
1026
1058
  }
1027
1059
 
1060
+ function preparedPlanKey(plan: RestorePlan): string {
1061
+ return `${plan.currentManifestId}\0${plan.targetManifestId}\0${plan.planDigest}`;
1062
+ }
1063
+
1064
+ function cloneRestorePlan(plan: RestorePlan): RestorePlan {
1065
+ return {
1066
+ currentManifestId: plan.currentManifestId,
1067
+ targetManifestId: plan.targetManifestId,
1068
+ boundaryRoots: [...plan.boundaryRoots],
1069
+ deletePaths: [...plan.deletePaths],
1070
+ writePaths: [...plan.writePaths],
1071
+ ...(plan.scopePaths === undefined ? {} : { scopePaths: [...plan.scopePaths] }),
1072
+ planDigest: plan.planDigest,
1073
+ };
1074
+ }
1075
+
1028
1076
  function sameEntry(left: RestorePath, right: RestorePath): boolean {
1029
1077
  return left.kind === right.kind &&
1030
1078
  left.mode === right.mode &&
@@ -5,6 +5,8 @@ import { checksum, topologyFingerprint } from "./encoding.ts";
5
5
  import { GitRunner } from "./git-runner.ts";
6
6
  import type { DiscoveryRoot } from "./model.ts";
7
7
 
8
+ const DIRECTORY_SCAN_CONCURRENCY = 16;
9
+
8
10
  interface RepositoryInfo {
9
11
  readonly absoluteRoot: string;
10
12
  readonly commonGitDir: string;
@@ -87,22 +89,27 @@ export class RootDiscovery {
87
89
  directory: string,
88
90
  activeRoots: Map<string, DiscoveredRoot>,
89
91
  ): Promise<void> {
90
- if (!await isSafeDirectory(directory, workspaceIdentity)) {
91
- return;
92
- }
93
- const entries = await readdir(directory, { withFileTypes: true });
94
- if (!await isSafeDirectory(directory, workspaceIdentity)) {
95
- return;
96
- }
97
- for (const entry of entries) {
98
- if (entry.name === ".git" || entry.isSymbolicLink() || !entry.isDirectory()) {
99
- continue;
100
- }
101
- const candidate = join(directory, entry.name);
102
- if (!await isSafeDirectory(candidate, workspaceIdentity)) {
103
- continue;
92
+ let level: Array<{ readonly path: string; readonly inspect: boolean }> = [{ path: directory, inspect: false }];
93
+ while (level.length > 0) {
94
+ const next: Array<{ readonly path: string; readonly inspect: true }> = [];
95
+ for (let index = 0; index < level.length; index += DIRECTORY_SCAN_CONCURRENCY) {
96
+ const children = await Promise.all(level.slice(index, index + DIRECTORY_SCAN_CONCURRENCY).map(
97
+ (candidate) => this.scanDirectoryNode(workspaceIdentity, candidate, activeRoots),
98
+ ));
99
+ for (const group of children) next.push(...group);
104
100
  }
105
- const inspection = await this.inspectRepository(candidate, workspaceIdentity);
101
+ level = next;
102
+ }
103
+ }
104
+
105
+ private async scanDirectoryNode(
106
+ workspaceIdentity: string,
107
+ candidate: { readonly path: string; readonly inspect: boolean },
108
+ activeRoots: Map<string, DiscoveredRoot>,
109
+ ): Promise<Array<{ readonly path: string; readonly inspect: true }>> {
110
+ if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
111
+ if (candidate.inspect) {
112
+ const inspection = await this.inspectRepository(candidate.path, workspaceIdentity);
106
113
  if (inspection.kind === "active") {
107
114
  activeRoots.set(
108
115
  inspection.repository.absoluteRoot,
@@ -111,8 +118,13 @@ export class RootDiscovery {
111
118
  } else if (inspection.kind === "broken") {
112
119
  activeRoots.set(inspection.absoluteRoot, brokenRoot(workspaceIdentity, inspection.absoluteRoot));
113
120
  }
114
- await this.scanDirectory(workspaceIdentity, candidate, activeRoots);
121
+ if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
115
122
  }
123
+ const entries = await readdir(candidate.path, { withFileTypes: true });
124
+ if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
125
+ return entries
126
+ .filter((entry) => entry.name !== ".git" && !entry.isSymbolicLink() && entry.isDirectory())
127
+ .map((entry) => ({ path: join(candidate.path, entry.name), inspect: true as const }));
116
128
  }
117
129
 
118
130
  private async inspectRepository(candidate: string, workspaceIdentity: string): Promise<RepositoryInspection> {