@narumitw/pi-sync 0.51.1 → 0.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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-4MPIH46N.ts → chunk-3B4Z2SKV.ts} +40 -6
  7. package/dist/chunks/chunk-3B4Z2SKV.ts.map +7 -0
  8. package/dist/chunks/{chunk-YPXGU5OR.ts → chunk-4QVPTIZB.ts} +16 -6
  9. package/dist/chunks/{chunk-YPXGU5OR.ts.map → chunk-4QVPTIZB.ts.map} +2 -2
  10. package/dist/chunks/{chunk-UA7RXOIT.ts → chunk-4SBTAVFL.ts} +6 -6
  11. package/dist/chunks/{chunk-6546TS54.ts → chunk-5RZPINUQ.ts} +7 -4
  12. package/dist/chunks/{chunk-6546TS54.ts.map → chunk-5RZPINUQ.ts.map} +2 -2
  13. package/dist/chunks/chunk-76NB4R7U.ts +107 -0
  14. package/dist/chunks/chunk-76NB4R7U.ts.map +7 -0
  15. package/dist/chunks/{chunk-DM4X4FIA.ts → chunk-7D6RDCVC.ts} +8 -10
  16. package/dist/chunks/{chunk-DM4X4FIA.ts.map → chunk-7D6RDCVC.ts.map} +2 -2
  17. package/dist/chunks/chunk-A6HU4ERM.ts +124 -0
  18. package/dist/chunks/chunk-A6HU4ERM.ts.map +7 -0
  19. package/dist/chunks/chunk-AMJRUXFT.ts +17 -0
  20. package/dist/chunks/chunk-AMJRUXFT.ts.map +7 -0
  21. package/dist/chunks/chunk-ANWNQIR7.ts +1013 -0
  22. package/dist/chunks/chunk-ANWNQIR7.ts.map +7 -0
  23. package/dist/chunks/{chunk-T56RNRZJ.ts → chunk-BDB4IDM3.ts} +19 -10
  24. package/dist/chunks/chunk-BDB4IDM3.ts.map +7 -0
  25. package/dist/chunks/{chunk-2RZSQWBH.ts → chunk-CHSIAC4N.ts} +5 -5
  26. package/dist/chunks/{chunk-2RZSQWBH.ts.map → chunk-CHSIAC4N.ts.map} +2 -2
  27. package/dist/chunks/chunk-HUZ5CR4W.ts +110 -0
  28. package/dist/chunks/chunk-HUZ5CR4W.ts.map +7 -0
  29. package/dist/chunks/{chunk-QLSN7AGX.ts → chunk-IJ6VV6FN.ts} +81 -19
  30. package/dist/chunks/chunk-IJ6VV6FN.ts.map +7 -0
  31. package/dist/chunks/chunk-NLS2NV3K.ts +1746 -0
  32. package/dist/chunks/chunk-NLS2NV3K.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-4WPGCXFB.ts +175 -0
  50. package/dist/chunks/conflict-review-4WPGCXFB.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-RHNECOOG.ts} +309 -508
  71. package/dist/chunks/sync-operations-RHNECOOG.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 +5 -5
  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 +428 -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
@@ -0,0 +1,723 @@
1
+ import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { createSyncBackend, type SyncBackendFactory } from "../backends/backend-factory.js";
3
+ import {
4
+ expectedRemoteHead,
5
+ type RemoteHead,
6
+ type SyncBackend,
7
+ SyncBackendConflictError,
8
+ } from "../backends/sync-backend.js";
9
+ import type { CommandOptions } from "../commands/command-types.js";
10
+ import { loadConfig, syncCheckConfigFingerprint } from "../settings/config.js";
11
+ import type { AnySyncConfig } from "../settings/settings-types.js";
12
+ import {
13
+ effectiveSessionRoot,
14
+ requireStableMergeSessionRoot,
15
+ snapshotOptionsForContext,
16
+ } from "../snapshot/session-paths.js";
17
+ import {
18
+ createSnapshot,
19
+ filterSnapshotForConfigPolicy,
20
+ mergeRemotePreservedFiles,
21
+ regenerateSnapshotIdentity,
22
+ scanSnapshot,
23
+ } from "../snapshot/snapshot.js";
24
+ import type { Snapshot } from "../snapshot/snapshot-types.js";
25
+ import { pruneMergeBaselines, readMergeAncestors, stageMergeBaseline } from "../state/merge-baseline-store.js";
26
+ import {
27
+ readStateForConfig,
28
+ statePathForConfig,
29
+ syncStateFingerprint,
30
+ writeStateForConfig,
31
+ } from "../state/sync-state-store.js";
32
+ import { confirmMergeReview } from "../ui/merge-review.js";
33
+ import { formatApplyPreview, formatPublicationPreview } from "../ui/sync-format.js";
34
+ import { safeTerminalText } from "../ui/terminal-text.js";
35
+ import { releaseConflictArtifacts } from "./conflict-artifact-ownership.js";
36
+ import {
37
+ type CreatedConflictArtifact,
38
+ conflictGroups,
39
+ readConflictArtifact,
40
+ saveConflictArtifact,
41
+ } from "./conflict-artifacts.js";
42
+ import { type ConflictResolution, resolveReviewedGroup } from "./conflict-resolution.js";
43
+ import { pruneCompletedConflicts } from "./conflict-retention.js";
44
+ import { resolveContentConflicts } from "./content-conflicts.js";
45
+ import { planFileMerge } from "./file-merge-planner.js";
46
+ import { overlayLocalFields, portableSnapshot, sameLocalFields } from "./local-fields.js";
47
+ import { applyMergedSnapshot, preflightMergedTargets } from "./merge-apply.js";
48
+ import {
49
+ clearMergeJournal,
50
+ type MergeJournal,
51
+ mergeJournalIdentity,
52
+ readMergeJournal,
53
+ writeMergeJournal,
54
+ } from "./merge-journal.js";
55
+ import { acceptedMergeFiles, type PartialProgress, progressState } from "./partial-progress.js";
56
+ import {
57
+ formatRemoteSelectionStatus,
58
+ readSnapshotForHead,
59
+ requireCompatibleRemoteSelection,
60
+ } from "./remote-snapshot.js";
61
+ import { resolveSettingsConflicts } from "./settings-conflicts.js";
62
+ import { createSyncDecision } from "./sync-decision.js";
63
+ import { backupLocal, captureMutationOwner, protectedSessionPaths } from "./sync-local.js";
64
+ import { inspectRemoteSelection } from "./sync-policy.js";
65
+ import { fileHashMap, hasLocalChanges, hasRemoteChanges, sameHashes, syncPolicyChanged } from "./sync-state.js";
66
+ import { TEXT_MERGE_WORK_LIMIT } from "./text-merge.js";
67
+
68
+ const MAX_ATTEMPTS = 3;
69
+
70
+ /** Established-baseline sync only. First-source selection remains in the existing command flow. */
71
+ export async function mergeSync(
72
+ ctx: ExtensionContext | ExtensionCommandContext,
73
+ options: CommandOptions,
74
+ factory: SyncBackendFactory = createSyncBackend,
75
+ resolution?: ConflictResolution,
76
+ ) {
77
+ const validateOwner = captureMutationOwner(ctx, options.signal);
78
+ const isCurrent = () => {
79
+ try {
80
+ validateOwner();
81
+ return true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ };
86
+ const config = await loadConfig(options.setup);
87
+ const configToken = syncCheckConfigFingerprint(config);
88
+ const sessionRoot = config.include.includes("sessions") ? await effectiveSessionRoot(ctx) : undefined;
89
+ validateOwner();
90
+ const snapshotOptions = {
91
+ ...snapshotOptionsForContext(ctx, config),
92
+ sessionDir: sessionRoot,
93
+ signal: options.signal,
94
+ };
95
+ const validate = async () => {
96
+ validateOwner();
97
+ const latest = await loadConfig(options.setup);
98
+ validateOwner();
99
+ if (configToken !== syncCheckConfigFingerprint(latest))
100
+ throw new Error("Sync setup or settings changed during transfer; reopen sync.");
101
+ if (sessionRoot !== undefined) {
102
+ const currentRoot = await effectiveSessionRoot(ctx);
103
+ validateOwner();
104
+ if (currentRoot !== sessionRoot)
105
+ throw new Error("Session storage root changed during transfer; evidence retained for review.");
106
+ }
107
+ if (options.auto && (!latest.automaticTransfer || !ctx.isIdle()))
108
+ throw new Error("Automatic transfer is no longer authorized at this idle boundary.");
109
+ };
110
+ await validate();
111
+ const backend = await factory(config);
112
+ await validate();
113
+ if (options.auto && backend.capability === "read-check-write-verify") {
114
+ throw new Error(
115
+ "Automatic transfer requires conditional publication or a lease; this backend supports only read/check/write/verify. Use manual sync.",
116
+ );
117
+ }
118
+ const pending = await readMergeJournal(config);
119
+ await validate();
120
+ if (pending) {
121
+ const recovered = await completeJournal(ctx, config, backend, pending, validate, options.signal, options.auto);
122
+ if (!recovered) {
123
+ if ((options.auto || !options.silent) && !options.signal?.aborted)
124
+ ctx.ui.notify(
125
+ "Interrupted candidate is not active; local content and baseline were unchanged. Evidence was reconciled without retrying publication. Run sync again for a fresh plan.",
126
+ "warning",
127
+ );
128
+ return "cancelled" as const;
129
+ }
130
+ if ((options.auto || !options.silent) && !options.signal?.aborted)
131
+ ctx.ui.notify("Recovered the committed merged transfer. Run sync again to inspect newer changes.", "info");
132
+ return "applied" as const;
133
+ }
134
+ const textBudget = { remaining: TEXT_MERGE_WORK_LIMIT };
135
+ const createdArtifacts: CreatedConflictArtifact[] = [];
136
+ const ownedStatePath = statePathForConfig(config);
137
+ try {
138
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
139
+ await validate();
140
+ const state = await readStateForConfig(config);
141
+ if (state.unresolved?.length && !config.partialSync)
142
+ throw new Error(
143
+ "Partial progress remains unresolved; re-enable partial sync or review an explicit force direction.",
144
+ );
145
+ const localRaw = await createSnapshot(config.snapshotIdentity, snapshotOptions);
146
+ const local = portableSnapshot(localRaw, config.localFields);
147
+ if (!sameLocalFields(state.localFields, config.localFields))
148
+ throw new Error("Local-field rules changed; review and confirm a directional migration first.");
149
+ const head = await backend.readHead(options.signal);
150
+ await validate();
151
+ const rawRemote = head ? await readSnapshotForHead(backend, head, options.signal) : undefined;
152
+ await validate();
153
+ if (rawRemote) {
154
+ const files = [...local.files, ...rawRemote.files];
155
+ if (
156
+ files.length > 16_384 ||
157
+ files.reduce((bytes, file) => bytes + file.contentBase64.length * 0.75, 0) > 64 * 1024 * 1024
158
+ ) {
159
+ throw new Error(
160
+ "Merged transfer exceeds the 64 MiB / 16,384 input-file bound; review an explicit push or pull instead.",
161
+ );
162
+ }
163
+ }
164
+ const remote = rawRemote
165
+ ? portableSnapshot(filterSnapshotForConfigPolicy(rawRemote, config), rawRemote.localFields)
166
+ : undefined;
167
+ if (rawRemote) {
168
+ requireCompatibleRemoteSelection(config, rawRemote);
169
+ const validation = planFileMerge({
170
+ baseline: state.lastFileHashes,
171
+ local: local.files,
172
+ remote: rawRemote.files,
173
+ selectionCompatible: true,
174
+ });
175
+ const managedPaths = new Set([
176
+ ...Object.keys(state.lastFileHashes),
177
+ ...[...local.files, ...(remote?.files ?? [])].map((file) => file.path),
178
+ ]);
179
+ if (
180
+ validation.kind === "planned" &&
181
+ validation.conflicts.some(
182
+ (item) => item.reason === "path-collision" && (!config.partialSync || !managedPaths.has(item.path)),
183
+ )
184
+ )
185
+ throw new Error("Remote snapshot contains path collisions; review a directional recovery before merging.");
186
+ }
187
+ const review = (
188
+ message: string,
189
+ kind: "both-changed" | "remote-empty" | "remote-or-policy-changed" = "both-changed",
190
+ ) =>
191
+ createSyncDecision({
192
+ kind,
193
+ config,
194
+ state,
195
+ local,
196
+ remote,
197
+ localChanged: hasLocalChanges(local, state, config),
198
+ remoteChanged: remote ? hasRemoteChanges(remote, state, config) : Boolean(state.lastAppliedSnapshot),
199
+ directMessage: message,
200
+ });
201
+ if (!state.lastAppliedSnapshot) throw review("Choose the initial source before enabling automatic transfer.");
202
+ if (syncPolicyChanged(state, config))
203
+ throw review("Included content changed; review the selection before merging.", "remote-or-policy-changed");
204
+ if (!head || !remote || !rawRemote)
205
+ throw review("The established remote is missing; choose an explicit recovery direction.", "remote-empty");
206
+ const selectionState = inspectRemoteSelection(config.include, rawRemote);
207
+ const selectionCompatible = selectionState.kind === "same";
208
+ let plan = planFileMerge({
209
+ baseline: state.lastFileHashes,
210
+ local: local.files,
211
+ remote: remote.files,
212
+ selectionCompatible,
213
+ protectedPaths: protectedSessionPaths(ctx, sessionRoot),
214
+ });
215
+ let resolved = { plan, fields: [] as string[] };
216
+ if (config.mergeSettings) {
217
+ try {
218
+ resolved = await resolveSettingsConflicts(config, state, local, remote, plan);
219
+ } catch (error) {
220
+ if (!config.partialSync) throw error;
221
+ }
222
+ }
223
+ await validate();
224
+ plan = await resolveContentConflicts(
225
+ config,
226
+ state,
227
+ local,
228
+ remote,
229
+ resolved.plan,
230
+ protectedSessionPaths(ctx, sessionRoot),
231
+ { textBudget, signal: options.signal },
232
+ );
233
+ await validate();
234
+ if (resolution) {
235
+ plan = await resolveReviewedGroup(
236
+ config,
237
+ backend,
238
+ state,
239
+ local,
240
+ remote,
241
+ head,
242
+ plan,
243
+ resolution,
244
+ protectedSessionPaths(ctx, sessionRoot),
245
+ );
246
+ await validate();
247
+ }
248
+ if (plan.kind !== "planned" || (plan.conflicts.length && (!config.partialSync || options.auto))) {
249
+ const decision = review(
250
+ selectionCompatible
251
+ ? `Conflicting or protected paths require review; no merged transfer was performed. Use /sync diff and an explicit direction.${resolved.fields.length ? ` Settings fields: ${resolved.fields.map(safeTerminalText).join(", ")}` : ""}`
252
+ : "Remote selection metadata is unavailable; review the included content and choose an explicit direction before merging.",
253
+ selectionCompatible ? "both-changed" : "remote-or-policy-changed",
254
+ );
255
+ if (!selectionCompatible)
256
+ decision.decision.review += `\n\n${formatRemoteSelectionStatus(selectionState)}\nAn explicit direction adopts this setup's included-content policy; no automatic merge was performed.`;
257
+ throw decision;
258
+ }
259
+ if (plan.kind !== "planned") throw new Error("Cannot partition an unverified merge plan.");
260
+ const groups = conflictGroups(plan, config);
261
+ const withheld = new Set(groups.flatMap((group) => group.paths));
262
+ // Transfer plans defer artifact writes until approved review and freshness guards pass.
263
+ // A no-transfer manual inspection can persist referenced conflict state directly.
264
+ const persistProgress = async (): Promise<PartialProgress | undefined> => {
265
+ if (!groups.length) return;
266
+ let ancestors: import("../snapshot/snapshot-types.js").SnapshotFile[] = [];
267
+ try {
268
+ ancestors = ((await readMergeAncestors(config, state)) ?? []).filter((file) => withheld.has(file.path));
269
+ } catch {
270
+ /* Retain unknown evidence; absence remains explicit. */
271
+ }
272
+ await validate();
273
+ const previousArtifacts = new Map<
274
+ string,
275
+ {
276
+ artifact: Awaited<ReturnType<typeof readConflictArtifact>>;
277
+ local: ReturnType<typeof fileHashMap>;
278
+ remote: ReturnType<typeof fileHashMap>;
279
+ }
280
+ >();
281
+ for (const token of new Set(state.unresolved?.map((group) => group.artifact) ?? [])) {
282
+ try {
283
+ const artifact = await readConflictArtifact(config, backend.identity, token);
284
+ previousArtifacts.set(token, {
285
+ artifact,
286
+ local: fileHashMap(artifact.local),
287
+ remote: fileHashMap(artifact.remote),
288
+ });
289
+ } catch {
290
+ /* Unknown evidence retained, never reused. */
291
+ }
292
+ await validate();
293
+ }
294
+ const localHashes = fileHashMap(local);
295
+ const remoteHashes = fileHashMap(remote);
296
+ const previousGroups = new Map(state.unresolved?.map((item) => [JSON.stringify(item.paths), item]));
297
+ const pending = groups.map((group) => {
298
+ const previous = previousGroups.get(JSON.stringify(group.paths));
299
+ const original = previous ? previousArtifacts.get(previous.artifact) : undefined;
300
+ if (!original || !previous) return { group };
301
+ return group.paths.every(
302
+ (filePath) =>
303
+ localHashes[filePath] === original.local[filePath] &&
304
+ remoteHashes[filePath] === original.remote[filePath] &&
305
+ state.lastFileHashes[filePath] === original.artifact.state.lastFileHashes[filePath],
306
+ )
307
+ ? { group, artifact: previous.artifact }
308
+ : { group };
309
+ });
310
+ let artifact: string | undefined;
311
+ const newGroups = pending.filter((item) => !item.artifact).map((item) => item.group);
312
+ const newPaths = new Set(newGroups.flatMap((group) => group.paths));
313
+ if (newGroups.length)
314
+ artifact = await saveConflictArtifact(
315
+ config,
316
+ backend.identity,
317
+ {
318
+ state,
319
+ local: { ...local, files: local.files.filter((file) => newPaths.has(file.path)) },
320
+ remote: { ...remote, files: remote.files.filter((file) => newPaths.has(file.path)) },
321
+ ancestors: ancestors.filter((file) => newPaths.has(file.path)),
322
+ groups: newGroups,
323
+ observed: { snapshotId: head.snapshotId, revision: head.revision },
324
+ },
325
+ captureMutationOwner(ctx, options.signal),
326
+ (created) => createdArtifacts.push(created),
327
+ );
328
+ await validate();
329
+ return {
330
+ previous: state,
331
+ groups: pending.map((item) => ({ paths: item.group.paths, artifact: item.artifact ?? artifact ?? "" })),
332
+ };
333
+ };
334
+ let progress: PartialProgress | undefined;
335
+ const accepted = regenerateSnapshotIdentity({
336
+ ...local,
337
+ files: acceptedMergeFiles(plan.decisions, local.files, withheld),
338
+ });
339
+ const after = overlayLocalFields(accepted, localRaw, config.localFields);
340
+ if (config.include.includes("sessions")) requireStableMergeSessionRoot(localRaw, after);
341
+ const publication = regenerateSnapshotIdentity({
342
+ ...accepted,
343
+ version: config.partialSync ? 3 : accepted.version,
344
+ files: [
345
+ ...accepted.files.filter((file) => !withheld.has(file.path)),
346
+ ...remote.files.filter((file) => withheld.has(file.path)),
347
+ ],
348
+ });
349
+ const upload = mergeRemotePreservedFiles(publication, rawRemote, config);
350
+ const publish =
351
+ !sameHashes(fileHashMap(publication), fileHashMap(remote)) ||
352
+ Boolean(config.partialSync && rawRemote.version !== 3);
353
+ const apply = !sameHashes(fileHashMap(localRaw), fileHashMap(after));
354
+ if (!publish && !apply) {
355
+ const beforeEvidence = await backend.readHead(options.signal);
356
+ await validate();
357
+ if (!beforeEvidence || !backend.sameRevision(beforeEvidence.revision, head.revision))
358
+ throw new Error("Remote changed before baseline acceptance; retry from a fresh observation.");
359
+ progress = await persistProgress();
360
+ await validate();
361
+ const currentLocal = await createSnapshot(config.snapshotIdentity, snapshotOptions);
362
+ await validate();
363
+ if (
364
+ !sameHashes(fileHashMap(currentLocal), fileHashMap(localRaw)) ||
365
+ syncStateFingerprint(await readStateForConfig(config)) !== syncStateFingerprint(state)
366
+ )
367
+ throw new Error(
368
+ "Local content or baseline changed before baseline acceptance; retry from a fresh observation.",
369
+ );
370
+ await validate();
371
+ const currentHead = await backend.readHead(options.signal);
372
+ await validate();
373
+ if (!currentHead || !backend.sameRevision(currentHead.revision, head.revision))
374
+ throw new Error("Remote changed before baseline acceptance; retry from a fresh observation.");
375
+ await writeAcceptedState(config, head, accepted, validate, progress);
376
+ await pruneMergeBaselines(
377
+ config,
378
+ acceptedState(config, head, accepted, progress),
379
+ captureMutationOwner(ctx, options.signal),
380
+ );
381
+ await validate();
382
+ await pruneCompletedConflicts(
383
+ config,
384
+ backend.identity,
385
+ acceptedState(config, head, accepted, progress),
386
+ accepted,
387
+ remote,
388
+ captureMutationOwner(ctx, options.signal),
389
+ );
390
+ await validate();
391
+ if (!options.silent)
392
+ ctx.ui.notify(
393
+ progress
394
+ ? `No independent transfer required; ${groups.length} conflict groups remain withheld. Review /sync conflicts.`
395
+ : "Pi Sync is already up to date.",
396
+ progress ? "warning" : "info",
397
+ );
398
+ return "applied" as const;
399
+ }
400
+ await preflightMergedTargets(localRaw, after, snapshotOptions, ctx.sessionManager.getSessionFile?.());
401
+ await validate();
402
+ if (!config.skipSecretScan && scanSnapshot(upload).length)
403
+ throw new Error("Refusing to merge possible secrets. Review managed content before syncing.");
404
+ if (
405
+ !options.yes &&
406
+ !(await confirmMergeReview(
407
+ ctx,
408
+ "Merge independent Pi changes?",
409
+ [
410
+ `Sync setup: ${safeTerminalText(config.setupName)}`,
411
+ `Storage location: ${safeTerminalText(backend.destination)}`,
412
+ `Sessions: ${config.include.includes("sessions") ? "included — may contain private conversations" : "not included"}`,
413
+ `Local writes/deletions: ${plan.decisions.filter((item) => item.kind === "accepted" && (item.source === "remote" || item.source === "merged")).length}`,
414
+ formatApplyPreview(localRaw, after).split("\n").map(safeTerminalText).join("\n"),
415
+ `Unresolved dependency groups: ${groups.length} (withheld versions stay on each side; baselines do not advance)`,
416
+ ...groups.flatMap((group) => group.paths.map((filePath) => `Withheld: ${safeTerminalText(filePath)}`)),
417
+ `Remote publication: ${publish ? "yes" : "no"}`,
418
+ formatPublicationPreview(rawRemote, upload).split("\n").map(safeTerminalText).join("\n"),
419
+ `Backend publication: ${backend.capability}`,
420
+ "The current session is protected. A backup and recovery journal are retained. Resources are not reloaded.",
421
+ ].join("\n"),
422
+ options.signal,
423
+ isCurrent,
424
+ ))
425
+ )
426
+ return "cancelled" as const;
427
+ await validate();
428
+ const refreshed = await createSnapshot(config.snapshotIdentity, snapshotOptions);
429
+ if (!sameHashes(fileHashMap(refreshed), fileHashMap(localRaw)))
430
+ throw new Error("Local content changed during review; no merged transfer was performed.");
431
+ if (syncStateFingerprint(await readStateForConfig(config)) !== syncStateFingerprint(state))
432
+ throw new Error("Sync baseline changed during review; retry from a fresh observation.");
433
+ await validate();
434
+ const refreshedHead = await backend.readHead(options.signal);
435
+ await validate();
436
+ if (!refreshedHead || !backend.sameRevision(refreshedHead.revision, head.revision))
437
+ throw new Error("Remote changed during review; no merged transfer was performed.");
438
+ progress = await persistProgress();
439
+ await validate();
440
+ const backup = await backupLocal(config.snapshotIdentity, snapshotOptions, options.signal);
441
+ await validate();
442
+ const journal: MergeJournal = {
443
+ version: 1,
444
+ identity: mergeJournalIdentity(config, backend.identity),
445
+ before: localRaw,
446
+ after,
447
+ accepted,
448
+ ...(progress ? { progress } : {}),
449
+ upload: publish ? upload : rawRemote,
450
+ expectedHead: head,
451
+ backup,
452
+ stateIdentity: syncStateFingerprint(state),
453
+ ...(sessionRoot !== undefined ? { sessionRoot } : {}),
454
+ ...(!publish ? { committedHead: head, applyOnly: true } : {}),
455
+ };
456
+ await writeMergeJournal(config, journal);
457
+ // No backend call has begun: a stale candidate can be retired without ambiguous publication.
458
+ const atCommitState = syncStateFingerprint(await readStateForConfig(config));
459
+ await validate();
460
+ const atCommit = await createSnapshot(config.snapshotIdentity, snapshotOptions);
461
+ await validate();
462
+ if (!sameHashes(fileHashMap(atCommit), fileHashMap(localRaw)) || atCommitState !== journal.stateIdentity) {
463
+ await validate();
464
+ await clearMergeJournal(config);
465
+ throw new Error(
466
+ "Local content or baseline changed before publication; candidate retired without transfer. Review a fresh sync.",
467
+ );
468
+ }
469
+ // Backup/journal awaits can also change physical identity without changing any bytes.
470
+ try {
471
+ await preflightMergedTargets(atCommit, after, snapshotOptions, ctx.sessionManager.getSessionFile?.());
472
+ await validate();
473
+ } catch (error) {
474
+ await validate();
475
+ await clearMergeJournal(config);
476
+ throw error;
477
+ }
478
+ try {
479
+ if (publish) {
480
+ const result = await backend.publishSnapshot(upload, expectedRemoteHead(head), {
481
+ signal: options.signal,
482
+ onCommit: options.onCommit,
483
+ });
484
+ journal.committedHead = result.head;
485
+ journal.warnings = result.warnings;
486
+ await writeMergeJournal(config, journal);
487
+ } else options.onCommit?.();
488
+ } catch (error) {
489
+ if (
490
+ error instanceof SyncBackendConflictError &&
491
+ error.phase === "before-commit" &&
492
+ !error.candidateMayHaveBeenActive
493
+ ) {
494
+ await clearMergeJournal(config);
495
+ if (attempt + 1 < MAX_ATTEMPTS) continue;
496
+ throw new Error("Remote changed before commit in all three attempts; retry later.", { cause: error });
497
+ }
498
+ // Cancellation before publication is safe only if no backend attempt began.
499
+ // Every uncertain/after-commit outcome retains evidence and is never blindly retried.
500
+ throw new Error(
501
+ "Merged transfer interrupted; journal and backup retained. Run /sync sync to reconcile before further mutations.",
502
+ { cause: error },
503
+ );
504
+ }
505
+ const completed = await completeJournal(ctx, config, backend, journal, validate, options.signal, options.auto);
506
+ if (!completed) {
507
+ if (!options.signal?.aborted && (options.auto || !options.silent))
508
+ ctx.ui.notify(
509
+ "Remote changed before local apply; candidate retired without transfer. Run sync again.",
510
+ "warning",
511
+ );
512
+ return "cancelled" as const;
513
+ }
514
+ if (!options.signal?.aborted && (options.auto || !options.silent))
515
+ ctx.ui.notify(
516
+ [
517
+ `Synced independent changes for “${safeTerminalText(config.setupName)}”. ${apply ? "Local files changed; reload or restart Pi when ready to use changed resources." : "Remote updated."} No automatic reload.`,
518
+ ...(progress
519
+ ? [
520
+ `Partial progress: ${groups.length} dependency groups remain unresolved; neither side's withheld versions or baseline hashes were replaced. Review /sync conflicts.`,
521
+ ]
522
+ : []),
523
+ ...(journal.warnings ?? []).map(safeTerminalText),
524
+ ].join("\n"),
525
+ progress || journal.warnings?.length ? "warning" : "info",
526
+ );
527
+ return "applied" as const;
528
+ }
529
+ } finally {
530
+ await releaseConflictArtifacts(config, ownedStatePath, createdArtifacts);
531
+ }
532
+ }
533
+
534
+ async function completeJournal(
535
+ ctx: ExtensionContext | ExtensionCommandContext,
536
+ config: AnySyncConfig,
537
+ backend: SyncBackend,
538
+ journal: MergeJournal,
539
+ validate: () => Promise<void>,
540
+ signal?: AbortSignal,
541
+ auto = false,
542
+ ) {
543
+ if (
544
+ journal.identity !== mergeJournalIdentity(config, backend.identity) ||
545
+ !sameLocalFields(journal.upload.localFields, config.localFields)
546
+ )
547
+ throw new Error(
548
+ "Merge journal belongs to a different setup, selection or local-field policy; preserve it for reviewed recovery.",
549
+ );
550
+ await validate();
551
+ const sessionRoot = config.include.includes("sessions") ? await effectiveSessionRoot(ctx) : undefined;
552
+ await validate();
553
+ if (sessionRoot !== undefined && journal.sessionRoot !== sessionRoot) {
554
+ throw new Error(
555
+ "Merge journal session root is missing or differs from this context; preserve evidence and use reviewed directional recovery.",
556
+ );
557
+ }
558
+ const snapshotOptions = { ...snapshotOptionsForContext(ctx, config), sessionDir: sessionRoot, signal };
559
+ const head = await backend.readHead(signal);
560
+ if (!journal.committedHead && head && backend.sameRevision(head.revision, journal.expectedHead.revision)) {
561
+ await validate();
562
+ const local = await createSnapshot(config.snapshotIdentity, snapshotOptions);
563
+ if (
564
+ !sameHashes(fileHashMap(local), fileHashMap(journal.before)) ||
565
+ syncStateFingerprint(await readStateForConfig(config)) !== journal.stateIdentity
566
+ ) {
567
+ throw new Error(
568
+ "Uncertain publication is not active, but local content or baseline changed; keep the journal for reviewed recovery.",
569
+ );
570
+ }
571
+ await clearMergeJournal(config);
572
+ return false;
573
+ }
574
+ await validate();
575
+ // Baseline publication is the durable completion boundary. A later remote revision
576
+ // cannot undo that acceptance; retire only the journal for the recorded commit.
577
+ if (journal.committedHead && (!head || !backend.sameRevision(head.revision, journal.committedHead.revision))) {
578
+ const state = await readStateForConfig(config);
579
+ await validate();
580
+ if (
581
+ matchesAcceptedState(state, config, journal.committedHead, journal.accepted ?? journal.after, journal.progress)
582
+ ) {
583
+ await clearMergeJournal(config);
584
+ await validate();
585
+ await pruneMergeBaselines(config, state, captureMutationOwner(ctx, signal));
586
+ await validate();
587
+ return true;
588
+ }
589
+ }
590
+ if (journal.applyOnly && (!head || !backend.sameRevision(head.revision, journal.expectedHead.revision))) {
591
+ const local = await createSnapshot(config.snapshotIdentity, snapshotOptions);
592
+ const state = await readStateForConfig(config);
593
+ await validate();
594
+ if (
595
+ sameHashes(fileHashMap(local), fileHashMap(journal.before)) &&
596
+ syncStateFingerprint(state) === journal.stateIdentity
597
+ ) {
598
+ await clearMergeJournal(config);
599
+ return false;
600
+ }
601
+ throw new Error("Apply-only remote head advanced after local changes; preserve the journal for reviewed recovery.");
602
+ }
603
+ if (
604
+ !head ||
605
+ head.snapshotId !== journal.upload.id ||
606
+ (journal.committedHead && !backend.sameRevision(head.revision, journal.committedHead.revision))
607
+ ) {
608
+ throw new Error(
609
+ "Publication outcome cannot be reconciled with the current remote head. Journal and backup retained; do not retry publication or restore old bytes blindly.",
610
+ );
611
+ }
612
+ const observed = await readSnapshotForHead(backend, head, signal);
613
+ requireCompatibleRemoteSelection(config, observed);
614
+ if (
615
+ !sameHashes(fileHashMap(observed), fileHashMap(journal.upload)) ||
616
+ JSON.stringify(observed.selection) !== JSON.stringify(journal.upload.selection)
617
+ )
618
+ throw new Error("Remote snapshot does not match the recorded merge publication.");
619
+ const state = await readStateForConfig(config);
620
+ if (matchesAcceptedState(state, config, head, journal.accepted ?? journal.after, journal.progress)) {
621
+ await validate();
622
+ await clearMergeJournal(config);
623
+ await validate();
624
+ await pruneMergeBaselines(config, state, captureMutationOwner(ctx, signal));
625
+ await validate();
626
+ return true;
627
+ }
628
+ if (syncStateFingerprint(state) !== journal.stateIdentity) {
629
+ throw new Error("Sync baseline changed after the interrupted merge; preserve its journal and review recovery.");
630
+ }
631
+ if (inspectRemoteSelection(config.include, observed).kind !== "same")
632
+ throw new Error(
633
+ "Pending merge has unavailable remote selection metadata; preserve its journal and choose a reviewed explicit direction.",
634
+ );
635
+ if (config.include.includes("sessions")) requireStableMergeSessionRoot(journal.before, journal.after);
636
+ await validate();
637
+ const validateOwner = captureMutationOwner(ctx, signal);
638
+ const validateMutation = () => {
639
+ validateOwner();
640
+ if (auto && !ctx.isIdle()) throw new Error("Automatic file apply is no longer idle; evidence retained.");
641
+ };
642
+ await applyMergedSnapshot(
643
+ journal.before,
644
+ journal.after,
645
+ protectedSessionPaths(ctx, sessionRoot),
646
+ { ...snapshotOptions, validateMutation },
647
+ validate,
648
+ ctx.sessionManager.getSessionFile?.(),
649
+ );
650
+ await validate();
651
+ const headAtAcceptance = await backend.readHead(signal);
652
+ await validate();
653
+ if (!headAtAcceptance || !backend.sameRevision(headAtAcceptance.revision, head.revision))
654
+ throw new Error("Remote changed during merged apply; journal and backup retained for review.");
655
+ await writeAcceptedState(config, head, journal.accepted ?? journal.after, validate, journal.progress);
656
+ await validate();
657
+ await clearMergeJournal(config);
658
+ await validate();
659
+ await pruneMergeBaselines(
660
+ config,
661
+ acceptedState(config, head, journal.accepted ?? journal.after, journal.progress),
662
+ captureMutationOwner(ctx, signal),
663
+ );
664
+ await validate();
665
+ await pruneCompletedConflicts(
666
+ config,
667
+ backend.identity,
668
+ acceptedState(config, head, journal.accepted ?? journal.after, journal.progress),
669
+ journal.accepted ?? journal.after,
670
+ journal.upload,
671
+ captureMutationOwner(ctx, signal),
672
+ );
673
+ await validate();
674
+ return true;
675
+ }
676
+
677
+ // Older valid journals may have accepted noncanonical JSON before projection normalization.
678
+ function matchesAcceptedState(
679
+ state: Awaited<ReturnType<typeof readStateForConfig>>,
680
+ config: AnySyncConfig,
681
+ head: RemoteHead,
682
+ snapshot: Snapshot,
683
+ progress?: PartialProgress,
684
+ ) {
685
+ return [true, false].some(
686
+ (canonical) =>
687
+ syncStateFingerprint(state) === syncStateFingerprint(acceptedState(config, head, snapshot, progress, canonical)),
688
+ );
689
+ }
690
+
691
+ function acceptedState(
692
+ config: AnySyncConfig,
693
+ head: RemoteHead,
694
+ snapshot: Snapshot,
695
+ progress?: PartialProgress,
696
+ canonical = true,
697
+ ) {
698
+ if (progress) return progressState(config, head, snapshot, progress);
699
+ return {
700
+ version: 1,
701
+ profile: config.snapshotIdentity,
702
+ lastAppliedSnapshot: head.snapshotId,
703
+ lastRemoteRevision: head.revision,
704
+ lastFileHashes: fileHashMap(canonical ? portableSnapshot(snapshot, config.localFields) : snapshot),
705
+ include: [...config.include],
706
+ ...(config.localFields !== undefined ? { localFields: config.localFields } : {}),
707
+ };
708
+ }
709
+
710
+ async function writeAcceptedState(
711
+ config: AnySyncConfig,
712
+ head: RemoteHead,
713
+ snapshot: Snapshot,
714
+ validate: () => Promise<void>,
715
+ progress?: PartialProgress,
716
+ ) {
717
+ const accepted = portableSnapshot(snapshot, config.localFields);
718
+ const state = acceptedState(config, head, accepted, progress);
719
+ await stageMergeBaseline(config, accepted, state, progress?.previous);
720
+ await validate();
721
+ await writeStateForConfig(config, state);
722
+ await validate();
723
+ }