@lisang233/pi-sync 0.1.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.
@@ -0,0 +1,532 @@
1
+ import type { Dirent } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type {
5
+ ExtensionCommandContext,
6
+ ExtensionContext,
7
+ ExtensionUIContext,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import type { SyncConfig } from "./config.js";
10
+ import { backupRootDir, stateDir } from "./config.js";
11
+ import { applyResolutions, parseConflictBlocks } from "./conflict.js";
12
+ import { diffSummary, formatSnapshotDiff } from "./diff.js";
13
+ import {
14
+ fetchRemote,
15
+ isRemoteUpToDate,
16
+ publishSnapshot,
17
+ readRemoteRevision,
18
+ readRemoteSnapshot,
19
+ readSnapshotAt,
20
+ } from "./git.js";
21
+ import {
22
+ type MergeOutcome,
23
+ mergeSnapshot,
24
+ mergeTexts,
25
+ planMerge,
26
+ textFromSnapshot,
27
+ } from "./merge.js";
28
+ import {
29
+ clearMergeSession,
30
+ hasMergeSession,
31
+ loadMergeSession,
32
+ type MergeFileState,
33
+ type MergeSessionData,
34
+ saveMergeSession,
35
+ } from "./merge-session.js";
36
+ import { agentDir, syncRootPath } from "./paths.js";
37
+ import { runBlockResolver } from "./resolve.js";
38
+ import { createSnapshot, type Snapshot, snapshotSha256 } from "./snapshot.js";
39
+ import { loadState, saveState } from "./state.js";
40
+ import { deriveSyncStatus, type SyncStatusInfo, syncIndicatorText } from "./status.js";
41
+
42
+ export interface SyncResult {
43
+ pushed: boolean;
44
+ pulled: boolean;
45
+ merged: boolean;
46
+ message: string;
47
+ conflicts?: string[];
48
+ }
49
+
50
+ export interface OperationContext {
51
+ ui: ExtensionUIContext;
52
+ signal?: AbortSignal;
53
+ }
54
+
55
+ export type CommandContext = ExtensionCommandContext | ExtensionContext;
56
+
57
+ /**
58
+ * Refresh the persistent status-bar sync indicator from the last known
59
+ * local↔remote state. Never fetches; call after fetch/pull/push/merge.
60
+ */
61
+ export async function refreshIndicator(ctx: OperationContext, config: SyncConfig): Promise<void> {
62
+ try {
63
+ const [local, remote, state] = await Promise.all([
64
+ createSnapshot(config),
65
+ readRemoteSnapshot(config),
66
+ loadState(),
67
+ ]);
68
+ const base = state?.lastRemoteRevision
69
+ ? await readSnapshotAt(state.lastRemoteRevision, { signal: ctx.signal })
70
+ : undefined;
71
+ ctx.ui.setStatus("sync", syncIndicatorText(deriveSyncStatus(local, remote, base)));
72
+ } catch {
73
+ ctx.ui.setStatus(
74
+ "sync",
75
+ syncIndicatorText({ label: "unknown", ahead: 0, behind: 0, conflicts: 0 }),
76
+ );
77
+ }
78
+ }
79
+
80
+ import { formatConfig } from "./config-ui.js";
81
+
82
+ export async function status(
83
+ ctx: CommandContext,
84
+ config: SyncConfig,
85
+ options: { diff?: boolean } = {},
86
+ ): Promise<SyncResult> {
87
+ // status never fetches; it reflects the last known mirror state.
88
+ const [local, remote, state] = await Promise.all([
89
+ createSnapshot(config),
90
+ readRemoteSnapshot(config),
91
+ loadState(),
92
+ ]);
93
+ const base = state?.lastRemoteRevision
94
+ ? await readSnapshotAt(state.lastRemoteRevision, { signal: ctx.signal })
95
+ : undefined;
96
+ const info = deriveSyncStatus(local, remote, base);
97
+ const mergeSession = await loadMergeSession();
98
+ await refreshIndicator(ctx, config);
99
+ const lines = [
100
+ formatConfig(config),
101
+ `state: ${syncIndicatorText(info)}`,
102
+ `last applied: ${state ? shortId(state.lastAppliedSnapshot) : "never"}`,
103
+ ];
104
+ if (mergeSession) {
105
+ lines.push(
106
+ `merge in progress: ${countPendingBlocks(mergeSession)} conflict block(s) unresolved — /sync merge to continue, /sync merge --abort to discard`,
107
+ );
108
+ }
109
+ lines.push(nextStepHint(info, mergeSession !== undefined));
110
+ if (options.diff && remote) {
111
+ lines.push("", formatSnapshotDiff(local, remote));
112
+ }
113
+ const level = info.label === "up-to-date" && !mergeSession ? "info" : "warning";
114
+ ctx.ui.notify(lines.join("\n"), level);
115
+ return { pushed: false, pulled: false, merged: false, message: "status" };
116
+ }
117
+
118
+ /** The closed-loop hint: what to do next given the current state. */
119
+ function nextStepHint(info: SyncStatusInfo, mergePending: boolean): string {
120
+ if (mergePending) return "next: /sync merge (resolve) or /sync merge --abort (discard)";
121
+ switch (info.label) {
122
+ case "unconfigured":
123
+ return "next: /sync init";
124
+ case "unpublished":
125
+ return "next: /sync push";
126
+ case "up-to-date":
127
+ return "next: nothing — all synced";
128
+ case "ahead":
129
+ return "next: /sync push to publish local changes";
130
+ case "behind":
131
+ return "next: /sync pull to fetch and apply remote changes";
132
+ case "conflict":
133
+ return "next: /sync pull --merge to resolve, or /sync pull --force to overwrite local";
134
+ case "unknown":
135
+ return "next: /sync fetch to check the remote";
136
+ }
137
+ }
138
+
139
+ export async function push(
140
+ ctx: CommandContext,
141
+ config: SyncConfig,
142
+ options: { force?: boolean } = {},
143
+ ): Promise<SyncResult> {
144
+ await fetchRemote(config, { signal: ctx.signal });
145
+ if (await hasMergeSession()) {
146
+ const message =
147
+ "A merge is in progress. Resolve it (/sync merge) or discard it (/sync merge --abort) before pushing.";
148
+ ctx.ui.notify(message, "error");
149
+ return { pushed: false, pulled: false, merged: false, message };
150
+ }
151
+ const [local, remoteRevision, remote, state] = await Promise.all([
152
+ createSnapshot(config),
153
+ readRemoteRevision(config),
154
+ readRemoteSnapshot(config),
155
+ loadState(),
156
+ ]);
157
+ if (
158
+ remote &&
159
+ state &&
160
+ !isRemoteUpToDate(state.lastRemoteRevision, remoteRevision) &&
161
+ !options.force
162
+ ) {
163
+ const message =
164
+ "Remote changed since the last sync. Run /sync fetch + /sync merge to reconcile, or /sync push --force to overwrite.";
165
+ ctx.ui.notify(message, "error");
166
+ return { pushed: false, pulled: false, merged: false, message };
167
+ }
168
+ const revision = await publishSnapshot(config, local, { signal: ctx.signal }, options.force);
169
+ await saveState({
170
+ version: 1,
171
+ lastAppliedSnapshot: snapshotSha256(local),
172
+ lastRemoteRevision: revision,
173
+ lastHashes: Object.fromEntries(local.files.map((file) => [file.path, file.sha256])),
174
+ });
175
+ await clearMergeSession();
176
+ await refreshIndicator(ctx, config);
177
+ const message = `Pushed ${local.files.length} files from ${agentDir()} to ${config.branch}.`;
178
+ ctx.ui.notify(message, "info");
179
+ return { pushed: true, pulled: false, merged: false, message };
180
+ }
181
+
182
+ export async function pull(
183
+ ctx: CommandContext,
184
+ config: SyncConfig,
185
+ options: { force?: boolean; merge?: boolean } = {},
186
+ ): Promise<SyncResult> {
187
+ await fetchRemote(config, { signal: ctx.signal });
188
+ const [local, remote, remoteRevision, state] = await Promise.all([
189
+ createSnapshot(config),
190
+ readRemoteSnapshot(config),
191
+ readRemoteRevision(config),
192
+ loadState(),
193
+ ]);
194
+ if (!remote) {
195
+ const message = "Remote is empty. Run /sync push first.";
196
+ ctx.ui.notify(message, "warning");
197
+ return { pushed: false, pulled: false, merged: false, message };
198
+ }
199
+ const base = state?.lastRemoteRevision
200
+ ? await readSnapshotAt(state.lastRemoteRevision, { signal: ctx.signal })
201
+ : undefined;
202
+ const plan = planMerge(local, remote, base);
203
+ const diverged =
204
+ plan.conflicts.length > 0 || (plan.takeLocal.length > 0 && plan.takeRemote.length > 0);
205
+
206
+ if (!diverged) {
207
+ if (plan.takeRemote.length === 0) {
208
+ const message =
209
+ plan.takeLocal.length > 0
210
+ ? "Already up to date; local is ahead. Run /sync push to publish."
211
+ : "Already up to date.";
212
+ ctx.ui.notify(message, "info");
213
+ await refreshIndicator(ctx, config);
214
+ return { pushed: false, pulled: false, merged: false, message };
215
+ }
216
+ // Fast-forward: apply the merged snapshot (remote-only changes).
217
+ const merged = mergeSnapshot(local, remote, plan);
218
+ await applySnapshot(merged, config);
219
+ await saveState({
220
+ version: 1,
221
+ lastAppliedSnapshot: snapshotSha256(merged),
222
+ lastRemoteRevision: remoteRevision,
223
+ lastHashes: Object.fromEntries(merged.files.map((file) => [file.path, file.sha256])),
224
+ });
225
+ await clearMergeSession();
226
+ await refreshIndicator(ctx, config);
227
+ const message = `Pulled ${plan.takeRemote.length} file(s) from ${config.branch} (${shortId(remoteRevision ?? "")}).`;
228
+ ctx.ui.notify(message, "info");
229
+ return { pushed: false, pulled: true, merged: false, message };
230
+ }
231
+
232
+ if (options.force) {
233
+ const backup = await backupLocalFiles(local);
234
+ await applySnapshot(remote, config);
235
+ await saveState({
236
+ version: 1,
237
+ lastAppliedSnapshot: snapshotSha256(remote),
238
+ lastRemoteRevision: remoteRevision,
239
+ lastHashes: Object.fromEntries(remote.files.map((file) => [file.path, file.sha256])),
240
+ });
241
+ await clearMergeSession();
242
+ await refreshIndicator(ctx, config);
243
+ const message = `Overwrote local files with the remote snapshot (${remote.files.length} files). Backup: ${backup}`;
244
+ ctx.ui.notify(message, "warning");
245
+ return { pushed: false, pulled: true, merged: false, message };
246
+ }
247
+
248
+ if (options.merge) {
249
+ return startMergeFlow(ctx, config, local, remote, base, plan, remoteRevision ?? "");
250
+ }
251
+
252
+ const message =
253
+ "Local and remote diverged. /sync pull --merge to resolve conflicts, /sync pull --force to overwrite local files.";
254
+ ctx.ui.notify(message, "warning");
255
+ await refreshIndicator(ctx, config);
256
+ return { pushed: false, pulled: false, merged: false, message, conflicts: plan.conflicts };
257
+ }
258
+
259
+ /**
260
+ * Start a conflict-resolution session: apply remote-only and cleanly-merged
261
+ * files, write diff3 markers for divergent files, then walk the blocks with
262
+ * the structured resolver. Persists progress block-by-block; completion writes
263
+ * the resolved files and clears the session.
264
+ */
265
+ async function startMergeFlow(
266
+ ctx: CommandContext,
267
+ config: SyncConfig,
268
+ local: Snapshot,
269
+ remote: Snapshot,
270
+ base: Snapshot | undefined,
271
+ plan: MergeOutcome,
272
+ remoteRevision: string,
273
+ ): Promise<SyncResult> {
274
+ if (await hasMergeSession()) {
275
+ const message =
276
+ "A merge is already in progress. Continue with /sync merge or discard it with /sync merge --abort.";
277
+ ctx.ui.notify(message, "warning");
278
+ return { pushed: false, pulled: false, merged: false, message };
279
+ }
280
+ const backup = await backupLocalFiles(local);
281
+ const conflictContents = new Map<string, string>();
282
+ const sessionFiles: MergeFileState[] = [];
283
+ for (const filePath of plan.conflicts) {
284
+ const merged = await mergeTexts(
285
+ textFromSnapshot(base ?? emptySnapshot(), filePath),
286
+ textFromSnapshot(local, filePath),
287
+ textFromSnapshot(remote, filePath),
288
+ ctx.signal,
289
+ );
290
+ conflictContents.set(filePath, merged.merged);
291
+ const blocks = parseConflictBlocks(merged.merged);
292
+ if (blocks.length > 0) {
293
+ sessionFiles.push({
294
+ path: filePath,
295
+ merged: merged.merged,
296
+ blocks: blocks.map((block) => block.block),
297
+ });
298
+ }
299
+ }
300
+ // Remote-only files and field-merged conflict files apply immediately;
301
+ // divergent files get their diff3 markers written to disk.
302
+ const toWrite = new Map<string, string>();
303
+ for (const filePath of plan.takeRemote) {
304
+ toWrite.set(filePath, textFromSnapshot(remote, filePath));
305
+ }
306
+ for (const [filePath, mergedText] of conflictContents) {
307
+ if (!sessionFiles.some((file) => file.path === filePath)) {
308
+ toWrite.set(filePath, mergedText);
309
+ }
310
+ }
311
+ for (const file of sessionFiles) {
312
+ toWrite.set(file.path, file.merged);
313
+ }
314
+ for (const [filePath, content] of toWrite) {
315
+ await writeAgentContent(config, filePath, content);
316
+ }
317
+ const session: MergeSessionData = {
318
+ baselineRevision: remoteRevision,
319
+ backupDir: backup,
320
+ createdAt: new Date().toISOString(),
321
+ takeRemote: plan.takeRemote.length,
322
+ takeLocal: plan.takeLocal.length,
323
+ files: sessionFiles,
324
+ };
325
+ await saveMergeSession(session);
326
+ await refreshIndicator(ctx, config);
327
+ if (sessionFiles.length === 0) {
328
+ return finishMergeSession(ctx, config, session, plan.takeRemote.length, plan.takeLocal.length);
329
+ }
330
+ const result = await runBlockResolver(ctx.ui, session);
331
+ if (!result.completed) {
332
+ const pending = countPendingBlocks(session);
333
+ const message = `Merge in progress: ${pending} conflict block(s) left. /sync merge to continue, /sync merge --abort to discard.`;
334
+ ctx.ui.notify(message, "warning");
335
+ return {
336
+ pushed: false,
337
+ pulled: false,
338
+ merged: true,
339
+ message,
340
+ conflicts: sessionFiles.map((file) => file.path),
341
+ };
342
+ }
343
+ return finishMergeSession(ctx, config, session, plan.takeRemote.length, plan.takeLocal.length);
344
+ }
345
+
346
+ /** Write the resolved files, record the applied snapshot, and clear the session. */
347
+ async function finishMergeSession(
348
+ ctx: CommandContext,
349
+ config: SyncConfig,
350
+ session: MergeSessionData,
351
+ remoteTaken: number,
352
+ localKept: number,
353
+ ): Promise<SyncResult> {
354
+ for (const file of session.files) {
355
+ const content = applyResolutions(
356
+ file.merged,
357
+ file.blocks.map((block) => block.resolution),
358
+ );
359
+ await writeAgentContent(config, file.path, content);
360
+ }
361
+ const finalSnapshot = await createSnapshot(config);
362
+ await saveState({
363
+ version: 1,
364
+ lastAppliedSnapshot: snapshotSha256(finalSnapshot),
365
+ lastRemoteRevision: session.baselineRevision,
366
+ lastHashes: Object.fromEntries(finalSnapshot.files.map((file) => [file.path, file.sha256])),
367
+ });
368
+ await clearMergeSession();
369
+ await refreshIndicator(ctx, config);
370
+ const resolved = session.files.reduce((sum, file) => sum + file.blocks.length, 0);
371
+ const message = `Merged: ${resolved} conflict block(s) resolved, ${remoteTaken} remote, ${localKept} local. Run /sync push to publish.`;
372
+ ctx.ui.notify(message, "info");
373
+ return { pushed: false, pulled: false, merged: true, message, conflicts: [] };
374
+ }
375
+
376
+ function countPendingBlocks(session: MergeSessionData): number {
377
+ return session.files.reduce(
378
+ (sum, file) => sum + file.blocks.filter((block) => block.resolution === undefined).length,
379
+ 0,
380
+ );
381
+ }
382
+
383
+ async function writeAgentContent(
384
+ config: SyncConfig,
385
+ relativePath: string,
386
+ content: string,
387
+ ): Promise<void> {
388
+ const target = resolveSnapshotTarget(relativePath, config);
389
+ if (!target) return;
390
+ await fs.mkdir(path.dirname(target), { recursive: true });
391
+ await fs.writeFile(target, content);
392
+ }
393
+
394
+ export async function fetch(
395
+ ctx: CommandContext,
396
+ config: SyncConfig,
397
+ options: { quiet?: boolean } = {},
398
+ ): Promise<SyncResult> {
399
+ await fetchRemote(config, { signal: ctx.signal });
400
+ const [remote, remoteRevision, local] = await Promise.all([
401
+ readRemoteSnapshot(config),
402
+ readRemoteRevision(config),
403
+ createSnapshot(config),
404
+ ]);
405
+ await refreshIndicator(ctx, config);
406
+ if (!remote) {
407
+ const message = "Remote is empty. Run /sync push to publish local content.";
408
+ if (!options.quiet) ctx.ui.notify(message, "info");
409
+ return { pushed: false, pulled: false, merged: false, message };
410
+ }
411
+ const summary = diffSummary(local, remote);
412
+ const message = `Fetched ${remote.files.length} files from ${config.branch} (${shortId(remoteRevision ?? "")}). ${describeChanges(summary)}.`;
413
+ if (!options.quiet) ctx.ui.notify(message, summary.identical ? "info" : "warning");
414
+ return { pushed: false, pulled: false, merged: false, message };
415
+ }
416
+
417
+ export async function merge(
418
+ ctx: CommandContext,
419
+ config: SyncConfig,
420
+ options: { abort?: boolean } = {},
421
+ ): Promise<SyncResult> {
422
+ if (options.abort) {
423
+ const session = await loadMergeSession();
424
+ if (!session) {
425
+ const message = "No merge in progress to abort.";
426
+ ctx.ui.notify(message, "info");
427
+ return { pushed: false, pulled: false, merged: false, message };
428
+ }
429
+ await restoreBackup(session.backupDir, config);
430
+ await clearMergeSession();
431
+ await refreshIndicator(ctx, config);
432
+ const message = "Merge aborted; local files restored from the pre-merge backup.";
433
+ ctx.ui.notify(message, "info");
434
+ return { pushed: false, pulled: false, merged: false, message };
435
+ }
436
+ const session = await loadMergeSession();
437
+ if (!session) {
438
+ const message =
439
+ "No merge in progress. Run /sync pull --merge to merge remote changes into local files.";
440
+ ctx.ui.notify(message, "info");
441
+ return { pushed: false, pulled: false, merged: false, message };
442
+ }
443
+ await refreshIndicator(ctx, config);
444
+ const result = await runBlockResolver(ctx.ui, session);
445
+ if (!result.completed) {
446
+ const pending = countPendingBlocks(session);
447
+ const message = `Merge in progress: ${pending} conflict block(s) left. /sync merge to continue, /sync merge --abort to discard.`;
448
+ ctx.ui.notify(message, "warning");
449
+ return {
450
+ pushed: false,
451
+ pulled: false,
452
+ merged: true,
453
+ message,
454
+ conflicts: session.files.map((file) => file.path),
455
+ };
456
+ }
457
+ return finishMergeSession(ctx, config, session, session.takeRemote, session.takeLocal);
458
+ }
459
+
460
+ /** Overwrite the agent dir's include paths with the pre-merge backup files. */
461
+ async function restoreBackup(backupDir: string, config: SyncConfig): Promise<void> {
462
+ let entries: Dirent[];
463
+ try {
464
+ entries = await fs.readdir(backupDir, { recursive: true, withFileTypes: true });
465
+ } catch (error) {
466
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
467
+ throw error;
468
+ }
469
+ for (const entry of entries) {
470
+ if (!entry.isFile()) continue;
471
+ const absPath = path.join(entry.parentPath ?? backupDir, entry.name);
472
+ const relative = path.relative(backupDir, absPath).split(path.sep).join("/");
473
+ const target = resolveSnapshotTarget(relative, config);
474
+ if (!target) continue;
475
+ await fs.mkdir(path.dirname(target), { recursive: true });
476
+ await fs.copyFile(absPath, target);
477
+ }
478
+ }
479
+
480
+ /** Apply a snapshot by writing its files back into the agent directory. */
481
+ export async function applySnapshot(snapshot: Snapshot, config: SyncConfig): Promise<void> {
482
+ for (const file of snapshot.files) {
483
+ const target = resolveSnapshotTarget(file.path, config);
484
+ if (!target) continue;
485
+ await fs.mkdir(path.dirname(target), { recursive: true });
486
+ await fs.writeFile(target, Buffer.from(file.contentBase64, "base64"));
487
+ }
488
+ }
489
+
490
+ function resolveSnapshotTarget(relativePath: string, config: SyncConfig): string | undefined {
491
+ if (relativePath.split("/").some((segment) => segment === "..")) return undefined;
492
+ const entry = config.include.find((candidate) => {
493
+ const lower = candidate.toLowerCase();
494
+ return (
495
+ relativePath.toLowerCase() === lower || relativePath.toLowerCase().startsWith(`${lower}/`)
496
+ );
497
+ });
498
+ if (!entry) return undefined;
499
+ const root = syncRootPath(entry);
500
+ const suffix = relativePath.slice(entry.length);
501
+ return path.join(root, suffix);
502
+ }
503
+
504
+ async function backupLocalFiles(local: Snapshot): Promise<string> {
505
+ const stamp = new Date().toISOString().replace(/[:.]/gu, "-");
506
+ const directory = path.join(backupRootDir(), stamp);
507
+ await fs.mkdir(directory, { recursive: true });
508
+ for (const file of local.files) {
509
+ const target = path.join(directory, file.path);
510
+ await fs.mkdir(path.dirname(target), { recursive: true });
511
+ await fs.writeFile(target, Buffer.from(file.contentBase64, "base64"));
512
+ }
513
+ return directory;
514
+ }
515
+
516
+ function describeChanges(summary: ReturnType<typeof diffSummary>): string {
517
+ const parts: string[] = [];
518
+ if (summary.added > 0) parts.push(`${summary.added} added`);
519
+ if (summary.removed > 0) parts.push(`${summary.removed} removed`);
520
+ if (summary.changed > 0) parts.push(`${summary.changed} changed`);
521
+ return parts.length > 0 ? parts.join(", ") : "no differences";
522
+ }
523
+
524
+ function shortId(value: string): string {
525
+ return value.length > 10 ? value.slice(0, 10) : value;
526
+ }
527
+
528
+ function emptySnapshot(): Snapshot {
529
+ return { version: 1, createdAt: new Date().toISOString(), files: [] };
530
+ }
531
+
532
+ export { stateDir };
package/src/paths.ts ADDED
@@ -0,0 +1,77 @@
1
+ import path from "node:path";
2
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
+
4
+ export const BUILT_IN_SYNC_FILES = [
5
+ "settings.json",
6
+ "keybindings.json",
7
+ "models.json",
8
+ "skills",
9
+ "prompts",
10
+ "themes",
11
+ "extensions",
12
+ "extension-settings",
13
+ ] as const;
14
+
15
+ export type BuiltInSyncFile = (typeof BUILT_IN_SYNC_FILES)[number];
16
+
17
+ export const DEFAULT_INCLUDE: readonly string[] = [...BUILT_IN_SYNC_FILES];
18
+
19
+ const TOP_LEVEL_FILE_NAMES = new Set<string>(
20
+ BUILT_IN_SYNC_FILES.filter((name) => name.includes(".")),
21
+ );
22
+
23
+ /** Absolute agent directory (e.g. ~/.pi/agent), honoring PI_CODING_AGENT_DIR. */
24
+ export function agentDir(): string {
25
+ return getAgentDir();
26
+ }
27
+
28
+ /** Absolute source path for one include entry (built-in or arbitrary agent-relative). */
29
+ export function syncRootPath(entry: string): string {
30
+ // normalizeInclude validates entries as safe agent-relative posix paths, so a
31
+ // plain join keeps every entry inside the agent directory.
32
+ return path.join(getAgentDir(), entry);
33
+ }
34
+
35
+ export function isBuiltInTopLevelFile(name: string): boolean {
36
+ return TOP_LEVEL_FILE_NAMES.has(name);
37
+ }
38
+
39
+ /** Normalize and validate an include list; throws on unsafe or duplicate entries. */
40
+ export function normalizeInclude(value: unknown): string[] {
41
+ if (!Array.isArray(value)) {
42
+ throw new Error("Invalid pi-sync config: include must be an array.");
43
+ }
44
+ const result: string[] = [];
45
+ const seen = new Set<string>();
46
+ for (const item of value) {
47
+ if (typeof item !== "string") {
48
+ throw new Error("Invalid pi-sync config: include items must be strings.");
49
+ }
50
+ const trimmed = item.trim();
51
+ const lower = trimmed.toLowerCase();
52
+ const builtIn = BUILT_IN_SYNC_FILES.find((name) => name.toLowerCase() === lower);
53
+ const normalized = builtIn ?? trimmed;
54
+ if (seen.has(normalized)) {
55
+ throw new Error(`Invalid pi-sync config: duplicate include item: ${trimmed}`);
56
+ }
57
+ if (!builtIn) {
58
+ validateAgentRelativeInclude(normalized);
59
+ }
60
+ seen.add(normalized);
61
+ result.push(normalized);
62
+ }
63
+ return result;
64
+ }
65
+
66
+ function validateAgentRelativeInclude(value: string): void {
67
+ if (!value || value === "." || value === ".." || value.startsWith("../")) {
68
+ throw new Error(`Invalid pi-sync config: unsafe include item: ${value}`);
69
+ }
70
+ if (path.posix.isAbsolute(value) || value.includes("\\")) {
71
+ throw new Error(`Invalid pi-sync config: include items must be agent-relative paths: ${value}`);
72
+ }
73
+ const normalized = path.posix.normalize(value);
74
+ if (normalized !== value) {
75
+ throw new Error(`Invalid pi-sync config: include items must be normalized paths: ${value}`);
76
+ }
77
+ }
package/src/resolve.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import { type MergeSessionData, saveMergeSession } from "./merge-session.js";
3
+
4
+ export interface ResolveResult {
5
+ /** True when every block now has a resolution. */
6
+ completed: boolean;
7
+ resolved: number;
8
+ }
9
+
10
+ const KEEP_LOCAL = "keep local";
11
+ const KEEP_REMOTE = "keep remote";
12
+ const TYPE_REPLACEMENT = "type replacement";
13
+ const ABORT = "abort";
14
+
15
+ /**
16
+ * Structured conflict resolution: walk the unresolved blocks one at a time and
17
+ * let the user keep the local side, keep the remote side, or type a
18
+ * replacement. Each block is persisted as it resolves, so an interrupted run
19
+ * resumes from where it stopped (/sync merge).
20
+ */
21
+ export async function runBlockResolver(
22
+ ui: ExtensionUIContext,
23
+ session: MergeSessionData,
24
+ ): Promise<ResolveResult> {
25
+ const pending: Array<{ file: MergeSessionData["files"][number]; index: number }> = [];
26
+ for (const file of session.files) {
27
+ for (let index = 0; index < file.blocks.length; index += 1) {
28
+ if (file.blocks[index].resolution === undefined) {
29
+ pending.push({ file, index });
30
+ }
31
+ }
32
+ }
33
+ if (pending.length === 0) return { completed: true, resolved: 0 };
34
+
35
+ let resolved = 0;
36
+ for (const { file, index } of pending) {
37
+ const block = file.blocks[index];
38
+ const choice = await ui.select(`${file.path} — conflict ${index + 1}/${file.blocks.length}`, [
39
+ KEEP_LOCAL,
40
+ KEEP_REMOTE,
41
+ TYPE_REPLACEMENT,
42
+ ABORT,
43
+ ]);
44
+ if (choice === undefined || choice === ABORT) {
45
+ await saveMergeSession(session);
46
+ return { completed: false, resolved };
47
+ }
48
+ if (choice === KEEP_LOCAL) {
49
+ block.resolution = block.local;
50
+ block.choice = "local";
51
+ } else if (choice === KEEP_REMOTE) {
52
+ block.resolution = block.remote;
53
+ block.choice = "remote";
54
+ } else {
55
+ const custom = await ui.input(`Replacement for ${file.path} block ${index + 1}`);
56
+ if (custom === undefined) {
57
+ await saveMergeSession(session);
58
+ return { completed: false, resolved };
59
+ }
60
+ block.resolution = custom;
61
+ block.choice = "custom";
62
+ }
63
+ resolved += 1;
64
+ await saveMergeSession(session);
65
+ }
66
+ return { completed: true, resolved };
67
+ }