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