@nseng-ai/plans 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.
- package/package.json +29 -0
- package/src/api.ts +25 -0
- package/src/cli.ts +430 -0
- package/src/content-slug-derivation.ts +127 -0
- package/src/index.ts +72 -0
- package/src/plan-persistence.ts +149 -0
- package/src/plan-store-gateway.ts +104 -0
- package/src/saved-plan-content-slug.ts +31 -0
- package/src/saved-plan-file.ts +583 -0
- package/src/saved-plan-selection.ts +361 -0
- package/src/testing.ts +129 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { basename, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { CommandExecApi } from "@nseng-ai/foundation/exec";
|
|
5
|
+
import { RealGitGateway } from "@nseng-ai/capability-kit/git";
|
|
6
|
+
import type { GitGateway } from "@nseng-ai/capability-kit/git";
|
|
7
|
+
import {
|
|
8
|
+
githubRepositoryIdentityFromNormalizedRemoteUrl,
|
|
9
|
+
normalizeGitRemoteUrl,
|
|
10
|
+
} from "@nseng-ai/capability-kit/github/identity";
|
|
11
|
+
import { normalizeSummary, validatePlanSlug } from "./plan-persistence.ts";
|
|
12
|
+
import { createRealPlanStoreGateway, type PlanStoreGateway } from "./plan-store-gateway.ts";
|
|
13
|
+
import { requireXdgPath, resolveNsXdgPath } from "@nseng-ai/capability-kit/xdg";
|
|
14
|
+
import {
|
|
15
|
+
isRecord,
|
|
16
|
+
optionalEntries,
|
|
17
|
+
optionalEntry,
|
|
18
|
+
type ExplicitUndefined,
|
|
19
|
+
} from "@nseng-ai/foundation/primitives";
|
|
20
|
+
|
|
21
|
+
const MAX_SEGMENT_LENGTH = 120;
|
|
22
|
+
const PLAN_FILE_SUFFIX = ".md";
|
|
23
|
+
const PLAN_FILE_DISPLAY_NAME = "Markdown saved plan";
|
|
24
|
+
|
|
25
|
+
export type RepoIdentitySource = "origin-url" | "repo-root";
|
|
26
|
+
|
|
27
|
+
export interface SavedPlanFileParams {
|
|
28
|
+
slug: string;
|
|
29
|
+
content: string;
|
|
30
|
+
summary?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PlanStoreOptions {
|
|
34
|
+
cwd: string;
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
planStoreRoot?: string;
|
|
37
|
+
env?: ExplicitUndefined<"env-map", Record<string, string | undefined>>;
|
|
38
|
+
git?: GitGateway;
|
|
39
|
+
planStoreGateway?: PlanStoreGateway;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface BuildPlanStoreOptionsInput {
|
|
43
|
+
cwd: string;
|
|
44
|
+
signal?: ExplicitUndefined<"abort-signal", AbortSignal>;
|
|
45
|
+
planStoreRoot?: ExplicitUndefined<"di-seam", string>;
|
|
46
|
+
env?: ExplicitUndefined<"env-map", Record<string, string | undefined>>;
|
|
47
|
+
git?: ExplicitUndefined<"di-seam", GitGateway>;
|
|
48
|
+
planStoreGateway?: ExplicitUndefined<"di-seam", PlanStoreGateway>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function buildPlanStoreOptions(options: BuildPlanStoreOptionsInput): PlanStoreOptions {
|
|
52
|
+
return {
|
|
53
|
+
cwd: options.cwd,
|
|
54
|
+
...optionalEntries({
|
|
55
|
+
signal: options.signal,
|
|
56
|
+
planStoreRoot: options.planStoreRoot,
|
|
57
|
+
env: options.env,
|
|
58
|
+
git: options.git,
|
|
59
|
+
planStoreGateway: options.planStoreGateway,
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface PlanStoreRepoEvidence {
|
|
65
|
+
repoRoot: string;
|
|
66
|
+
repoKey: string;
|
|
67
|
+
repoIdentitySource: RepoIdentitySource;
|
|
68
|
+
repoDirectoryPath: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface PlanStoreDirectoryEvidence extends PlanStoreRepoEvidence {
|
|
72
|
+
sourceBranch: string;
|
|
73
|
+
branchKey: string;
|
|
74
|
+
directoryPath: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SavedPlanListItem extends PlanStoreRepoEvidence {
|
|
78
|
+
slug: string;
|
|
79
|
+
branchKey: string;
|
|
80
|
+
filePath: string;
|
|
81
|
+
fileName: string;
|
|
82
|
+
modifiedTimeMs: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface LatestSavedPlanFileEvidence extends PlanStoreDirectoryEvidence {
|
|
86
|
+
slug: string;
|
|
87
|
+
filePath: string;
|
|
88
|
+
fileName: string;
|
|
89
|
+
modifiedTimeMs: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface SavedPlanFileEvidence {
|
|
93
|
+
slug: string;
|
|
94
|
+
repoRoot: string;
|
|
95
|
+
repoKey: string;
|
|
96
|
+
repoIdentitySource: RepoIdentitySource;
|
|
97
|
+
sourceBranch: string;
|
|
98
|
+
branchKey: string;
|
|
99
|
+
filePath: string;
|
|
100
|
+
summary?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface RepoIdentity {
|
|
104
|
+
source: RepoIdentitySource;
|
|
105
|
+
identity: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type NoSavedPlanAvailableReason = "missing-directory" | "no-plan-files";
|
|
109
|
+
|
|
110
|
+
export class NoSavedPlanAvailableError extends Error {
|
|
111
|
+
readonly reason: NoSavedPlanAvailableReason;
|
|
112
|
+
readonly directoryPath: string;
|
|
113
|
+
|
|
114
|
+
constructor(params: {
|
|
115
|
+
reason: NoSavedPlanAvailableReason;
|
|
116
|
+
directoryPath: string;
|
|
117
|
+
message: string;
|
|
118
|
+
}) {
|
|
119
|
+
super(params.message);
|
|
120
|
+
this.name = "NoSavedPlanAvailableError";
|
|
121
|
+
this.reason = params.reason;
|
|
122
|
+
this.directoryPath = params.directoryPath;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function defaultPlanStoreRoot(
|
|
127
|
+
env: Record<string, string | undefined> = process.env,
|
|
128
|
+
): string {
|
|
129
|
+
return requireXdgPath(resolveNsXdgPath({ kind: "state", env, segments: ["enriched-plan"] }));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function normalizeRepoOriginUrl(rawUrl: string): string {
|
|
133
|
+
return normalizeGitRemoteUrl(rawUrl);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function buildRepoPlanStoreKey(repoRoot: string, normalizedIdentity: string): string {
|
|
137
|
+
const identity = normalizeRepoOriginUrl(normalizedIdentity);
|
|
138
|
+
const githubIdentity = githubRepositoryIdentityFromNormalizedRemoteUrl(identity);
|
|
139
|
+
if (githubIdentity !== undefined) {
|
|
140
|
+
const owner = sanitizePlanPathSegment(githubIdentity.owner.toLowerCase(), "owner");
|
|
141
|
+
const repo = sanitizePlanPathSegment(githubIdentity.repo.toLowerCase(), "repo");
|
|
142
|
+
return `gh--${owner}--${repo}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const repoRootName = basename(resolve(repoRoot));
|
|
146
|
+
return sanitizePlanPathSegment(identity, repoRootName.length > 0 ? repoRootName : "repo");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function encodeBranchForPlanPath(branch: string): string {
|
|
150
|
+
return branch
|
|
151
|
+
.split("/")
|
|
152
|
+
.map((segment, index) => sanitizePlanPathSegment(segment, `branch-${index + 1}`))
|
|
153
|
+
.join("---");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function sanitizePlanPathSegment(value: string, fallback: string): string {
|
|
157
|
+
const sanitizedFallback = fallback
|
|
158
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
159
|
+
.replace(/-+/g, "-")
|
|
160
|
+
.replace(/^[.-]+/, "")
|
|
161
|
+
.replace(/[.-]+$/, "");
|
|
162
|
+
const safeFallback = sanitizedFallback.length > 0 ? sanitizedFallback : "segment";
|
|
163
|
+
let sanitized = value
|
|
164
|
+
.normalize("NFKD")
|
|
165
|
+
.replace(/[̀-ͯ]/g, "")
|
|
166
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
167
|
+
.replace(/-+/g, "-")
|
|
168
|
+
.replace(/^[.-]+/, "")
|
|
169
|
+
.replace(/[.-]+$/, "")
|
|
170
|
+
.slice(0, MAX_SEGMENT_LENGTH)
|
|
171
|
+
.replace(/^[.-]+/, "")
|
|
172
|
+
.replace(/[.-]+$/, "");
|
|
173
|
+
|
|
174
|
+
if (sanitized.length === 0 || sanitized === "." || sanitized === "..") {
|
|
175
|
+
sanitized = safeFallback;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return sanitized;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function buildPlanFileName(slug: string): string {
|
|
182
|
+
return `${slug}${PLAN_FILE_SUFFIX}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function buildPlanStoreBranchDirectoryPath(params: {
|
|
186
|
+
repoDirectoryPath: string;
|
|
187
|
+
branchKey: string;
|
|
188
|
+
}): string {
|
|
189
|
+
return join(params.repoDirectoryPath, params.branchKey);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function formatSavedPlanFileEvidence(evidence: SavedPlanFileEvidence): string {
|
|
193
|
+
const lines = [
|
|
194
|
+
"Saved plan file in local plan store.",
|
|
195
|
+
`Path: ${evidence.filePath}`,
|
|
196
|
+
`Repo key: ${evidence.repoKey}`,
|
|
197
|
+
`Repo root: ${evidence.repoRoot}`,
|
|
198
|
+
`Repo identity source: ${evidence.repoIdentitySource}`,
|
|
199
|
+
`Source branch: ${evidence.sourceBranch}`,
|
|
200
|
+
`Branch path segment: ${evidence.branchKey}`,
|
|
201
|
+
`Slug: ${evidence.slug}`,
|
|
202
|
+
];
|
|
203
|
+
if (evidence.summary !== undefined) {
|
|
204
|
+
lines.push(`Summary: ${evidence.summary}`);
|
|
205
|
+
}
|
|
206
|
+
return lines.join("\n");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function resolvePlanStoreRepoDirectory(
|
|
210
|
+
pi: CommandExecApi,
|
|
211
|
+
options: PlanStoreOptions,
|
|
212
|
+
): Promise<PlanStoreRepoEvidence> {
|
|
213
|
+
return await resolvePlanStoreRepoDirectoryFromContext(
|
|
214
|
+
options,
|
|
215
|
+
await resolvePlanStoreRepositoryContext(pi, options),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function resolvePlanStoreDirectory(
|
|
220
|
+
pi: CommandExecApi,
|
|
221
|
+
options: PlanStoreOptions,
|
|
222
|
+
): Promise<PlanStoreDirectoryEvidence> {
|
|
223
|
+
const context = await resolvePlanStoreRepositoryContext(pi, options);
|
|
224
|
+
const sourceBranch = await resolveCurrentBranch(context.git, options.cwd, options.signal);
|
|
225
|
+
const repoDirectory = await resolvePlanStoreRepoDirectoryFromContext(options, context);
|
|
226
|
+
const branchKey = encodeBranchForPlanPath(sourceBranch);
|
|
227
|
+
const directoryPath = buildPlanStoreBranchDirectoryPath({
|
|
228
|
+
repoDirectoryPath: repoDirectory.repoDirectoryPath,
|
|
229
|
+
branchKey,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
...repoDirectory,
|
|
234
|
+
sourceBranch,
|
|
235
|
+
branchKey,
|
|
236
|
+
directoryPath,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function listSavedPlans(
|
|
241
|
+
pi: CommandExecApi,
|
|
242
|
+
options: PlanStoreOptions,
|
|
243
|
+
): Promise<SavedPlanListItem[]> {
|
|
244
|
+
const repoDirectory = await resolvePlanStoreRepoDirectory(pi, options);
|
|
245
|
+
const planStoreGateway = resolvePlanStoreGateway(options);
|
|
246
|
+
const plans: SavedPlanListItem[] = [];
|
|
247
|
+
|
|
248
|
+
const branchEntries = await listDirectoryEntriesIfPresent(
|
|
249
|
+
planStoreGateway,
|
|
250
|
+
repoDirectory.repoDirectoryPath,
|
|
251
|
+
);
|
|
252
|
+
for (const branchEntry of branchEntries) {
|
|
253
|
+
if (branchEntry.type !== "directory") {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const branchKey = branchEntry.name;
|
|
258
|
+
const branchDirectoryPath = join(repoDirectory.repoDirectoryPath, branchKey);
|
|
259
|
+
const planEntries = await listDirectoryEntriesIfPresent(planStoreGateway, branchDirectoryPath);
|
|
260
|
+
for (const planEntry of planEntries) {
|
|
261
|
+
if (planEntry.type !== "file" || !planEntry.name.endsWith(PLAN_FILE_SUFFIX)) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const filePath = join(branchDirectoryPath, planEntry.name);
|
|
266
|
+
const fileStat = await statFileIfRegular(planStoreGateway, filePath);
|
|
267
|
+
if (fileStat === undefined) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
plans.push({
|
|
272
|
+
...repoDirectory,
|
|
273
|
+
branchKey,
|
|
274
|
+
slug: planEntry.name.slice(0, -PLAN_FILE_SUFFIX.length),
|
|
275
|
+
filePath,
|
|
276
|
+
fileName: planEntry.name,
|
|
277
|
+
modifiedTimeMs: fileStat.mtimeMs,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return plans.sort(compareSavedPlanListItems);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function findLatestSavedPlanFile(
|
|
286
|
+
pi: CommandExecApi,
|
|
287
|
+
options: PlanStoreOptions,
|
|
288
|
+
): Promise<LatestSavedPlanFileEvidence> {
|
|
289
|
+
const directory = await resolvePlanStoreDirectory(pi, options);
|
|
290
|
+
const planStoreGateway = resolvePlanStoreGateway(options);
|
|
291
|
+
const candidates: Array<{
|
|
292
|
+
directory: PlanStoreDirectoryEvidence;
|
|
293
|
+
fileName: string;
|
|
294
|
+
filePath: string;
|
|
295
|
+
modifiedTimeMs: number;
|
|
296
|
+
}> = [];
|
|
297
|
+
const directoryRead = await planStoreGateway.listDirectory(directory.directoryPath);
|
|
298
|
+
const entries = directoryRead.type === "present" ? directoryRead.entries : [];
|
|
299
|
+
for (const entry of entries) {
|
|
300
|
+
if (entry.type !== "file" || !entry.name.endsWith(PLAN_FILE_SUFFIX)) {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const filePath = join(directory.directoryPath, entry.name);
|
|
305
|
+
const fileStat = await planStoreGateway.statPath(filePath);
|
|
306
|
+
if (fileStat?.type !== "file") {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
candidates.push({
|
|
310
|
+
directory,
|
|
311
|
+
fileName: entry.name,
|
|
312
|
+
filePath,
|
|
313
|
+
modifiedTimeMs: fileStat.mtimeMs,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (directoryRead.type === "missing") {
|
|
318
|
+
throw new NoSavedPlanAvailableError({
|
|
319
|
+
reason: "missing-directory",
|
|
320
|
+
directoryPath: directory.directoryPath,
|
|
321
|
+
message: [
|
|
322
|
+
"No local plan store directory exists for the current repository and branch.",
|
|
323
|
+
`Plan store directory: ${directory.directoryPath}`,
|
|
324
|
+
`Repo key: ${directory.repoKey}`,
|
|
325
|
+
`Source branch: ${directory.sourceBranch}`,
|
|
326
|
+
`Branch path segment: ${directory.branchKey}`,
|
|
327
|
+
"Create a saved plan first, or pass an explicit absolute or home-relative plan file path.",
|
|
328
|
+
].join("\n"),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (candidates.length === 0) {
|
|
333
|
+
throw new NoSavedPlanAvailableError({
|
|
334
|
+
reason: "no-plan-files",
|
|
335
|
+
directoryPath: directory.directoryPath,
|
|
336
|
+
message: [
|
|
337
|
+
`No ${PLAN_FILE_DISPLAY_NAME} files exist in the local plan store for the current repository and branch.`,
|
|
338
|
+
`Plan store directory: ${directory.directoryPath}`,
|
|
339
|
+
"Create a saved plan first, or pass an explicit absolute or home-relative plan file path.",
|
|
340
|
+
].join("\n"),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const latest = candidates.sort(compareLatestSavedPlanCandidates)[0];
|
|
345
|
+
if (latest === undefined) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`No ${PLAN_FILE_DISPLAY_NAME} files exist in the local plan store directory ${directory.directoryPath}.`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
...latest.directory,
|
|
353
|
+
slug: latest.fileName.slice(0, -PLAN_FILE_SUFFIX.length),
|
|
354
|
+
filePath: latest.filePath,
|
|
355
|
+
fileName: latest.fileName,
|
|
356
|
+
modifiedTimeMs: latest.modifiedTimeMs,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export async function writeSavedPlanFile(
|
|
361
|
+
pi: CommandExecApi,
|
|
362
|
+
rawParams: unknown,
|
|
363
|
+
options: PlanStoreOptions,
|
|
364
|
+
): Promise<SavedPlanFileEvidence> {
|
|
365
|
+
const params = parseSavedPlanFileParams(rawParams);
|
|
366
|
+
const slug = params.slug.trim();
|
|
367
|
+
const slugError = validatePlanSlug(slug);
|
|
368
|
+
if (slugError !== undefined) {
|
|
369
|
+
throw new Error(`Invalid saved plan slug: ${slugError}`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const directory = await resolvePlanStoreDirectory(pi, options);
|
|
373
|
+
const planStoreGateway = resolvePlanStoreGateway(options);
|
|
374
|
+
const fileName = buildPlanFileName(slug);
|
|
375
|
+
const filePath = join(directory.directoryPath, fileName);
|
|
376
|
+
|
|
377
|
+
await planStoreGateway.writeTextFileExclusive(filePath, params.content);
|
|
378
|
+
|
|
379
|
+
const evidence = {
|
|
380
|
+
slug,
|
|
381
|
+
repoRoot: directory.repoRoot,
|
|
382
|
+
repoKey: directory.repoKey,
|
|
383
|
+
repoIdentitySource: directory.repoIdentitySource,
|
|
384
|
+
sourceBranch: directory.sourceBranch,
|
|
385
|
+
branchKey: directory.branchKey,
|
|
386
|
+
filePath,
|
|
387
|
+
};
|
|
388
|
+
const summary = normalizeSummary(params.summary);
|
|
389
|
+
if (summary === undefined) {
|
|
390
|
+
return evidence;
|
|
391
|
+
}
|
|
392
|
+
return { ...evidence, summary };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function resolvePrimaryPlanStoreRoot(options: PlanStoreOptions): string {
|
|
396
|
+
return options.planStoreRoot ?? defaultPlanStoreRoot(options.env ?? process.env);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function resolvePlanStoreGateway(options: PlanStoreOptions): PlanStoreGateway {
|
|
400
|
+
return options.planStoreGateway ?? createRealPlanStoreGateway();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function parseSavedPlanFileParams(params: unknown): SavedPlanFileParams {
|
|
404
|
+
if (!isRecord(params)) {
|
|
405
|
+
throw new Error("writeSavedPlanFile parameters must be an object.");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const slug = params.slug;
|
|
409
|
+
const content = params.content;
|
|
410
|
+
const summary = params.summary;
|
|
411
|
+
if (typeof slug !== "string") {
|
|
412
|
+
throw new Error("writeSavedPlanFile requires string parameter `slug`.");
|
|
413
|
+
}
|
|
414
|
+
if (typeof content !== "string") {
|
|
415
|
+
throw new Error("writeSavedPlanFile requires string parameter `content`.");
|
|
416
|
+
}
|
|
417
|
+
if (summary !== undefined && typeof summary !== "string") {
|
|
418
|
+
throw new Error("writeSavedPlanFile parameter `summary` must be a string when provided.");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (summary === undefined) {
|
|
422
|
+
return { slug, content };
|
|
423
|
+
}
|
|
424
|
+
return { slug, content, summary };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function resolveRequiredGitRepoRoot(
|
|
428
|
+
git: GitGateway,
|
|
429
|
+
cwd: string,
|
|
430
|
+
signal: AbortSignal | undefined,
|
|
431
|
+
planStoreGateway: PlanStoreGateway,
|
|
432
|
+
): Promise<string> {
|
|
433
|
+
const root = await git.repoRoot({ cwd, signal });
|
|
434
|
+
if (!root.ok) {
|
|
435
|
+
throw new Error(root.error.message);
|
|
436
|
+
}
|
|
437
|
+
return await planStoreGateway.realpathOrResolve(root.value);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function resolveCurrentBranch(
|
|
441
|
+
git: GitGateway,
|
|
442
|
+
cwd: string,
|
|
443
|
+
signal: AbortSignal | undefined,
|
|
444
|
+
): Promise<string> {
|
|
445
|
+
const branch = await git.currentBranch({ cwd, signal });
|
|
446
|
+
if (branch.type === "branch") return branch.branch;
|
|
447
|
+
if (branch.type === "detached") {
|
|
448
|
+
throw new Error(
|
|
449
|
+
"Current git checkout is detached or unnamed; check out a named branch before creating a saved plan file.",
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
throw new Error(branch.error.message);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
interface PlanStoreRepositoryContext {
|
|
456
|
+
git: GitGateway;
|
|
457
|
+
planStoreGateway: PlanStoreGateway;
|
|
458
|
+
repoRoot: string;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
interface RepoIdentityOptions {
|
|
462
|
+
cwd: string;
|
|
463
|
+
repoRoot: string;
|
|
464
|
+
signal?: AbortSignal;
|
|
465
|
+
planStoreGateway: PlanStoreGateway;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function resolvePlanStoreRepositoryContext(
|
|
469
|
+
pi: CommandExecApi,
|
|
470
|
+
options: PlanStoreOptions,
|
|
471
|
+
): Promise<PlanStoreRepositoryContext> {
|
|
472
|
+
const git = options.git ?? new RealGitGateway(pi);
|
|
473
|
+
const planStoreGateway = resolvePlanStoreGateway(options);
|
|
474
|
+
const repoRoot = await resolveRequiredGitRepoRoot(
|
|
475
|
+
git,
|
|
476
|
+
options.cwd,
|
|
477
|
+
options.signal,
|
|
478
|
+
planStoreGateway,
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
return { git, planStoreGateway, repoRoot };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function resolvePlanStoreRepoDirectoryFromContext(
|
|
485
|
+
options: PlanStoreOptions,
|
|
486
|
+
context: PlanStoreRepositoryContext,
|
|
487
|
+
): Promise<PlanStoreRepoEvidence> {
|
|
488
|
+
const repoIdentity = await resolveRepoIdentity(
|
|
489
|
+
context.git,
|
|
490
|
+
buildRepoIdentityOptions(options, context.repoRoot, context.planStoreGateway),
|
|
491
|
+
);
|
|
492
|
+
const repoKey = buildRepoPlanStoreKey(context.repoRoot, repoIdentity.identity);
|
|
493
|
+
const planStoreRoot = resolvePrimaryPlanStoreRoot(options);
|
|
494
|
+
const repoDirectoryPath = join(planStoreRoot, repoKey);
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
repoRoot: context.repoRoot,
|
|
498
|
+
repoKey,
|
|
499
|
+
repoIdentitySource: repoIdentity.source,
|
|
500
|
+
repoDirectoryPath,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function buildRepoIdentityOptions(
|
|
505
|
+
options: PlanStoreOptions,
|
|
506
|
+
repoRoot: string,
|
|
507
|
+
planStoreGateway: PlanStoreGateway,
|
|
508
|
+
): RepoIdentityOptions {
|
|
509
|
+
return {
|
|
510
|
+
...buildGitCwdParams(options),
|
|
511
|
+
repoRoot,
|
|
512
|
+
planStoreGateway,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function buildGitCwdParams(options: { cwd: string; signal?: AbortSignal }): {
|
|
517
|
+
cwd: string;
|
|
518
|
+
signal?: AbortSignal;
|
|
519
|
+
} {
|
|
520
|
+
return {
|
|
521
|
+
cwd: options.cwd,
|
|
522
|
+
...optionalEntry("signal", options.signal),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function resolveRepoIdentity(
|
|
527
|
+
git: GitGateway,
|
|
528
|
+
options: RepoIdentityOptions,
|
|
529
|
+
): Promise<RepoIdentity> {
|
|
530
|
+
const origin = await git.originUrl(buildGitCwdParams(options));
|
|
531
|
+
if (origin.type === "error") {
|
|
532
|
+
throw new Error(origin.error.message);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (origin.type === "found") {
|
|
536
|
+
const normalized = normalizeRepoOriginUrl(origin.value);
|
|
537
|
+
if (normalized.length > 0) {
|
|
538
|
+
return { source: "origin-url", identity: normalized };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
source: "repo-root",
|
|
544
|
+
identity: await options.planStoreGateway.realpathOrResolve(options.repoRoot),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function listDirectoryEntriesIfPresent(
|
|
549
|
+
planStoreGateway: PlanStoreGateway,
|
|
550
|
+
path: string,
|
|
551
|
+
): Promise<readonly { name: string; type: "file" | "directory" | "other" }[]> {
|
|
552
|
+
const read = await planStoreGateway.listDirectory(path);
|
|
553
|
+
return read.type === "present" ? read.entries : [];
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function statFileIfRegular(
|
|
557
|
+
planStoreGateway: PlanStoreGateway,
|
|
558
|
+
path: string,
|
|
559
|
+
): Promise<{ mtimeMs: number } | undefined> {
|
|
560
|
+
const fileStat = await planStoreGateway.statPath(path);
|
|
561
|
+
return fileStat?.type === "file" ? { mtimeMs: fileStat.mtimeMs } : undefined;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function compareSavedPlanListItems(left: SavedPlanListItem, right: SavedPlanListItem): number {
|
|
565
|
+
if (left.modifiedTimeMs !== right.modifiedTimeMs) {
|
|
566
|
+
return right.modifiedTimeMs - left.modifiedTimeMs;
|
|
567
|
+
}
|
|
568
|
+
const branchCompare = left.branchKey.localeCompare(right.branchKey);
|
|
569
|
+
if (branchCompare !== 0) {
|
|
570
|
+
return branchCompare;
|
|
571
|
+
}
|
|
572
|
+
return left.fileName.localeCompare(right.fileName);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function compareLatestSavedPlanCandidates(
|
|
576
|
+
left: { filePath: string; modifiedTimeMs: number },
|
|
577
|
+
right: { filePath: string; modifiedTimeMs: number },
|
|
578
|
+
): number {
|
|
579
|
+
if (left.modifiedTimeMs !== right.modifiedTimeMs) {
|
|
580
|
+
return right.modifiedTimeMs - left.modifiedTimeMs;
|
|
581
|
+
}
|
|
582
|
+
return right.filePath.localeCompare(left.filePath);
|
|
583
|
+
}
|