@narumitw/pi-sync 0.51.0 → 0.52.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.
Files changed (181) hide show
  1. package/README.md +30 -5
  2. package/dist/chunks/{chunk-7KHKWL3Q.ts → chunk-2CBF6SMT.ts} +8 -7
  3. package/dist/chunks/chunk-2CBF6SMT.ts.map +7 -0
  4. package/dist/chunks/{chunk-YFADF5ZQ.ts → chunk-2DI6PDZV.ts} +5 -1
  5. package/dist/chunks/chunk-2DI6PDZV.ts.map +7 -0
  6. package/dist/chunks/chunk-2GUXI2XT.ts +1702 -0
  7. package/dist/chunks/chunk-2GUXI2XT.ts.map +7 -0
  8. package/dist/chunks/{chunk-4MPIH46N.ts → chunk-3B4Z2SKV.ts} +40 -6
  9. package/dist/chunks/chunk-3B4Z2SKV.ts.map +7 -0
  10. package/dist/chunks/{chunk-YPXGU5OR.ts → chunk-4QVPTIZB.ts} +16 -6
  11. package/dist/chunks/{chunk-YPXGU5OR.ts.map → chunk-4QVPTIZB.ts.map} +2 -2
  12. package/dist/chunks/{chunk-UA7RXOIT.ts → chunk-4SBTAVFL.ts} +6 -6
  13. package/dist/chunks/{chunk-6546TS54.ts → chunk-5RZPINUQ.ts} +7 -4
  14. package/dist/chunks/{chunk-6546TS54.ts.map → chunk-5RZPINUQ.ts.map} +2 -2
  15. package/dist/chunks/chunk-76NB4R7U.ts +107 -0
  16. package/dist/chunks/chunk-76NB4R7U.ts.map +7 -0
  17. package/dist/chunks/{chunk-DM4X4FIA.ts → chunk-7D6RDCVC.ts} +8 -10
  18. package/dist/chunks/{chunk-DM4X4FIA.ts.map → chunk-7D6RDCVC.ts.map} +2 -2
  19. package/dist/chunks/chunk-A6HU4ERM.ts +124 -0
  20. package/dist/chunks/chunk-A6HU4ERM.ts.map +7 -0
  21. package/dist/chunks/chunk-AMJRUXFT.ts +17 -0
  22. package/dist/chunks/chunk-AMJRUXFT.ts.map +7 -0
  23. package/dist/chunks/chunk-ANWNQIR7.ts +1013 -0
  24. package/dist/chunks/chunk-ANWNQIR7.ts.map +7 -0
  25. package/dist/chunks/{chunk-T56RNRZJ.ts → chunk-BDB4IDM3.ts} +19 -10
  26. package/dist/chunks/chunk-BDB4IDM3.ts.map +7 -0
  27. package/dist/chunks/{chunk-2RZSQWBH.ts → chunk-CHSIAC4N.ts} +5 -5
  28. package/dist/chunks/{chunk-2RZSQWBH.ts.map → chunk-CHSIAC4N.ts.map} +2 -2
  29. package/dist/chunks/chunk-HUZ5CR4W.ts +110 -0
  30. package/dist/chunks/chunk-HUZ5CR4W.ts.map +7 -0
  31. package/dist/chunks/{chunk-QLSN7AGX.ts → chunk-IJ6VV6FN.ts} +81 -19
  32. package/dist/chunks/chunk-IJ6VV6FN.ts.map +7 -0
  33. package/dist/chunks/chunk-ORQRHEEO.ts +362 -0
  34. package/dist/chunks/chunk-ORQRHEEO.ts.map +7 -0
  35. package/dist/chunks/chunk-QE7NZZWT.ts +380 -0
  36. package/dist/chunks/chunk-QE7NZZWT.ts.map +7 -0
  37. package/dist/chunks/{chunk-HGJH56KR.ts → chunk-QHSQCK7L.ts} +4 -4
  38. package/dist/chunks/chunk-QOFMXPM5.ts +735 -0
  39. package/dist/chunks/chunk-QOFMXPM5.ts.map +7 -0
  40. package/dist/chunks/{chunk-DZUSTGQV.ts → chunk-UQ3ALBIB.ts} +4 -4
  41. package/dist/chunks/{chunk-Q32CQWPM.ts → chunk-V6VZ5TLG.ts} +13 -6
  42. package/dist/chunks/chunk-V6VZ5TLG.ts.map +7 -0
  43. package/dist/chunks/{chunk-E42GXD6D.ts → chunk-WKPX2SIC.ts} +4 -4
  44. package/dist/chunks/chunk-ZW5ZLGBI.ts +154 -0
  45. package/dist/chunks/chunk-ZW5ZLGBI.ts.map +7 -0
  46. package/dist/chunks/{chunk-6NT3YFGY.ts → chunk-ZX2Q6THB.ts} +8 -3
  47. package/dist/chunks/{chunk-6NT3YFGY.ts.map → chunk-ZX2Q6THB.ts.map} +2 -2
  48. package/dist/chunks/conflict-artifacts-BB2FZMIE.ts +27 -0
  49. package/dist/chunks/conflict-review-T3PB7ZSD.ts +175 -0
  50. package/dist/chunks/conflict-review-T3PB7ZSD.ts.map +7 -0
  51. package/dist/chunks/{file-selection-RAVHRYFG.ts → file-selection-WZX7HAY2.ts} +8 -7
  52. package/dist/chunks/{file-selection-RAVHRYFG.ts.map → file-selection-WZX7HAY2.ts.map} +1 -1
  53. package/dist/chunks/{git-backend-IAANDL3K.ts → git-backend-HLGKUQJT.ts} +19 -7
  54. package/dist/chunks/git-backend-HLGKUQJT.ts.map +7 -0
  55. package/dist/chunks/local-fields-6DGYOOIK.ts +22 -0
  56. package/dist/chunks/manager-result-dispatcher-VIDT3QBU.ts +23 -0
  57. package/dist/chunks/{manager-ui-763NEKJY.ts → manager-ui-HSMHS3OK.ts} +236 -57
  58. package/dist/chunks/manager-ui-HSMHS3OK.ts.map +7 -0
  59. package/dist/chunks/merge-baseline-store-EVQAAML6.ts +22 -0
  60. package/dist/chunks/remote-selection-ui-SMF5CLFO.ts +22 -0
  61. package/dist/chunks/{s3-backend-XGXUOPUN.ts → s3-backend-DJ2NT3SZ.ts} +16 -9
  62. package/dist/chunks/s3-backend-DJ2NT3SZ.ts.map +7 -0
  63. package/dist/chunks/{setup-switch-5G2VNJHL.ts → setup-switch-KTUWL6DE.ts} +9 -6
  64. package/dist/chunks/{setup-wizard-YIEWGX32.ts → setup-wizard-GSDPDFC4.ts} +10 -7
  65. package/dist/chunks/{setup-wizard-YIEWGX32.ts.map → setup-wizard-GSDPDFC4.ts.map} +1 -1
  66. package/dist/chunks/{snapshot-JL5DFSXI.ts → snapshot-THS5EY2L.ts} +6 -9
  67. package/dist/chunks/snapshot-THS5EY2L.ts.map +7 -0
  68. package/dist/chunks/{sync-inspection-P3ENJLNF.ts → sync-inspection-VBJF2FWC.ts} +10 -10
  69. package/dist/chunks/sync-inspection-VBJF2FWC.ts.map +7 -0
  70. package/dist/chunks/{sync-operations-AR76EB6C.ts → sync-operations-27VQUDH2.ts} +309 -508
  71. package/dist/chunks/sync-operations-27VQUDH2.ts.map +7 -0
  72. package/dist/chunks/{sync-state-VHJUZ4YG.ts → sync-state-CFL4RRRB.ts} +6 -6
  73. package/dist/chunks/sync-state-CFL4RRRB.ts.map +7 -0
  74. package/dist/chunks/{webdav-backend-3EGUONAT.ts → webdav-backend-ETQGSI6I.ts} +13 -7
  75. package/dist/chunks/webdav-backend-ETQGSI6I.ts.map +7 -0
  76. package/dist/index.ts +142 -43
  77. package/dist/index.ts.map +2 -2
  78. package/docs/content-merge-audit.md +156 -0
  79. package/docs/merge-implementation-audit.md +312 -0
  80. package/docs/settings-merge-audit.md +153 -0
  81. package/docs/settings.md +36 -0
  82. package/package.json +6 -6
  83. package/src/backends/git/git-backend.ts +2 -0
  84. package/src/backends/git/git-storage.ts +8 -3
  85. package/src/backends/s3/s3-backend.ts +2 -1
  86. package/src/backends/webdav/webdav-backend.ts +2 -1
  87. package/src/commands/command-execution.ts +10 -0
  88. package/src/commands/command.ts +4 -0
  89. package/src/settings/config.ts +12 -0
  90. package/src/settings/settings-management.ts +2 -2
  91. package/src/settings/settings-types.ts +14 -1
  92. package/src/settings/settings-validation.ts +24 -3
  93. package/src/snapshot/session-paths.ts +34 -7
  94. package/src/snapshot/snapshot-apply.ts +81 -32
  95. package/src/snapshot/snapshot-case-replacement.ts +80 -0
  96. package/src/snapshot/snapshot-codec.ts +6 -1
  97. package/src/snapshot/snapshot-root-transition.ts +154 -0
  98. package/src/snapshot/snapshot-transaction-plan.ts +72 -0
  99. package/src/snapshot/snapshot-transaction.ts +844 -102
  100. package/src/snapshot/snapshot-types.ts +5 -0
  101. package/src/state/json-file.ts +32 -5
  102. package/src/state/merge-baseline-store.ts +182 -0
  103. package/src/state/mutation-directory-sync.ts +34 -0
  104. package/src/state/state-types.ts +4 -0
  105. package/src/state/sync-state-store.ts +14 -1
  106. package/src/sync/automatic-sync.ts +11 -3
  107. package/src/sync/conflict-artifact-ownership.ts +63 -0
  108. package/src/sync/conflict-artifacts.ts +195 -0
  109. package/src/sync/conflict-groups.ts +68 -0
  110. package/src/sync/conflict-preview.ts +56 -0
  111. package/src/sync/conflict-resolution.ts +93 -0
  112. package/src/sync/conflict-retention.ts +72 -0
  113. package/src/sync/conflict-review.ts +95 -0
  114. package/src/sync/content-conflicts.ts +73 -0
  115. package/src/sync/field-migration.ts +39 -0
  116. package/src/sync/file-merge-planner.ts +143 -0
  117. package/src/sync/json-document.ts +184 -0
  118. package/src/sync/local-fields.ts +134 -0
  119. package/src/sync/merge-apply.ts +380 -0
  120. package/src/sync/merge-journal.ts +219 -0
  121. package/src/sync/merged-sync.ts +723 -0
  122. package/src/sync/partial-progress.ts +54 -0
  123. package/src/sync/remote-snapshot.ts +6 -0
  124. package/src/sync/session-merge.ts +194 -0
  125. package/src/sync/settings-conflicts.ts +52 -0
  126. package/src/sync/settings-merge.ts +57 -0
  127. package/src/sync/startup-check.ts +90 -5
  128. package/src/sync/sync-inspection.ts +1 -0
  129. package/src/sync/sync-local.ts +61 -0
  130. package/src/sync/sync-mutations.ts +256 -114
  131. package/src/sync/sync-queries.ts +6 -2
  132. package/src/sync/sync-state.ts +14 -5
  133. package/src/sync/text-merge.ts +133 -0
  134. package/src/sync-extension.ts +8 -0
  135. package/src/ui/manager-state.ts +12 -1
  136. package/src/ui/manager-ui.ts +10 -0
  137. package/src/ui/merge-review.ts +28 -0
  138. package/src/ui/remote-selection-ui.ts +1 -0
  139. package/src/ui/settings-ui.ts +183 -2
  140. package/src/ui/setup/setup-actions.ts +2 -2
  141. package/src/ui/setup/setup-switcher.ts +2 -1
  142. package/src/ui/storage-connections-ui.ts +11 -7
  143. package/src/ui/sync-attention.ts +3 -0
  144. package/src/ui/sync-format.ts +16 -12
  145. package/src/ui/sync-setups-ui.ts +1 -0
  146. package/src/ui/terminal-text.ts +9 -2
  147. package/dist/chunks/chunk-4AIHXRKE.ts +0 -197
  148. package/dist/chunks/chunk-4AIHXRKE.ts.map +0 -7
  149. package/dist/chunks/chunk-4MPIH46N.ts.map +0 -7
  150. package/dist/chunks/chunk-7KHKWL3Q.ts.map +0 -7
  151. package/dist/chunks/chunk-EEJPYTWE.ts +0 -348
  152. package/dist/chunks/chunk-EEJPYTWE.ts.map +0 -7
  153. package/dist/chunks/chunk-IVXEDPAD.ts +0 -370
  154. package/dist/chunks/chunk-IVXEDPAD.ts.map +0 -7
  155. package/dist/chunks/chunk-MSSW6F3R.ts +0 -12
  156. package/dist/chunks/chunk-MSSW6F3R.ts.map +0 -7
  157. package/dist/chunks/chunk-OVDTF5CH.ts +0 -90
  158. package/dist/chunks/chunk-OVDTF5CH.ts.map +0 -7
  159. package/dist/chunks/chunk-Q32CQWPM.ts.map +0 -7
  160. package/dist/chunks/chunk-QLSN7AGX.ts.map +0 -7
  161. package/dist/chunks/chunk-SWUXA3MH.ts +0 -18
  162. package/dist/chunks/chunk-SWUXA3MH.ts.map +0 -7
  163. package/dist/chunks/chunk-T56RNRZJ.ts.map +0 -7
  164. package/dist/chunks/chunk-YFADF5ZQ.ts.map +0 -7
  165. package/dist/chunks/git-backend-IAANDL3K.ts.map +0 -7
  166. package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +0 -23
  167. package/dist/chunks/manager-ui-763NEKJY.ts.map +0 -7
  168. package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +0 -22
  169. package/dist/chunks/s3-backend-XGXUOPUN.ts.map +0 -7
  170. package/dist/chunks/sync-operations-AR76EB6C.ts.map +0 -7
  171. package/dist/chunks/webdav-backend-3EGUONAT.ts.map +0 -7
  172. /package/dist/chunks/{chunk-UA7RXOIT.ts.map → chunk-4SBTAVFL.ts.map} +0 -0
  173. /package/dist/chunks/{chunk-HGJH56KR.ts.map → chunk-QHSQCK7L.ts.map} +0 -0
  174. /package/dist/chunks/{chunk-DZUSTGQV.ts.map → chunk-UQ3ALBIB.ts.map} +0 -0
  175. /package/dist/chunks/{chunk-E42GXD6D.ts.map → chunk-WKPX2SIC.ts.map} +0 -0
  176. /package/dist/chunks/{manager-result-dispatcher-OTUMQM26.ts.map → conflict-artifacts-BB2FZMIE.ts.map} +0 -0
  177. /package/dist/chunks/{remote-selection-ui-TZTJF32Y.ts.map → local-fields-6DGYOOIK.ts.map} +0 -0
  178. /package/dist/chunks/{setup-switch-5G2VNJHL.ts.map → manager-result-dispatcher-VIDT3QBU.ts.map} +0 -0
  179. /package/dist/chunks/{snapshot-JL5DFSXI.ts.map → merge-baseline-store-EVQAAML6.ts.map} +0 -0
  180. /package/dist/chunks/{sync-inspection-P3ENJLNF.ts.map → remote-selection-ui-SMF5CLFO.ts.map} +0 -0
  181. /package/dist/chunks/{sync-state-VHJUZ4YG.ts.map → setup-switch-KTUWL6DE.ts.map} +0 -0
@@ -1,5 +1,3 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
1
  import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
2
  import { createSyncBackend, type SyncBackendFactory } from "../backends/backend-factory.js";
5
3
  import {
@@ -9,23 +7,21 @@ import {
9
7
  type SyncBackend,
10
8
  } from "../backends/sync-backend.js";
11
9
  import type { CommandOptions } from "../commands/command-types.js";
12
- import { loadConfig } from "../settings/config.js";
10
+ import { loadConfig, syncCheckConfigFingerprint } from "../settings/config.js";
13
11
  import type { AnySyncConfig } from "../settings/settings-types.js";
14
- import { sessionDirForApply, sessionDirFromContext, snapshotOptionsForContext } from "../snapshot/session-paths.js";
12
+ import { sessionDirForApply, snapshotOptionsForContext } from "../snapshot/session-paths.js";
15
13
  import {
16
14
  createSnapshot,
17
15
  filterSnapshotForConfigPolicy,
18
16
  mergeRemotePreservedFiles,
19
17
  regenerateSnapshotIdentity,
20
18
  scanSnapshot,
21
- sessionSnapshotPathFromAbsolute,
22
19
  snapshotIncludesSessions,
23
20
  snapshotWithoutSessions,
24
21
  } from "../snapshot/snapshot.js";
25
22
  import { applySnapshot } from "../snapshot/snapshot-apply.js";
26
- import { encodeSnapshot } from "../snapshot/snapshot-codec.js";
27
- import type { Snapshot, SnapshotOptions } from "../snapshot/snapshot-types.js";
28
- import { stateDir } from "../state/state-directory.js";
23
+ import type { Snapshot } from "../snapshot/snapshot-types.js";
24
+ import { pruneMergeBaselines } from "../state/merge-baseline-store.js";
29
25
  import type { SyncState } from "../state/state-types.js";
30
26
  import { readStateForConfig, writeStateForConfig } from "../state/sync-state-store.js";
31
27
  import {
@@ -35,10 +31,16 @@ import {
35
31
  formatRollbackSummary,
36
32
  } from "../ui/sync-format.js";
37
33
  import { setSyncStatus } from "../ui/sync-status.js";
34
+ import { confirmFieldMigration } from "./field-migration.js";
35
+ import { overlayLocalFields, portableSnapshot, sameLocalFields, validatePortableSnapshot } from "./local-fields.js";
36
+ import { readMergeJournal, requireNoMergeJournal, retireMergeJournal } from "./merge-journal.js";
37
+ import { mergeSync } from "./merged-sync.js";
38
38
  import { readRemoteSnapshot, readSnapshotForHead, requireCompatibleRemoteSelection } from "./remote-snapshot.js";
39
39
  import { throwIfAborted } from "./signals.js";
40
40
  import { createSyncDecision } from "./sync-decision.js";
41
41
  import { errorMessage } from "./sync-errors.js";
42
+
43
+ import { backupLocal, captureMutationOwner, protectedSessionPaths } from "./sync-local.js";
42
44
  import { inspectRemoteSelection } from "./sync-policy.js";
43
45
  import {
44
46
  canPullRemoteSessionsOnFirstSync,
@@ -57,6 +59,14 @@ import {
57
59
  const VERSION = 1;
58
60
  const POST_LOCAL_COMMIT_TIMEOUT_MS = 30_000;
59
61
 
62
+ /** Whole-direction acceptance must never forget withheld state without an explicit recovery choice. */
63
+ function requireResolvedDirectionalProgress(state: SyncState, options: CommandOptions, allowForce = true) {
64
+ if (state.unresolved?.length && (options.auto || !options.force || !allowForce))
65
+ throw new Error(
66
+ "Unresolved partial conflicts remain. Review /sync conflicts or explicitly choose push --force or pull --force; automatic transfer and rollback cannot accept unresolved groups.",
67
+ );
68
+ }
69
+
60
70
  export class PublicationStatePersistenceError extends Error {
61
71
  readonly head: RemoteHead;
62
72
  readonly backupPath?: string;
@@ -97,13 +107,20 @@ export async function push(
97
107
  input?: PushInput,
98
108
  factory: SyncBackendFactory = createSyncBackend,
99
109
  ) {
110
+ const validateMutation = captureMutationOwner(ctx, options.signal);
111
+ const validateCommittedOwner = captureMutationOwner(ctx);
100
112
  const config = input?.config ?? (await loadConfig(options.setup));
101
113
  throwIfAborted(options.signal);
114
+ await requireNoMergeJournal(config, options);
115
+ const state = input?.state ?? (await readStateForConfig(config));
116
+ validateMutation();
117
+ requireResolvedDirectionalProgress(state, options);
102
118
  setSyncStatus(ctx, `pushing ${config.setupName}`);
103
119
  const backend = input?.backend ?? (await factory(config));
104
- const state = input?.state ?? (await readStateForConfig(config));
105
120
  throwIfAborted(options.signal);
106
- const local = input?.local ?? (await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config)));
121
+ const localRaw =
122
+ input?.local ?? (await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config)));
123
+ const local = portableSnapshot(localRaw, config.localFields);
107
124
  throwIfAborted(options.signal);
108
125
 
109
126
  let head = await backend.readHead(options.signal);
@@ -138,6 +155,13 @@ export async function push(
138
155
  }
139
156
  }
140
157
 
158
+ if (!remoteForUpload && head && config.localFields !== undefined)
159
+ remoteForUpload = await readSnapshotForHead(backend, head, options.signal);
160
+ if (!(await confirmFieldMigration(ctx, config, state, remoteForUpload, options.force, options.signal))) {
161
+ setSyncStatus(ctx, undefined);
162
+ return "cancelled" as const;
163
+ }
164
+ const configIdentity = syncCheckConfigFingerprint(config);
141
165
  let upload = await snapshotForUpload(backend, config, local, head, remoteForUpload, options.signal);
142
166
  if (!config.skipSecretScan) {
143
167
  const secrets = scanSnapshot(local);
@@ -154,7 +178,13 @@ export async function push(
154
178
  const refreshedHead = await backend.readHead(options.signal);
155
179
  if (!sameRemoteHead(backend, head, refreshedHead)) {
156
180
  head = refreshedHead;
157
- remoteForUpload = head ? await backend.readSnapshot(head.snapshotRef, options.signal) : undefined;
181
+ remoteForUpload = head ? await readSnapshotForHead(backend, head, options.signal) : undefined;
182
+ validateMutation();
183
+ if (!(await confirmFieldMigration(ctx, config, state, remoteForUpload, options.force, options.signal))) {
184
+ setSyncStatus(ctx, undefined);
185
+ return "cancelled" as const;
186
+ }
187
+ validateMutation();
158
188
  upload = await snapshotForUpload(backend, config, local, head, remoteForUpload, options.signal);
159
189
  if (
160
190
  !(await confirmPush(
@@ -174,22 +204,42 @@ export async function push(
174
204
  }
175
205
  }
176
206
 
207
+ validateMutation();
208
+ if (configIdentity !== syncCheckConfigFingerprint(await loadConfig(options.setup)))
209
+ throw new Error("Settings changed during push review.");
210
+ validateMutation();
211
+ if (config.localFields !== undefined || (await readMergeJournal(config))) {
212
+ const current = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
213
+ if (!sameHashes(fileHashMap(current), fileHashMap(localRaw)))
214
+ throw new Error("Local content changed during recovery review; retry from current bytes.");
215
+ }
216
+ requireResolvedDirectionalProgress(await readStateForConfig(config), options);
217
+ validateMutation();
177
218
  const result = await backend.publishSnapshot(upload, expectedRemoteHead(head), {
178
219
  signal: options.signal,
179
220
  onCommit: options.onCommit,
180
221
  });
181
222
  try {
182
- await writeStateForConfig(config, {
183
- version: VERSION,
184
- profile: config.snapshotIdentity,
185
- lastAppliedSnapshot: result.head.snapshotId,
186
- lastRemoteRevision: result.head.revision,
187
- lastFileHashes: fileHashMap(local),
188
- include: [...config.include],
189
- });
223
+ await writeStateForConfig(
224
+ config,
225
+ {
226
+ version: VERSION,
227
+ profile: config.snapshotIdentity,
228
+ lastAppliedSnapshot: result.head.snapshotId,
229
+ lastRemoteRevision: result.head.revision,
230
+ lastFileHashes: fileHashMap(local),
231
+ include: [...config.include],
232
+ ...(config.localFields !== undefined ? { localFields: config.localFields } : {}),
233
+ },
234
+ local,
235
+ validateCommittedOwner,
236
+ );
190
237
  } catch (error) {
191
238
  throw new PublicationStatePersistenceError(result.head, error);
192
239
  }
240
+ await retireMergeJournal(config);
241
+ await pruneMergeBaselines(config, await readStateForConfig(config), validateCommittedOwner);
242
+ validateCommittedOwner();
193
243
  if (options.signal?.aborted) return;
194
244
  setSyncStatus(ctx, undefined);
195
245
  if (!options.silent) {
@@ -211,12 +261,16 @@ export async function pull(
211
261
  options: CommandOptions,
212
262
  factory: SyncBackendFactory = createSyncBackend,
213
263
  ) {
264
+ const validateMutation = captureMutationOwner(ctx, options.signal);
265
+ const validateCommittedOwner = captureMutationOwner(ctx);
214
266
  const config = await loadConfig(options.setup);
215
267
  throwIfAborted(options.signal);
268
+ await requireNoMergeJournal(config, options);
216
269
  setSyncStatus(ctx, `pulling ${config.setupName}`);
217
270
  const backend = await factory(config);
218
271
  const state = await readStateForConfig(config);
219
- throwIfAborted(options.signal);
272
+ validateMutation();
273
+ requireResolvedDirectionalProgress(state, options);
220
274
  const local = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
221
275
  throwIfAborted(options.signal);
222
276
  const { head, snapshot: remote } = await readRemoteSnapshot(backend, config, options.signal);
@@ -234,6 +288,19 @@ export async function pull(
234
288
  });
235
289
  }
236
290
 
291
+ if (!sameLocalFields(config.localFields, remote.localFields)) {
292
+ setSyncStatus(ctx, undefined);
293
+ throw new Error(
294
+ "Pull requires the configured local-field policy to match the remote. To change the remote policy, review push --force from the authoritative machine first, then pull using matching rules; old history is not erased.",
295
+ );
296
+ }
297
+ if (!(await confirmFieldMigration(ctx, config, state, remote, options.force, options.signal))) {
298
+ setSyncStatus(ctx, undefined);
299
+ return "cancelled" as const;
300
+ }
301
+ const configIdentity = syncCheckConfigFingerprint(config);
302
+ const logical = portableSnapshot(remote, config.localFields);
303
+ const physical = overlayLocalFields(logical, local, config.localFields);
237
304
  const remoteChanged = hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx));
238
305
  if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) {
239
306
  throw createSyncDecision({
@@ -253,7 +320,7 @@ export async function pull(
253
320
  !options.yes &&
254
321
  !(await ctx.ui.confirm(
255
322
  snapshotIncludesSessions(remote) ? "Pull pi settings and sessions?" : "Pull pi settings?",
256
- formatPullSummary(config, backend.destination, local, remote, protectedSessionPaths(ctx).size),
323
+ formatPullSummary(config, backend.destination, local, physical, protectedSessionPaths(ctx).size),
257
324
  ))
258
325
  ) {
259
326
  setSyncStatus(ctx, undefined);
@@ -262,22 +329,63 @@ export async function pull(
262
329
  }
263
330
 
264
331
  throwIfAborted(options.signal);
332
+ const recoveringMerge = Boolean(await readMergeJournal(config));
333
+ const requireFreshRecovery = async () => {
334
+ validateMutation();
335
+ const currentHead = await backend.readHead(options.signal);
336
+ validateMutation();
337
+ const current = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
338
+ validateMutation();
339
+ if (!sameHashes(fileHashMap(current), fileHashMap(local)) || !sameRemoteHead(backend, head, currentHead)) {
340
+ throw new Error("Local or remote content changed during recovery review; retry from a fresh diff.");
341
+ }
342
+ };
343
+ if (recoveringMerge) await requireFreshRecovery();
344
+ if (configIdentity !== syncCheckConfigFingerprint(await loadConfig(options.setup)))
345
+ throw new Error("Settings changed during pull review.");
346
+ validateMutation();
347
+ const refreshed = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
348
+ if (!sameHashes(fileHashMap(local), fileHashMap(refreshed)))
349
+ throw new Error("Local bytes changed during pull review.");
350
+ if (!sameRemoteHead(backend, head, await backend.readHead(options.signal)))
351
+ throw new Error("Remote changed during pull review.");
352
+ validateMutation();
265
353
  const backup = await backupLocal(config.snapshotIdentity, snapshotOptionsForContext(ctx, config), options.signal);
266
- const applySessionDir = await sessionDirForApply(ctx, remote);
267
- throwIfAborted(options.signal);
354
+ const applySessionDir = await sessionDirForApply(ctx, physical);
355
+ if (recoveringMerge) await requireFreshRecovery();
356
+ requireResolvedDirectionalProgress(await readStateForConfig(config), options);
357
+ validateMutation();
268
358
  options.onCommit?.();
269
- const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
359
+ const lastFileHashes = await applySnapshot(physical, protectedSessionPaths(ctx), {
270
360
  include: config.include,
271
361
  sessionDir: applySessionDir,
362
+ signal: options.signal,
363
+ validateMutation,
364
+ expectedFileHashes: config.localFields?.length
365
+ ? { "settings.json": fileHashMap(local)["settings.json"] ?? null }
366
+ : undefined,
272
367
  });
273
- await writeStateForConfig(config, {
274
- version: VERSION,
275
- profile: config.snapshotIdentity,
276
- lastAppliedSnapshot: remote.id,
277
- lastRemoteRevision: head?.revision,
278
- lastFileHashes,
279
- include: [...config.include],
280
- });
368
+ // Local installation is complete; user cancellation must not leave the baseline stale.
369
+ validateCommittedOwner();
370
+ await writeStateForConfig(
371
+ config,
372
+ {
373
+ version: VERSION,
374
+ profile: config.snapshotIdentity,
375
+ lastAppliedSnapshot: remote.id,
376
+ lastRemoteRevision: head?.revision,
377
+ lastFileHashes: config.localFields?.length
378
+ ? { ...lastFileHashes, "settings.json": fileHashMap(logical)["settings.json"] }
379
+ : lastFileHashes,
380
+ include: [...config.include],
381
+ ...(config.localFields !== undefined ? { localFields: config.localFields } : {}),
382
+ },
383
+ logical,
384
+ validateCommittedOwner,
385
+ );
386
+ await retireMergeJournal(config);
387
+ await pruneMergeBaselines(config, await readStateForConfig(config), validateCommittedOwner);
388
+ validateCommittedOwner();
281
389
  if (options.signal?.aborted) return "applied" as const;
282
390
  setSyncStatus(ctx, undefined);
283
391
  if (!options.silent) {
@@ -288,7 +396,7 @@ export async function pull(
288
396
  "warning",
289
397
  );
290
398
  }
291
- if (options.reload) await maybeReload(ctx, options.signal);
399
+ if (options.reload) await maybeReload(ctx, options.signal, validateMutation);
292
400
  return "applied" as const;
293
401
  }
294
402
 
@@ -297,11 +405,16 @@ export async function syncBoth(
297
405
  options: CommandOptions,
298
406
  factory: SyncBackendFactory = createSyncBackend,
299
407
  ) {
408
+ const validateMutation = captureMutationOwner(ctx, options.signal);
300
409
  const config = await loadConfig(options.setup);
301
410
  throwIfAborted(options.signal);
302
- const backend = await factory(config);
411
+ if (config.include.length > 0 && options.auto && config.automaticTransfer) return mergeSync(ctx, options, factory);
412
+ if (!options.auto && config.include.length > 0 && (await readMergeJournal(config)))
413
+ return mergeSync(ctx, options, factory);
303
414
  const state = await readStateForConfig(config);
304
415
  throwIfAborted(options.signal);
416
+ if (!options.auto && config.include.length > 0 && state.lastAppliedSnapshot) return mergeSync(ctx, options, factory);
417
+ const backend = await factory(config);
305
418
  const local = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
306
419
  throwIfAborted(options.signal);
307
420
  if (config.include.length === 0) {
@@ -318,9 +431,35 @@ export async function syncBoth(
318
431
  const localChanged = hasLocalChanges(local, state, config);
319
432
  const remoteChanged = remote ? hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx)) : false;
320
433
  const firstSync = !state.lastAppliedSnapshot;
434
+ if (options.auto) throw new Error("Automatic transfer is not authorized by the current settings.");
321
435
 
436
+ const portableLocal = portableSnapshot(local, config.localFields);
437
+ const acceptMatchingState = async (snapshot: Snapshot) => {
438
+ requireCompatibleRemoteSelection(config, snapshot);
439
+ const accepted = portableSnapshot(snapshot, config.localFields);
440
+ if (state.lastAppliedSnapshot && !sameLocalFields(state.localFields, config.localFields))
441
+ throw new Error("Local-field rules changed; review and confirm a directional migration first.");
442
+ await writeStateForConfig(
443
+ config,
444
+ {
445
+ version: VERSION,
446
+ profile: config.snapshotIdentity,
447
+ lastAppliedSnapshot: snapshot.id,
448
+ lastRemoteRevision: head?.revision,
449
+ lastFileHashes: fileHashMap(accepted),
450
+ include: [...config.include],
451
+ ...(config.localFields !== undefined ? { localFields: config.localFields } : {}),
452
+ },
453
+ accepted,
454
+ validateMutation,
455
+ );
456
+ await pruneMergeBaselines(config, await readStateForConfig(config), validateMutation);
457
+ validateMutation();
458
+ };
322
459
  if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
323
- if (!canPullRemoteSettingsOnFirstSync(local, remote)) {
460
+ requireCompatibleRemoteSelection(config, remote);
461
+ const portableRemote = portableSnapshot(remote, config.localFields);
462
+ if (!canPullRemoteSettingsOnFirstSync(portableLocal, portableRemote)) {
324
463
  throw createSyncDecision({
325
464
  kind: "first-sync-settings-diverged",
326
465
  config,
@@ -333,8 +472,8 @@ export async function syncBoth(
333
472
  "Remote settings exist and this machine has different local Pi settings. Run /sync diff, then manually choose /sync pull or /sync push.",
334
473
  });
335
474
  }
336
- if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
337
- if (!canPullRemoteSessionsOnFirstSync(local, remote)) {
475
+ if (!sameHashes(fileHashMap(portableLocal), fileHashMap(portableRemote))) {
476
+ if (!canPullRemoteSessionsOnFirstSync(portableLocal, portableRemote)) {
338
477
  throw createSyncDecision({
339
478
  kind: "first-sync-sessions-diverged",
340
479
  config,
@@ -350,26 +489,18 @@ export async function syncBoth(
350
489
  await pull(ctx, options, factory);
351
490
  return;
352
491
  }
353
- await writeStateForConfig(config, {
354
- version: VERSION,
355
- profile: config.snapshotIdentity,
356
- lastAppliedSnapshot: remote.id,
357
- lastRemoteRevision: head?.revision,
358
- lastFileHashes: fileHashMap(remote),
359
- include: [...config.include],
360
- });
492
+ await acceptMatchingState(remote);
361
493
  if (!options.silent) ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
362
494
  return;
363
495
  }
364
- if (localChanged && remoteChanged && remote && snapshotsMatch(local, remote)) {
365
- await writeStateForConfig(config, {
366
- version: VERSION,
367
- profile: config.snapshotIdentity,
368
- lastAppliedSnapshot: remote.id,
369
- lastRemoteRevision: head?.revision,
370
- lastFileHashes: fileHashMap(remote),
371
- include: [...config.include],
372
- });
496
+ if (
497
+ localChanged &&
498
+ remoteChanged &&
499
+ remote &&
500
+ snapshotsMatch(portableLocal, portableSnapshot(remote, remote.localFields))
501
+ ) {
502
+ requireCompatibleRemoteSelection(config, remote);
503
+ await acceptMatchingState(remote);
373
504
  if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
374
505
  return;
375
506
  }
@@ -394,14 +525,7 @@ export async function syncBoth(
394
525
  return;
395
526
  }
396
527
  if (shouldRefreshSyncedState(remote, head, state, config, (left, right) => backend.sameRevision(left, right))) {
397
- await writeStateForConfig(config, {
398
- version: VERSION,
399
- profile: config.snapshotIdentity,
400
- lastAppliedSnapshot: remote.id,
401
- lastRemoteRevision: head?.revision,
402
- lastFileHashes: fileHashMap(remote),
403
- include: [...config.include],
404
- });
528
+ await acceptMatchingState(remote);
405
529
  }
406
530
  if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
407
531
  }
@@ -412,6 +536,8 @@ export async function rollback(
412
536
  factory: SyncBackendFactory = createSyncBackend,
413
537
  expectedSelection?: { backendIdentity: string; setup?: string },
414
538
  ) {
539
+ const validateMutation = captureMutationOwner(ctx, options.signal);
540
+ const validateCommittedOwner = captureMutationOwner(ctx);
415
541
  const target = options.args[0];
416
542
  if (!target) throw new Error("Usage: /sync rollback <snapshot-id> [--yes]");
417
543
 
@@ -424,42 +550,72 @@ export async function rollback(
424
550
  ) {
425
551
  throw new Error("Sync setup or storage location changed while history was open; reopen history and retry.");
426
552
  }
553
+ // --force is only a rollback compatibility flag, not a reviewed merge-recovery direction.
554
+ await requireNoMergeJournal(config);
555
+ const state = await readStateForConfig(config);
556
+ validateMutation();
557
+ requireResolvedDirectionalProgress(state, options, false);
427
558
  const decoded = await backend.readSnapshot(target, options.signal);
559
+ validatePortableSnapshot(decoded);
560
+ if (!sameLocalFields(decoded.localFields, config.localFields))
561
+ throw new Error(
562
+ "Rollback requires the historical snapshot local-field policy to match the configured rules. Review push --force from current local bytes to migrate policy first; choose matching history afterward.",
563
+ );
428
564
  const selected = filterSnapshotForConfigPolicy(
429
565
  config.include.includes("sessions") ? decoded : snapshotWithoutSessions(decoded),
430
566
  config,
431
567
  );
432
- const remote = regenerateSnapshotIdentity(selected);
568
+ const remote = portableSnapshot(regenerateSnapshotIdentity(selected), config.localFields);
433
569
  const local = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
434
570
  const expectedHead = await backend.readHead(options.signal);
435
- throwIfAborted(options.signal);
571
+ const currentRemote = expectedHead ? await readSnapshotForHead(backend, expectedHead, options.signal) : undefined;
572
+ validateMutation();
573
+ if (!(await confirmFieldMigration(ctx, config, state, currentRemote, true, options.signal))) return;
574
+ validateMutation();
575
+ const physical = overlayLocalFields(remote, local, config.localFields);
436
576
 
437
577
  if (
438
578
  !options.yes &&
439
579
  !(await ctx.ui.confirm(
440
580
  snapshotIncludesSessions(remote) ? "Rollback pi settings and sessions?" : "Rollback pi settings?",
441
- formatRollbackSummary(config, backend.destination, local, remote, target, protectedSessionPaths(ctx).size),
581
+ formatRollbackSummary(config, backend.destination, local, physical, target, protectedSessionPaths(ctx).size),
442
582
  ))
443
583
  ) {
444
584
  ctx.ui.notify("Rollback cancelled.", "info");
445
585
  return;
446
586
  }
447
587
 
588
+ validateMutation();
589
+ const currentLocal = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
590
+ if (!sameHashes(fileHashMap(currentLocal), fileHashMap(local)))
591
+ throw new Error("Local bytes changed during rollback review.");
592
+ if (syncCheckConfigFingerprint(config) !== syncCheckConfigFingerprint(await loadConfig(options.setup)))
593
+ throw new Error("Settings changed during rollback review.");
594
+ if (!sameRemoteHead(backend, expectedHead, await backend.readHead(options.signal)))
595
+ throw new Error("Remote changed during rollback review.");
596
+ validateMutation();
448
597
  throwIfAborted(options.signal);
449
598
  const backup = await backupLocal(config.snapshotIdentity, snapshotOptionsForContext(ctx, config), options.signal);
450
599
  const applySessionDir = await sessionDirForApply(ctx, remote);
451
- throwIfAborted(options.signal);
600
+ requireResolvedDirectionalProgress(await readStateForConfig(config), options, false);
601
+ validateMutation();
452
602
  options.onCommit?.();
453
- const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
603
+ const lastFileHashes = await applySnapshot(physical, protectedSessionPaths(ctx), {
454
604
  include: config.include,
455
605
  sessionDir: applySessionDir,
606
+ signal: options.signal,
607
+ validateMutation,
608
+ expectedFileHashes: config.localFields?.length
609
+ ? { "settings.json": fileHashMap(local)["settings.json"] ?? null }
610
+ : undefined,
456
611
  });
612
+ // The local transaction is retired; finish publication despite caller cancellation.
613
+ validateCommittedOwner();
457
614
  let result: PublishSnapshotResult;
458
615
  try {
459
616
  const completionSignal = AbortSignal.timeout(POST_LOCAL_COMMIT_TIMEOUT_MS);
460
- const upload = await snapshotForUpload(backend, config, remote, expectedHead, undefined, completionSignal, {
461
- ignoreUnreadableRemote: true,
462
- });
617
+ const upload = await snapshotForUpload(backend, config, remote, expectedHead, currentRemote, completionSignal);
618
+ validateCommittedOwner();
463
619
  result = await backend.publishSnapshot(upload, expectedRemoteHead(expectedHead), {
464
620
  signal: completionSignal,
465
621
  });
@@ -467,17 +623,28 @@ export async function rollback(
467
623
  throw new RollbackPublicationError(backup, error);
468
624
  }
469
625
  try {
470
- await writeStateForConfig(config, {
471
- version: VERSION,
472
- profile: config.snapshotIdentity,
473
- lastAppliedSnapshot: result.head.snapshotId,
474
- lastRemoteRevision: result.head.revision,
475
- lastFileHashes,
476
- include: [...config.include],
477
- });
626
+ await writeStateForConfig(
627
+ config,
628
+ {
629
+ version: VERSION,
630
+ profile: config.snapshotIdentity,
631
+ lastAppliedSnapshot: result.head.snapshotId,
632
+ lastRemoteRevision: result.head.revision,
633
+ lastFileHashes: config.localFields?.length
634
+ ? { ...lastFileHashes, "settings.json": fileHashMap(remote)["settings.json"] }
635
+ : lastFileHashes,
636
+ include: [...config.include],
637
+ ...(config.localFields !== undefined ? { localFields: config.localFields } : {}),
638
+ },
639
+ remote,
640
+ validateCommittedOwner,
641
+ );
478
642
  } catch (error) {
479
643
  throw new PublicationStatePersistenceError(result.head, error, backup);
480
644
  }
645
+ await retireMergeJournal(config);
646
+ await pruneMergeBaselines(config, await readStateForConfig(config), validateCommittedOwner);
647
+ validateCommittedOwner();
481
648
  if (options.signal?.aborted) return;
482
649
  ctx.ui.notify(
483
650
  [
@@ -488,21 +655,16 @@ export async function rollback(
488
655
  .join("\n"),
489
656
  result.warnings.length > 0 ? "warning" : "info",
490
657
  );
491
- await maybeReload(ctx, options.signal);
658
+ await maybeReload(ctx, options.signal, validateMutation);
492
659
  }
493
660
 
494
- function protectedSessionPaths(ctx: ExtensionCommandContext | ExtensionContext) {
495
- const getSessionFile = ctx.sessionManager.getSessionFile;
496
- if (typeof getSessionFile !== "function") return new Set<string>();
497
- const sessionFile = getSessionFile.call(ctx.sessionManager) as string | undefined;
498
- const snapshotPath = sessionFile
499
- ? sessionSnapshotPathFromAbsolute(sessionFile, sessionDirFromContext(ctx))
500
- : undefined;
501
- return snapshotPath ? new Set([snapshotPath]) : new Set<string>();
502
- }
503
-
504
- async function maybeReload(ctx: ExtensionCommandContext | ExtensionContext, signal?: AbortSignal) {
661
+ async function maybeReload(
662
+ ctx: ExtensionCommandContext | ExtensionContext,
663
+ signal?: AbortSignal,
664
+ validateMutation = captureMutationOwner(ctx, signal),
665
+ ) {
505
666
  if (signal?.aborted || !("reload" in ctx)) return;
667
+ validateMutation();
506
668
  if (
507
669
  ctx.hasUI &&
508
670
  (await ctx.ui.confirm(
@@ -511,6 +673,7 @@ async function maybeReload(ctx: ExtensionCommandContext | ExtensionContext, sign
511
673
  ))
512
674
  ) {
513
675
  if (signal?.aborted) return;
676
+ validateMutation();
514
677
  await ctx.reload();
515
678
  }
516
679
  }
@@ -530,7 +693,7 @@ async function readRemoteSnapshotForUpload(
530
693
  ) {
531
694
  return undefined;
532
695
  }
533
- return backend.readSnapshot(head.snapshotRef, signal);
696
+ return readSnapshotForHead(backend, head, signal);
534
697
  }
535
698
 
536
699
  async function snapshotForUpload(
@@ -540,18 +703,9 @@ async function snapshotForUpload(
540
703
  head: RemoteHead | undefined,
541
704
  remote?: Snapshot,
542
705
  signal?: AbortSignal,
543
- options: { ignoreUnreadableRemote?: boolean } = {},
544
706
  ) {
545
707
  if (!head) return local;
546
- let snapshot = remote;
547
- if (!snapshot) {
548
- try {
549
- snapshot = await backend.readSnapshot(head.snapshotRef, signal);
550
- } catch (error) {
551
- if (options.ignoreUnreadableRemote) return local;
552
- throw error;
553
- }
554
- }
708
+ const snapshot = remote ?? (await readSnapshotForHead(backend, head, signal));
555
709
  return mergeRemotePreservedFiles(local, snapshot, config);
556
710
  }
557
711
 
@@ -584,16 +738,4 @@ function sameRemoteHead(backend: SyncBackend, left: RemoteHead | undefined, righ
584
738
  return backend.sameRevision(left.revision, right.revision);
585
739
  }
586
740
 
587
- export async function backupLocal(profile: string, options: SnapshotOptions = {}, signal?: AbortSignal) {
588
- throwIfAborted(signal);
589
- const snapshot = await createSnapshot(profile, options);
590
- throwIfAborted(signal);
591
- const backupDirectory = path.join(stateDir(), "backups");
592
- await fs.mkdir(backupDirectory, { recursive: true });
593
- throwIfAborted(signal);
594
- const backupPath = path.join(backupDirectory, `${snapshot.id}.json.gz`);
595
- const encoded = await encodeSnapshot(snapshot);
596
- throwIfAborted(signal);
597
- await fs.writeFile(backupPath, encoded, { signal });
598
- return backupPath;
599
- }
741
+ export { backupLocal } from "./sync-local.js";
@@ -10,6 +10,7 @@ import { inspectLock, isLockGuardHeld, isStaleLock, withLock } from "../state/lo
10
10
  import { formatDiff, formatSnapshotOnlyDiff, publicationCapabilityDescription } from "../ui/sync-format.js";
11
11
  import { setSyncStatus } from "../ui/sync-status.js";
12
12
  import { safeTerminalText } from "../ui/terminal-text.js";
13
+ import { portableSnapshot } from "./local-fields.js";
13
14
  import { formatRemoteSelectionStatus, readRemoteSnapshot } from "./remote-snapshot.js";
14
15
  import { throwIfAborted } from "./signals.js";
15
16
  import { errorMessage } from "./sync-errors.js";
@@ -65,7 +66,10 @@ export async function diff(
65
66
  throwIfAborted(options.signal);
66
67
  setSyncStatus(ctx, `checking ${config.setupName}`);
67
68
  const backend = await factory(config);
68
- const local = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
69
+ const local = portableSnapshot(
70
+ await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config)),
71
+ config.localFields,
72
+ );
69
73
  throwIfAborted(options.signal);
70
74
  const { snapshot: remote, selectionState } = await readRemoteSnapshot(backend, config, options.signal, {
71
75
  allowSelectionDifference: true,
@@ -89,7 +93,7 @@ export async function diff(
89
93
  return;
90
94
  }
91
95
 
92
- ctx.ui.notify(`${header}\n\n${formatDiff(local, remote)}`, level);
96
+ ctx.ui.notify(`${header}\n\n${formatDiff(local, portableSnapshot(remote, remote.localFields))}`, level);
93
97
  }
94
98
 
95
99
  export async function doctor(