@narumitw/pi-sync 0.51.1 → 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.
- package/README.md +30 -5
- package/dist/chunks/{chunk-7KHKWL3Q.ts → chunk-2CBF6SMT.ts} +8 -7
- package/dist/chunks/chunk-2CBF6SMT.ts.map +7 -0
- package/dist/chunks/{chunk-YFADF5ZQ.ts → chunk-2DI6PDZV.ts} +5 -1
- package/dist/chunks/chunk-2DI6PDZV.ts.map +7 -0
- package/dist/chunks/chunk-2GUXI2XT.ts +1702 -0
- package/dist/chunks/chunk-2GUXI2XT.ts.map +7 -0
- package/dist/chunks/{chunk-4MPIH46N.ts → chunk-3B4Z2SKV.ts} +40 -6
- package/dist/chunks/chunk-3B4Z2SKV.ts.map +7 -0
- package/dist/chunks/{chunk-YPXGU5OR.ts → chunk-4QVPTIZB.ts} +16 -6
- package/dist/chunks/{chunk-YPXGU5OR.ts.map → chunk-4QVPTIZB.ts.map} +2 -2
- package/dist/chunks/{chunk-UA7RXOIT.ts → chunk-4SBTAVFL.ts} +6 -6
- package/dist/chunks/{chunk-6546TS54.ts → chunk-5RZPINUQ.ts} +7 -4
- package/dist/chunks/{chunk-6546TS54.ts.map → chunk-5RZPINUQ.ts.map} +2 -2
- package/dist/chunks/chunk-76NB4R7U.ts +107 -0
- package/dist/chunks/chunk-76NB4R7U.ts.map +7 -0
- package/dist/chunks/{chunk-DM4X4FIA.ts → chunk-7D6RDCVC.ts} +8 -10
- package/dist/chunks/{chunk-DM4X4FIA.ts.map → chunk-7D6RDCVC.ts.map} +2 -2
- package/dist/chunks/chunk-A6HU4ERM.ts +124 -0
- package/dist/chunks/chunk-A6HU4ERM.ts.map +7 -0
- package/dist/chunks/chunk-AMJRUXFT.ts +17 -0
- package/dist/chunks/chunk-AMJRUXFT.ts.map +7 -0
- package/dist/chunks/chunk-ANWNQIR7.ts +1013 -0
- package/dist/chunks/chunk-ANWNQIR7.ts.map +7 -0
- package/dist/chunks/{chunk-T56RNRZJ.ts → chunk-BDB4IDM3.ts} +19 -10
- package/dist/chunks/chunk-BDB4IDM3.ts.map +7 -0
- package/dist/chunks/{chunk-2RZSQWBH.ts → chunk-CHSIAC4N.ts} +5 -5
- package/dist/chunks/{chunk-2RZSQWBH.ts.map → chunk-CHSIAC4N.ts.map} +2 -2
- package/dist/chunks/chunk-HUZ5CR4W.ts +110 -0
- package/dist/chunks/chunk-HUZ5CR4W.ts.map +7 -0
- package/dist/chunks/{chunk-QLSN7AGX.ts → chunk-IJ6VV6FN.ts} +81 -19
- package/dist/chunks/chunk-IJ6VV6FN.ts.map +7 -0
- package/dist/chunks/chunk-ORQRHEEO.ts +362 -0
- package/dist/chunks/chunk-ORQRHEEO.ts.map +7 -0
- package/dist/chunks/chunk-QE7NZZWT.ts +380 -0
- package/dist/chunks/chunk-QE7NZZWT.ts.map +7 -0
- package/dist/chunks/{chunk-HGJH56KR.ts → chunk-QHSQCK7L.ts} +4 -4
- package/dist/chunks/chunk-QOFMXPM5.ts +735 -0
- package/dist/chunks/chunk-QOFMXPM5.ts.map +7 -0
- package/dist/chunks/{chunk-DZUSTGQV.ts → chunk-UQ3ALBIB.ts} +4 -4
- package/dist/chunks/{chunk-Q32CQWPM.ts → chunk-V6VZ5TLG.ts} +13 -6
- package/dist/chunks/chunk-V6VZ5TLG.ts.map +7 -0
- package/dist/chunks/{chunk-E42GXD6D.ts → chunk-WKPX2SIC.ts} +4 -4
- package/dist/chunks/chunk-ZW5ZLGBI.ts +154 -0
- package/dist/chunks/chunk-ZW5ZLGBI.ts.map +7 -0
- package/dist/chunks/{chunk-6NT3YFGY.ts → chunk-ZX2Q6THB.ts} +8 -3
- package/dist/chunks/{chunk-6NT3YFGY.ts.map → chunk-ZX2Q6THB.ts.map} +2 -2
- package/dist/chunks/conflict-artifacts-BB2FZMIE.ts +27 -0
- package/dist/chunks/conflict-review-T3PB7ZSD.ts +175 -0
- package/dist/chunks/conflict-review-T3PB7ZSD.ts.map +7 -0
- package/dist/chunks/{file-selection-RAVHRYFG.ts → file-selection-WZX7HAY2.ts} +8 -7
- package/dist/chunks/{file-selection-RAVHRYFG.ts.map → file-selection-WZX7HAY2.ts.map} +1 -1
- package/dist/chunks/{git-backend-IAANDL3K.ts → git-backend-HLGKUQJT.ts} +19 -7
- package/dist/chunks/git-backend-HLGKUQJT.ts.map +7 -0
- package/dist/chunks/local-fields-6DGYOOIK.ts +22 -0
- package/dist/chunks/manager-result-dispatcher-VIDT3QBU.ts +23 -0
- package/dist/chunks/{manager-ui-763NEKJY.ts → manager-ui-HSMHS3OK.ts} +236 -57
- package/dist/chunks/manager-ui-HSMHS3OK.ts.map +7 -0
- package/dist/chunks/merge-baseline-store-EVQAAML6.ts +22 -0
- package/dist/chunks/remote-selection-ui-SMF5CLFO.ts +22 -0
- package/dist/chunks/{s3-backend-XGXUOPUN.ts → s3-backend-DJ2NT3SZ.ts} +16 -9
- package/dist/chunks/s3-backend-DJ2NT3SZ.ts.map +7 -0
- package/dist/chunks/{setup-switch-5G2VNJHL.ts → setup-switch-KTUWL6DE.ts} +9 -6
- package/dist/chunks/{setup-wizard-YIEWGX32.ts → setup-wizard-GSDPDFC4.ts} +10 -7
- package/dist/chunks/{setup-wizard-YIEWGX32.ts.map → setup-wizard-GSDPDFC4.ts.map} +1 -1
- package/dist/chunks/{snapshot-JL5DFSXI.ts → snapshot-THS5EY2L.ts} +6 -9
- package/dist/chunks/snapshot-THS5EY2L.ts.map +7 -0
- package/dist/chunks/{sync-inspection-P3ENJLNF.ts → sync-inspection-VBJF2FWC.ts} +10 -10
- package/dist/chunks/sync-inspection-VBJF2FWC.ts.map +7 -0
- package/dist/chunks/{sync-operations-AR76EB6C.ts → sync-operations-27VQUDH2.ts} +309 -508
- package/dist/chunks/sync-operations-27VQUDH2.ts.map +7 -0
- package/dist/chunks/{sync-state-VHJUZ4YG.ts → sync-state-CFL4RRRB.ts} +6 -6
- package/dist/chunks/sync-state-CFL4RRRB.ts.map +7 -0
- package/dist/chunks/{webdav-backend-3EGUONAT.ts → webdav-backend-ETQGSI6I.ts} +13 -7
- package/dist/chunks/webdav-backend-ETQGSI6I.ts.map +7 -0
- package/dist/index.ts +142 -43
- package/dist/index.ts.map +2 -2
- package/docs/content-merge-audit.md +156 -0
- package/docs/merge-implementation-audit.md +312 -0
- package/docs/settings-merge-audit.md +153 -0
- package/docs/settings.md +36 -0
- package/package.json +5 -5
- package/src/backends/git/git-backend.ts +2 -0
- package/src/backends/git/git-storage.ts +8 -3
- package/src/backends/s3/s3-backend.ts +2 -1
- package/src/backends/webdav/webdav-backend.ts +2 -1
- package/src/commands/command-execution.ts +10 -0
- package/src/commands/command.ts +4 -0
- package/src/settings/config.ts +12 -0
- package/src/settings/settings-management.ts +2 -2
- package/src/settings/settings-types.ts +14 -1
- package/src/settings/settings-validation.ts +24 -3
- package/src/snapshot/session-paths.ts +34 -7
- package/src/snapshot/snapshot-apply.ts +81 -32
- package/src/snapshot/snapshot-case-replacement.ts +80 -0
- package/src/snapshot/snapshot-codec.ts +6 -1
- package/src/snapshot/snapshot-root-transition.ts +154 -0
- package/src/snapshot/snapshot-transaction-plan.ts +72 -0
- package/src/snapshot/snapshot-transaction.ts +844 -102
- package/src/snapshot/snapshot-types.ts +5 -0
- package/src/state/json-file.ts +32 -5
- package/src/state/merge-baseline-store.ts +182 -0
- package/src/state/mutation-directory-sync.ts +34 -0
- package/src/state/state-types.ts +4 -0
- package/src/state/sync-state-store.ts +14 -1
- package/src/sync/automatic-sync.ts +11 -3
- package/src/sync/conflict-artifact-ownership.ts +63 -0
- package/src/sync/conflict-artifacts.ts +195 -0
- package/src/sync/conflict-groups.ts +68 -0
- package/src/sync/conflict-preview.ts +56 -0
- package/src/sync/conflict-resolution.ts +93 -0
- package/src/sync/conflict-retention.ts +72 -0
- package/src/sync/conflict-review.ts +95 -0
- package/src/sync/content-conflicts.ts +73 -0
- package/src/sync/field-migration.ts +39 -0
- package/src/sync/file-merge-planner.ts +143 -0
- package/src/sync/json-document.ts +184 -0
- package/src/sync/local-fields.ts +134 -0
- package/src/sync/merge-apply.ts +380 -0
- package/src/sync/merge-journal.ts +219 -0
- package/src/sync/merged-sync.ts +723 -0
- package/src/sync/partial-progress.ts +54 -0
- package/src/sync/remote-snapshot.ts +6 -0
- package/src/sync/session-merge.ts +194 -0
- package/src/sync/settings-conflicts.ts +52 -0
- package/src/sync/settings-merge.ts +57 -0
- package/src/sync/startup-check.ts +90 -5
- package/src/sync/sync-inspection.ts +1 -0
- package/src/sync/sync-local.ts +61 -0
- package/src/sync/sync-mutations.ts +256 -114
- package/src/sync/sync-queries.ts +6 -2
- package/src/sync/sync-state.ts +14 -5
- package/src/sync/text-merge.ts +133 -0
- package/src/sync-extension.ts +8 -0
- package/src/ui/manager-state.ts +12 -1
- package/src/ui/manager-ui.ts +10 -0
- package/src/ui/merge-review.ts +28 -0
- package/src/ui/remote-selection-ui.ts +1 -0
- package/src/ui/settings-ui.ts +183 -2
- package/src/ui/setup/setup-actions.ts +2 -2
- package/src/ui/setup/setup-switcher.ts +2 -1
- package/src/ui/storage-connections-ui.ts +11 -7
- package/src/ui/sync-attention.ts +3 -0
- package/src/ui/sync-format.ts +16 -12
- package/src/ui/sync-setups-ui.ts +1 -0
- package/src/ui/terminal-text.ts +9 -2
- package/dist/chunks/chunk-4AIHXRKE.ts +0 -197
- package/dist/chunks/chunk-4AIHXRKE.ts.map +0 -7
- package/dist/chunks/chunk-4MPIH46N.ts.map +0 -7
- package/dist/chunks/chunk-7KHKWL3Q.ts.map +0 -7
- package/dist/chunks/chunk-EEJPYTWE.ts +0 -348
- package/dist/chunks/chunk-EEJPYTWE.ts.map +0 -7
- package/dist/chunks/chunk-IVXEDPAD.ts +0 -370
- package/dist/chunks/chunk-IVXEDPAD.ts.map +0 -7
- package/dist/chunks/chunk-MSSW6F3R.ts +0 -12
- package/dist/chunks/chunk-MSSW6F3R.ts.map +0 -7
- package/dist/chunks/chunk-OVDTF5CH.ts +0 -90
- package/dist/chunks/chunk-OVDTF5CH.ts.map +0 -7
- package/dist/chunks/chunk-Q32CQWPM.ts.map +0 -7
- package/dist/chunks/chunk-QLSN7AGX.ts.map +0 -7
- package/dist/chunks/chunk-SWUXA3MH.ts +0 -18
- package/dist/chunks/chunk-SWUXA3MH.ts.map +0 -7
- package/dist/chunks/chunk-T56RNRZJ.ts.map +0 -7
- package/dist/chunks/chunk-YFADF5ZQ.ts.map +0 -7
- package/dist/chunks/git-backend-IAANDL3K.ts.map +0 -7
- package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +0 -23
- package/dist/chunks/manager-ui-763NEKJY.ts.map +0 -7
- package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +0 -22
- package/dist/chunks/s3-backend-XGXUOPUN.ts.map +0 -7
- package/dist/chunks/sync-operations-AR76EB6C.ts.map +0 -7
- package/dist/chunks/webdav-backend-3EGUONAT.ts.map +0 -7
- /package/dist/chunks/{chunk-UA7RXOIT.ts.map → chunk-4SBTAVFL.ts.map} +0 -0
- /package/dist/chunks/{chunk-HGJH56KR.ts.map → chunk-QHSQCK7L.ts.map} +0 -0
- /package/dist/chunks/{chunk-DZUSTGQV.ts.map → chunk-UQ3ALBIB.ts.map} +0 -0
- /package/dist/chunks/{chunk-E42GXD6D.ts.map → chunk-WKPX2SIC.ts.map} +0 -0
- /package/dist/chunks/{manager-result-dispatcher-OTUMQM26.ts.map → conflict-artifacts-BB2FZMIE.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-TZTJF32Y.ts.map → local-fields-6DGYOOIK.ts.map} +0 -0
- /package/dist/chunks/{setup-switch-5G2VNJHL.ts.map → manager-result-dispatcher-VIDT3QBU.ts.map} +0 -0
- /package/dist/chunks/{snapshot-JL5DFSXI.ts.map → merge-baseline-store-EVQAAML6.ts.map} +0 -0
- /package/dist/chunks/{sync-inspection-P3ENJLNF.ts.map → remote-selection-ui-SMF5CLFO.ts.map} +0 -0
- /package/dist/chunks/{sync-state-VHJUZ4YG.ts.map → setup-switch-KTUWL6DE.ts.map} +0 -0
|
@@ -0,0 +1,1702 @@
|
|
|
1
|
+
// @generated by scripts/build-runtime.mjs; do not edit.
|
|
2
|
+
// @ts-nocheck -- generated JavaScript uses a .ts extension for Pi's Jiti loader.
|
|
3
|
+
import {
|
|
4
|
+
clearMergeJournal,
|
|
5
|
+
conflictArtifactFingerprint,
|
|
6
|
+
conflictDirectory,
|
|
7
|
+
conflictGroups,
|
|
8
|
+
mergeJournalIdentity,
|
|
9
|
+
readConflictArtifact,
|
|
10
|
+
readMergeJournal,
|
|
11
|
+
saveConflictArtifact,
|
|
12
|
+
writeMergeJournal
|
|
13
|
+
} from "./chunk-ORQRHEEO.ts";
|
|
14
|
+
import {
|
|
15
|
+
fileHashMap,
|
|
16
|
+
hasLocalChanges,
|
|
17
|
+
hasRemoteChanges,
|
|
18
|
+
sameHashes,
|
|
19
|
+
syncPolicyChanged
|
|
20
|
+
} from "./chunk-BDB4IDM3.ts";
|
|
21
|
+
import {
|
|
22
|
+
formatRemoteSelectionStatus,
|
|
23
|
+
readSnapshotForHead,
|
|
24
|
+
requireCompatibleRemoteSelection
|
|
25
|
+
} from "./chunk-V6VZ5TLG.ts";
|
|
26
|
+
import {
|
|
27
|
+
createSyncBackend
|
|
28
|
+
} from "./chunk-QHSQCK7L.ts";
|
|
29
|
+
import {
|
|
30
|
+
applySnapshotTransaction,
|
|
31
|
+
recoverPendingSnapshotTransactions,
|
|
32
|
+
syncMutationParents
|
|
33
|
+
} from "./chunk-ANWNQIR7.ts";
|
|
34
|
+
import {
|
|
35
|
+
backupLocal,
|
|
36
|
+
captureMutationOwner,
|
|
37
|
+
protectedSessionPaths
|
|
38
|
+
} from "./chunk-IJ6VV6FN.ts";
|
|
39
|
+
import {
|
|
40
|
+
safeTerminalText,
|
|
41
|
+
snapshotPathLabel
|
|
42
|
+
} from "./chunk-AMJRUXFT.ts";
|
|
43
|
+
import {
|
|
44
|
+
TEXT_MERGE_WORK_LIMIT,
|
|
45
|
+
isMergeTextPath,
|
|
46
|
+
mergeSession,
|
|
47
|
+
mergeText,
|
|
48
|
+
pruneMergeBaselines,
|
|
49
|
+
readMergeAncestor,
|
|
50
|
+
readMergeAncestors,
|
|
51
|
+
stageMergeBaseline
|
|
52
|
+
} from "./chunk-QE7NZZWT.ts";
|
|
53
|
+
import {
|
|
54
|
+
mergePathIdentity,
|
|
55
|
+
planFileMerge
|
|
56
|
+
} from "./chunk-76NB4R7U.ts";
|
|
57
|
+
import {
|
|
58
|
+
readStateForConfig,
|
|
59
|
+
statePathForConfig,
|
|
60
|
+
syncStateFingerprint,
|
|
61
|
+
writeStateForConfig
|
|
62
|
+
} from "./chunk-4QVPTIZB.ts";
|
|
63
|
+
import {
|
|
64
|
+
loadConfig,
|
|
65
|
+
syncCheckConfigFingerprint,
|
|
66
|
+
syncConfigReviewIdentity
|
|
67
|
+
} from "./chunk-3B4Z2SKV.ts";
|
|
68
|
+
import {
|
|
69
|
+
SyncBackendConflictError,
|
|
70
|
+
expectedRemoteHead
|
|
71
|
+
} from "./chunk-UETBKTDI.ts";
|
|
72
|
+
import {
|
|
73
|
+
SyncDecisionRequiredError
|
|
74
|
+
} from "./chunk-7D5SF7P3.ts";
|
|
75
|
+
import {
|
|
76
|
+
overlayLocalFields,
|
|
77
|
+
portableSnapshot,
|
|
78
|
+
sameLocalFields
|
|
79
|
+
} from "./chunk-A6HU4ERM.ts";
|
|
80
|
+
import {
|
|
81
|
+
agentDir,
|
|
82
|
+
createSnapshot,
|
|
83
|
+
effectiveSessionRoot,
|
|
84
|
+
filterSnapshotForConfigPolicy,
|
|
85
|
+
includeFromSelectionConfig,
|
|
86
|
+
inspectRemoteSelection,
|
|
87
|
+
isSessionFilePath,
|
|
88
|
+
isSessionPath,
|
|
89
|
+
mergeRemotePreservedFiles,
|
|
90
|
+
regenerateSnapshotIdentity,
|
|
91
|
+
requireStableMergeSessionRoot,
|
|
92
|
+
scanSnapshot,
|
|
93
|
+
sessionStorageRoot,
|
|
94
|
+
snapshotIncludesSessions,
|
|
95
|
+
snapshotOptionsForContext,
|
|
96
|
+
snapshotTarget
|
|
97
|
+
} from "./chunk-QOFMXPM5.ts";
|
|
98
|
+
import {
|
|
99
|
+
jsonEqual,
|
|
100
|
+
parseSettingsDocument,
|
|
101
|
+
renderSettingsMembers
|
|
102
|
+
} from "./chunk-ZW5ZLGBI.ts";
|
|
103
|
+
import {
|
|
104
|
+
assertWithinRoot,
|
|
105
|
+
isDeniedPath,
|
|
106
|
+
isPathInside,
|
|
107
|
+
parentPaths,
|
|
108
|
+
safeJoin,
|
|
109
|
+
syncDirectory,
|
|
110
|
+
toPosix
|
|
111
|
+
} from "./chunk-HUZ5CR4W.ts";
|
|
112
|
+
|
|
113
|
+
// src/ui/merge-review.ts
|
|
114
|
+
import { runDocumentReview } from "@narumitw/pi-tui-kit";
|
|
115
|
+
async function confirmMergeReview(ctx, title, content, signal, isCurrent, confirmationLabel = "Apply merged transfer") {
|
|
116
|
+
const result = await runDocumentReview(ctx, {
|
|
117
|
+
title,
|
|
118
|
+
content,
|
|
119
|
+
format: { kind: "text" },
|
|
120
|
+
viewportSize: "adaptive",
|
|
121
|
+
confirmation: { label: confirmationLabel },
|
|
122
|
+
hint: "close",
|
|
123
|
+
signal,
|
|
124
|
+
isCurrent,
|
|
125
|
+
onError: () => {
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
if (result.kind === "error") throw new Error("Merge review failed; no transfer was performed.");
|
|
129
|
+
if (result.kind === "unsupported")
|
|
130
|
+
throw new Error("Merge review requires observable TUI or RPC; review the plan before using --yes.");
|
|
131
|
+
return result.kind === "confirmed";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/ui/sync-format.ts
|
|
135
|
+
function formatDiff(local, remote) {
|
|
136
|
+
const localMap = fileHashMap(local);
|
|
137
|
+
const remoteMap = fileHashMap(remote);
|
|
138
|
+
const allPaths = [.../* @__PURE__ */ new Set([...Object.keys(localMap), ...Object.keys(remoteMap)])].sort();
|
|
139
|
+
const lines = [`local: ${local.files.length} files`, `remote: ${remote.id} (${remote.files.length} files)`, ""];
|
|
140
|
+
let changed = 0;
|
|
141
|
+
for (const filePath of allPaths) {
|
|
142
|
+
if (!Object.hasOwn(localMap, filePath)) {
|
|
143
|
+
lines.push(`Remote only: ${snapshotPathLabel(filePath)}`);
|
|
144
|
+
changed += 1;
|
|
145
|
+
} else if (!Object.hasOwn(remoteMap, filePath)) {
|
|
146
|
+
lines.push(`Local only: ${snapshotPathLabel(filePath)}`);
|
|
147
|
+
changed += 1;
|
|
148
|
+
} else if (localMap[filePath] !== remoteMap[filePath]) {
|
|
149
|
+
lines.push(`Different: ${snapshotPathLabel(filePath)}`);
|
|
150
|
+
changed += 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (changed === 0) lines.push("No file differences.");
|
|
154
|
+
return lines.join("\n");
|
|
155
|
+
}
|
|
156
|
+
function formatSnapshotOnlyDiff(title, snapshot) {
|
|
157
|
+
return [`${title}: ${snapshot.id}`, ...snapshot.files.map((file) => `Add: ${snapshotPathLabel(file.path)}`)].join(
|
|
158
|
+
"\n"
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
function formatPushSummary(config, destination, upload, head, preservedRemoteFileCount = 0, remote) {
|
|
162
|
+
return [
|
|
163
|
+
`Sync setup: ${safeTerminalText(config.setupName)}`,
|
|
164
|
+
`Storage location: ${safeTerminalText(destination)}`,
|
|
165
|
+
`Upload ${upload.files.length} files from ${safeTerminalText(agentDir())}.`,
|
|
166
|
+
`Sessions: ${upload.syncSessions ? "included \u2014 may contain private conversations" : "not included"}`,
|
|
167
|
+
head ? `Remote latest: ${head.snapshotId}` : "Remote latest: empty",
|
|
168
|
+
"Publication effect: the backend's active head will reference the new immutable snapshot.",
|
|
169
|
+
...remote && inspectRemoteSelection(config.include, remote).kind === "different" ? ["Included-content policy effect: replace the differing remote selection with this setup's local selection."] : [],
|
|
170
|
+
formatPublicationPreview(remote, upload),
|
|
171
|
+
preservedRemoteFileCount > 0 ? `Possible secrets in locally managed files were scanned before this prompt; ${preservedRemoteFileCount} preserved remote file(s) were not rescanned.` : "Possible secrets were scanned before this prompt."
|
|
172
|
+
].join("\n");
|
|
173
|
+
}
|
|
174
|
+
function formatApplyPreview(local, remote) {
|
|
175
|
+
return formatDirectionalChanges(local, remote, {
|
|
176
|
+
add: "Add locally",
|
|
177
|
+
update: "Update locally",
|
|
178
|
+
remove: "Remove locally"
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
function formatPullSummary(config, destination, local, remote, protectedSessionCount) {
|
|
182
|
+
return [
|
|
183
|
+
`Sync setup: ${safeTerminalText(config.setupName)}`,
|
|
184
|
+
`Storage location: ${safeTerminalText(destination)}`,
|
|
185
|
+
`Snapshot: ${safeTerminalText(remote.id)}`,
|
|
186
|
+
`Sessions: ${remote.syncSessions ? "included \u2014 may contain private conversations" : "not included"}`,
|
|
187
|
+
`Protected live sessions: ${protectedSessionCount || "none"}`,
|
|
188
|
+
formatApplyPreview(local, remote),
|
|
189
|
+
"A local backup is created before these writes/deletes. The remote active snapshot is unchanged."
|
|
190
|
+
].join("\n");
|
|
191
|
+
}
|
|
192
|
+
function formatRollbackSummary(config, destination, local, remote, requestedSnapshot, protectedSessionCount) {
|
|
193
|
+
return [
|
|
194
|
+
`Sync setup: ${safeTerminalText(config.setupName)}`,
|
|
195
|
+
`Storage location: ${safeTerminalText(destination)}`,
|
|
196
|
+
`Snapshot: ${safeTerminalText(requestedSnapshot)}`,
|
|
197
|
+
`Sessions: ${remote.syncSessions ? "included \u2014 may contain private conversations" : "not included"}`,
|
|
198
|
+
`Protected live sessions: ${protectedSessionCount || "none"}`,
|
|
199
|
+
formatApplyPreview(local, remote),
|
|
200
|
+
"A local backup is created before applying; the backend's active head will change."
|
|
201
|
+
].join("\n");
|
|
202
|
+
}
|
|
203
|
+
function formatPublicationPreview(remote, upload) {
|
|
204
|
+
if (!remote) {
|
|
205
|
+
return ["Remote is empty.", ...upload.files.map((file) => `Add remotely: ${snapshotPathLabel(file.path)}`)].join(
|
|
206
|
+
"\n"
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return formatDirectionalChanges(remote, upload, {
|
|
210
|
+
add: "Add remotely",
|
|
211
|
+
update: "Update remotely",
|
|
212
|
+
remove: "Remove remotely"
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function formatDirectionalChanges(before, after, labels) {
|
|
216
|
+
const beforeMap = fileHashMap(before);
|
|
217
|
+
const afterMap = fileHashMap(after);
|
|
218
|
+
const paths = [.../* @__PURE__ */ new Set([...Object.keys(beforeMap), ...Object.keys(afterMap)])].sort();
|
|
219
|
+
const lines = [];
|
|
220
|
+
for (const filePath of paths) {
|
|
221
|
+
if (!Object.hasOwn(beforeMap, filePath)) lines.push(`${labels.add}: ${snapshotPathLabel(filePath)}`);
|
|
222
|
+
else if (!Object.hasOwn(afterMap, filePath)) lines.push(`${labels.remove}: ${snapshotPathLabel(filePath)}`);
|
|
223
|
+
else if (beforeMap[filePath] !== afterMap[filePath]) lines.push(`${labels.update}: ${snapshotPathLabel(filePath)}`);
|
|
224
|
+
}
|
|
225
|
+
if (lines.length === 0) lines.push("No file changes.");
|
|
226
|
+
return lines.join("\n");
|
|
227
|
+
}
|
|
228
|
+
function countPreservedRemoteFiles(local, upload) {
|
|
229
|
+
const localPaths = new Set(local.files.map((file) => file.path));
|
|
230
|
+
return upload.files.filter((file) => !localPaths.has(file.path)).length;
|
|
231
|
+
}
|
|
232
|
+
function publicationCapabilityDescription(capability) {
|
|
233
|
+
switch (capability) {
|
|
234
|
+
case "lease-protected":
|
|
235
|
+
return "lease-protected (exact expected-ref update)";
|
|
236
|
+
case "atomic-conditional":
|
|
237
|
+
return "atomic-conditional (verified atomic precondition)";
|
|
238
|
+
case "conditional-required":
|
|
239
|
+
return "conditional-required (read-only until atomic preconditions are verified)";
|
|
240
|
+
case "read-check-write-verify":
|
|
241
|
+
return "read-check-write-verify (visible races rejected; simultaneous unseen races remain possible)";
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// src/sync/conflict-artifact-ownership.ts
|
|
246
|
+
import fs from "node:fs/promises";
|
|
247
|
+
import path from "node:path";
|
|
248
|
+
async function releaseConflictArtifacts(config, statePath, created) {
|
|
249
|
+
if (!created.length || statePathForConfig(config) !== statePath) return;
|
|
250
|
+
try {
|
|
251
|
+
const state = await readStateForConfig(config);
|
|
252
|
+
if (statePathForConfig(config) !== statePath) return;
|
|
253
|
+
const journal = await readMergeJournal(config);
|
|
254
|
+
if (statePathForConfig(config) !== statePath) return;
|
|
255
|
+
const referenced = /* @__PURE__ */ new Set([
|
|
256
|
+
...(state.unresolved ?? []).map((group) => group.artifact),
|
|
257
|
+
...(journal?.progress?.groups ?? []).map((group) => group.artifact)
|
|
258
|
+
]);
|
|
259
|
+
for (const lease of created) {
|
|
260
|
+
if (referenced.has(lease.token) || lease.target !== path.join(conflictDirectory(config), `${lease.token}.json`))
|
|
261
|
+
continue;
|
|
262
|
+
try {
|
|
263
|
+
const stat = await fs.lstat(lease.target);
|
|
264
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.dev !== lease.dev || stat.ino !== lease.ino || stat.size !== lease.size)
|
|
265
|
+
continue;
|
|
266
|
+
const artifact = JSON.parse(await fs.readFile(lease.target, "utf8"));
|
|
267
|
+
if (conflictArtifactFingerprint(artifact) !== lease.fingerprint || statePathForConfig(config) !== statePath)
|
|
268
|
+
continue;
|
|
269
|
+
const latest = await fs.lstat(lease.target);
|
|
270
|
+
if (latest.dev !== lease.dev || latest.ino !== lease.ino || latest.nlink !== 1 || statePathForConfig(config) !== statePath)
|
|
271
|
+
continue;
|
|
272
|
+
await fs.unlink(lease.target);
|
|
273
|
+
await syncDirectory(path.dirname(lease.target));
|
|
274
|
+
} catch {
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// src/sync/conflict-resolution.ts
|
|
282
|
+
async function resolveReviewedGroup(config, backend, state, local, remote, head, plan, resolution, protectedPaths) {
|
|
283
|
+
const artifact = await readConflictArtifact(config, backend.identity, resolution.token);
|
|
284
|
+
const group = artifact.groups[resolution.group];
|
|
285
|
+
if (conflictArtifactFingerprint(artifact) !== resolution.artifactIdentity || !group || plan.kind !== "planned" || !state.unresolved?.some(
|
|
286
|
+
(item) => item.artifact === resolution.token && JSON.stringify(item.paths) === JSON.stringify(group.paths)
|
|
287
|
+
) || syncStateFingerprint(state) !== resolution.stateIdentity || state.lastObservedSnapshot !== head.snapshotId || !state.lastObservedRevision || !backend.sameRevision(state.lastObservedRevision, head.revision))
|
|
288
|
+
throw new Error("Conflict review is stale; refresh sync and review again.");
|
|
289
|
+
const ours = fileHashMap(local);
|
|
290
|
+
const theirs = fileHashMap(remote);
|
|
291
|
+
const oldOurs = fileHashMap(artifact.local);
|
|
292
|
+
const oldTheirs = fileHashMap(artifact.remote);
|
|
293
|
+
if (group.paths.some(
|
|
294
|
+
(filePath) => ours[filePath] !== oldOurs[filePath] || theirs[filePath] !== oldTheirs[filePath] || state.lastFileHashes[filePath] !== artifact.state.lastFileHashes[filePath]
|
|
295
|
+
))
|
|
296
|
+
throw new Error("Conflict versions changed; no resolution was applied.");
|
|
297
|
+
if (group.paths.some(
|
|
298
|
+
(filePath) => [...protectedPaths].some((value) => mergePathIdentity(value) === mergePathIdentity(filePath))
|
|
299
|
+
))
|
|
300
|
+
throw new Error(
|
|
301
|
+
"Current session conflict cannot be resolved from the loaded session; preserve evidence and close Pi."
|
|
302
|
+
);
|
|
303
|
+
const selected = resolution.source === "local" ? local : remote;
|
|
304
|
+
const paths = new Set(group.paths);
|
|
305
|
+
for (const snapshot of [selected, local]) {
|
|
306
|
+
const layout = planFileMerge({
|
|
307
|
+
baseline: {},
|
|
308
|
+
local: snapshot.files.filter((file) => paths.has(file.path)),
|
|
309
|
+
remote: [],
|
|
310
|
+
selectionCompatible: true
|
|
311
|
+
});
|
|
312
|
+
if (layout.kind !== "planned" || layout.conflicts.length)
|
|
313
|
+
throw new Error(
|
|
314
|
+
"Selected/current local group still has path collisions; preserve evidence, repair the layout manually and refresh sync."
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
const selectedFiles = new Map(selected.files.map((file) => [file.path, file]));
|
|
318
|
+
const decisions = plan.decisions.map(
|
|
319
|
+
(item) => paths.has(item.path) ? {
|
|
320
|
+
kind: "accepted",
|
|
321
|
+
path: item.path,
|
|
322
|
+
source: resolution.source,
|
|
323
|
+
file: selectedFiles.get(item.path)
|
|
324
|
+
} : item
|
|
325
|
+
);
|
|
326
|
+
return {
|
|
327
|
+
kind: "planned",
|
|
328
|
+
decisions,
|
|
329
|
+
conflicts: decisions.filter((item) => item.kind === "conflict")
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// src/sync/conflict-retention.ts
|
|
334
|
+
import fs2 from "node:fs/promises";
|
|
335
|
+
import path2 from "node:path";
|
|
336
|
+
async function pruneCompletedConflicts(config, backend, state, local, remote, validate) {
|
|
337
|
+
validate();
|
|
338
|
+
const directory = conflictDirectory(config);
|
|
339
|
+
let names;
|
|
340
|
+
try {
|
|
341
|
+
const stat = await fs2.lstat(directory);
|
|
342
|
+
validate();
|
|
343
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Unsafe conflict retention directory.");
|
|
344
|
+
names = await fs2.readdir(directory);
|
|
345
|
+
validate();
|
|
346
|
+
} catch (error) {
|
|
347
|
+
if (error.code === "ENOENT") return;
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
const pinned = new Set(state.unresolved?.map((group) => group.artifact) ?? []);
|
|
351
|
+
const ours = fileHashMap(local);
|
|
352
|
+
const theirs = fileHashMap(remote);
|
|
353
|
+
const completed = [];
|
|
354
|
+
for (const name of names) {
|
|
355
|
+
validate();
|
|
356
|
+
const token = name.replace(/\.json$/u, "");
|
|
357
|
+
if (!/^[a-f0-9-]{36}\.json$/u.test(name) || pinned.has(token)) continue;
|
|
358
|
+
try {
|
|
359
|
+
const artifact = await readConflictArtifact(config, backend, token);
|
|
360
|
+
validate();
|
|
361
|
+
const originalLocal = fileHashMap(artifact.local);
|
|
362
|
+
const originalRemote = fileHashMap(artifact.remote);
|
|
363
|
+
if (!artifact.groups.every(
|
|
364
|
+
(group) => group.paths.every(
|
|
365
|
+
(filePath) => state.lastFileHashes[filePath] === ours[filePath] && ours[filePath] === theirs[filePath] && (ours[filePath] === originalLocal[filePath] || ours[filePath] === originalRemote[filePath])
|
|
366
|
+
)
|
|
367
|
+
))
|
|
368
|
+
continue;
|
|
369
|
+
const stat = await fs2.lstat(path2.join(directory, name));
|
|
370
|
+
validate();
|
|
371
|
+
if (stat.isFile()) completed.push({ token, modified: stat.mtimeMs });
|
|
372
|
+
} catch {
|
|
373
|
+
validate();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
completed.sort((left, right) => right.modified - left.modified || left.token.localeCompare(right.token));
|
|
377
|
+
for (const entry of completed.slice(32)) {
|
|
378
|
+
validate();
|
|
379
|
+
await fs2.rm(path2.join(directory, `${entry.token}.json`));
|
|
380
|
+
validate();
|
|
381
|
+
}
|
|
382
|
+
if (completed.length > 32) {
|
|
383
|
+
await syncDirectory(directory);
|
|
384
|
+
validate();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// src/sync/content-conflicts.ts
|
|
389
|
+
import { createHash } from "node:crypto";
|
|
390
|
+
function snapshotFile(filePath, bytes) {
|
|
391
|
+
return {
|
|
392
|
+
path: filePath,
|
|
393
|
+
contentBase64: bytes.toString("base64"),
|
|
394
|
+
sha256: createHash("sha256").update(bytes).digest("hex")
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
async function resolveContentConflicts(config, state, local, remote, plan, protectedPaths, options = {}) {
|
|
398
|
+
if (plan.kind !== "planned" || !config.mergeContent) return plan;
|
|
399
|
+
options.signal?.throwIfAborted();
|
|
400
|
+
const budget = options.textBudget ?? { remaining: TEXT_MERGE_WORK_LIMIT };
|
|
401
|
+
const decisions = [...plan.decisions];
|
|
402
|
+
let ancestors = [];
|
|
403
|
+
try {
|
|
404
|
+
ancestors = await readMergeAncestors(config, state) ?? [];
|
|
405
|
+
} catch {
|
|
406
|
+
options.signal?.throwIfAborted();
|
|
407
|
+
return plan;
|
|
408
|
+
}
|
|
409
|
+
options.signal?.throwIfAborted();
|
|
410
|
+
const oursByPath = new Map(local.files.map((file) => [file.path, file]));
|
|
411
|
+
const theirsByPath = new Map(remote.files.map((file) => [file.path, file]));
|
|
412
|
+
const ancestorsByPath = new Map(ancestors.map((file) => [file.path, file]));
|
|
413
|
+
const decisionIndex = new Map(decisions.map((decision, index) => [decision.path, index]));
|
|
414
|
+
const protectedKeys = new Set([...protectedPaths].map(mergePathIdentity));
|
|
415
|
+
for (const conflict of plan.conflicts) {
|
|
416
|
+
options.signal?.throwIfAborted();
|
|
417
|
+
if (conflict.reason !== "both-changed" || protectedKeys.has(mergePathIdentity(conflict.path))) continue;
|
|
418
|
+
const session = conflict.path.startsWith("sessions/") && conflict.path.endsWith(".jsonl");
|
|
419
|
+
if (!session && (!isMergeTextPath(conflict.path) || budget.remaining === 0)) continue;
|
|
420
|
+
const ours = oursByPath.get(conflict.path);
|
|
421
|
+
const theirs = theirsByPath.get(conflict.path);
|
|
422
|
+
if (!ours || !theirs) continue;
|
|
423
|
+
const ancestor = ancestorsByPath.get(conflict.path);
|
|
424
|
+
const base = ancestor ? Buffer.from(ancestor.contentBase64, "base64") : void 0;
|
|
425
|
+
if (!base) continue;
|
|
426
|
+
const localBytes = Buffer.from(ours.contentBase64, "base64");
|
|
427
|
+
const remoteBytes = Buffer.from(theirs.contentBase64, "base64");
|
|
428
|
+
const bytes = session ? mergeSession(base, localBytes, remoteBytes) : mergeText(base, localBytes, remoteBytes, { budget, signal: options.signal });
|
|
429
|
+
if (!bytes) continue;
|
|
430
|
+
const index = decisionIndex.get(conflict.path);
|
|
431
|
+
if (index === void 0) continue;
|
|
432
|
+
decisions[index] = {
|
|
433
|
+
kind: "accepted",
|
|
434
|
+
path: conflict.path,
|
|
435
|
+
source: "merged",
|
|
436
|
+
file: snapshotFile(conflict.path, bytes)
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
kind: "planned",
|
|
441
|
+
decisions,
|
|
442
|
+
conflicts: decisions.filter((item) => item.kind === "conflict")
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// src/sync/merge-apply.ts
|
|
447
|
+
import { createHash as createHash3, randomUUID } from "node:crypto";
|
|
448
|
+
import fs4 from "node:fs/promises";
|
|
449
|
+
import path4 from "node:path";
|
|
450
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
451
|
+
|
|
452
|
+
// src/snapshot/snapshot-apply.ts
|
|
453
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
454
|
+
import fs3 from "node:fs/promises";
|
|
455
|
+
import path3 from "node:path";
|
|
456
|
+
function sha256(value) {
|
|
457
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
458
|
+
}
|
|
459
|
+
function fileHashMap2(snapshot) {
|
|
460
|
+
return Object.fromEntries(snapshot.files.map((file) => [file.path, file.sha256]));
|
|
461
|
+
}
|
|
462
|
+
async function applySnapshot(snapshot, protectedRelativePaths = /* @__PURE__ */ new Set(), options = {}) {
|
|
463
|
+
const root = agentDir();
|
|
464
|
+
const { sessionDir } = options;
|
|
465
|
+
const transactionOptions = {
|
|
466
|
+
sessionDir,
|
|
467
|
+
signal: options.signal,
|
|
468
|
+
validateMutation: options.validateMutation,
|
|
469
|
+
protectedTargets: [...protectedRelativePaths].map((relative) => snapshotTarget(root, relative, sessionDir)),
|
|
470
|
+
expectedPreimages: new Map(
|
|
471
|
+
Object.entries(options.expectedFileHashes ?? {}).map(([relative, hash]) => [
|
|
472
|
+
snapshotTarget(root, relative, sessionDir),
|
|
473
|
+
hash === null ? "missing" : `file:${hash}`
|
|
474
|
+
])
|
|
475
|
+
)
|
|
476
|
+
};
|
|
477
|
+
options.validateMutation?.();
|
|
478
|
+
await recoverPendingSnapshotTransactions(transactionOptions);
|
|
479
|
+
const current = await createSnapshot(snapshot.profile, {
|
|
480
|
+
...options,
|
|
481
|
+
...options.include === void 0 ? { syncSessions: snapshotIncludesSessions(snapshot) } : {},
|
|
482
|
+
sessionDir
|
|
483
|
+
});
|
|
484
|
+
const plan = await addTopLevelCaseVariantDeletes(
|
|
485
|
+
root,
|
|
486
|
+
protectSnapshotApplyPlan(
|
|
487
|
+
root,
|
|
488
|
+
preflightSnapshotApply(root, snapshot, current, { sessionDir }),
|
|
489
|
+
protectedRelativePaths,
|
|
490
|
+
sessionDir
|
|
491
|
+
),
|
|
492
|
+
snapshot
|
|
493
|
+
);
|
|
494
|
+
await preflightSnapshotMutations(root, plan, sessionDir, options);
|
|
495
|
+
await applySnapshotTransaction(plan, transactionOptions);
|
|
496
|
+
return appliedFileHashMap(snapshot, current, protectedRelativePaths);
|
|
497
|
+
}
|
|
498
|
+
function preflightSnapshotApply(root, snapshot, current, options = {}) {
|
|
499
|
+
const seenPaths = /* @__PURE__ */ new Set();
|
|
500
|
+
const remotePaths = /* @__PURE__ */ new Set();
|
|
501
|
+
const writes = [];
|
|
502
|
+
const deletes = [];
|
|
503
|
+
for (const file of snapshot.files) {
|
|
504
|
+
const normalized = toPosix(file.path);
|
|
505
|
+
if (!isSafeSnapshotPath(file.path)) {
|
|
506
|
+
throw new Error(`Unsafe path in snapshot: ${file.path}`);
|
|
507
|
+
}
|
|
508
|
+
if (isSessionPath(normalized) && !isSessionFilePath(normalized)) {
|
|
509
|
+
throw new Error(`Unsafe session path in snapshot: ${file.path}`);
|
|
510
|
+
}
|
|
511
|
+
if (seenPaths.has(normalized)) throw new Error(`Duplicate path in snapshot: ${normalized}`);
|
|
512
|
+
seenPaths.add(normalized);
|
|
513
|
+
remotePaths.add(normalized);
|
|
514
|
+
const target = snapshotTarget(root, normalized, options.sessionDir);
|
|
515
|
+
const content = decodeBase64Strict(file.contentBase64, normalized);
|
|
516
|
+
if (sha256(content) !== file.sha256) throw new Error(`Checksum mismatch in snapshot file: ${normalized}`);
|
|
517
|
+
writes.push({ target, content });
|
|
518
|
+
}
|
|
519
|
+
const deletePaths = /* @__PURE__ */ new Set();
|
|
520
|
+
for (const file of current.files) {
|
|
521
|
+
const normalized = toPosix(file.path);
|
|
522
|
+
if (!remotePaths.has(normalized)) {
|
|
523
|
+
deletePaths.add(snapshotTarget(root, normalized, options.sessionDir));
|
|
524
|
+
}
|
|
525
|
+
for (const remotePath of parentPaths(normalized)) {
|
|
526
|
+
if (remotePaths.has(remotePath)) {
|
|
527
|
+
deletePaths.add(snapshotTarget(root, remotePath, options.sessionDir));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
deletes.push(...deletePaths);
|
|
532
|
+
return { writes, deletes };
|
|
533
|
+
}
|
|
534
|
+
function protectSnapshotApplyPlan(root, plan, protectedRelativePaths, sessionDir) {
|
|
535
|
+
if (protectedRelativePaths.size === 0) return plan;
|
|
536
|
+
const protectedTargets = new Set(
|
|
537
|
+
[...protectedRelativePaths].map((relativePath) => snapshotTarget(root, relativePath, sessionDir))
|
|
538
|
+
);
|
|
539
|
+
return {
|
|
540
|
+
writes: plan.writes.filter((item) => !protectedTargets.has(item.target)),
|
|
541
|
+
deletes: plan.deletes.filter((target) => !protectedTargets.has(target))
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
async function addTopLevelCaseVariantDeletes(root, plan, snapshot) {
|
|
545
|
+
const topLevelPaths = /* @__PURE__ */ new Map();
|
|
546
|
+
for (const file of snapshot.files) {
|
|
547
|
+
const normalized = toPosix(file.path);
|
|
548
|
+
if (!normalized.includes("/") && isSafeSnapshotPath(file.path)) {
|
|
549
|
+
topLevelPaths.set(normalized.toLowerCase(), normalized);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (topLevelPaths.size === 0) return plan;
|
|
553
|
+
let entries;
|
|
554
|
+
try {
|
|
555
|
+
entries = await fs3.readdir(root, { withFileTypes: true });
|
|
556
|
+
} catch (error) {
|
|
557
|
+
if (error.code === "ENOENT") return plan;
|
|
558
|
+
throw error;
|
|
559
|
+
}
|
|
560
|
+
const deletes = new Set(plan.deletes);
|
|
561
|
+
for (const entry of entries) {
|
|
562
|
+
const canonicalPath = topLevelPaths.get(entry.name.toLowerCase());
|
|
563
|
+
if (canonicalPath && entry.name !== canonicalPath && (entry.isFile() || entry.isSymbolicLink())) {
|
|
564
|
+
deletes.add(safeJoin(root, entry.name));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return { ...plan, deletes: [...deletes] };
|
|
568
|
+
}
|
|
569
|
+
function appliedFileHashMap(snapshot, current, protectedRelativePaths) {
|
|
570
|
+
const hashes = fileHashMap2(snapshot);
|
|
571
|
+
if (protectedRelativePaths.size === 0) return hashes;
|
|
572
|
+
const currentHashes = fileHashMap2(current);
|
|
573
|
+
for (const relativePath of protectedRelativePaths) {
|
|
574
|
+
const normalized = toPosix(relativePath);
|
|
575
|
+
if (currentHashes[normalized]) {
|
|
576
|
+
hashes[normalized] = currentHashes[normalized];
|
|
577
|
+
} else {
|
|
578
|
+
delete hashes[normalized];
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return hashes;
|
|
582
|
+
}
|
|
583
|
+
function isSafeSnapshotPath(relativePath) {
|
|
584
|
+
if (relativePath.includes("\\")) return false;
|
|
585
|
+
const normalized = toPosix(relativePath);
|
|
586
|
+
return Boolean(normalized) && normalized !== "." && normalized !== ".." && !normalized.startsWith("../") && !path3.posix.isAbsolute(normalized) && path3.posix.normalize(normalized) === normalized && !isDeniedPath(normalized);
|
|
587
|
+
}
|
|
588
|
+
function decodeBase64Strict(value, filePath) {
|
|
589
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
|
|
590
|
+
throw new Error(`Invalid base64 content in snapshot file: ${filePath}`);
|
|
591
|
+
}
|
|
592
|
+
return Buffer.from(value, "base64");
|
|
593
|
+
}
|
|
594
|
+
async function preflightSnapshotMutations(root, plan, sessionDir, options = {}) {
|
|
595
|
+
assertMutationCurrent(options);
|
|
596
|
+
const deletePaths = new Set(plan.deletes);
|
|
597
|
+
for (const target of plan.deletes) {
|
|
598
|
+
await assertNoSymlinkParents(rootForTarget(root, target, sessionDir), target, options);
|
|
599
|
+
assertMutationCurrent(options);
|
|
600
|
+
}
|
|
601
|
+
for (const item of plan.writes) {
|
|
602
|
+
await prepareSnapshotWrite(rootForTarget(root, item.target, sessionDir), item.target, deletePaths, options);
|
|
603
|
+
assertMutationCurrent(options);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
function assertMutationCurrent(options) {
|
|
607
|
+
options.validateMutation?.();
|
|
608
|
+
options.signal?.throwIfAborted();
|
|
609
|
+
}
|
|
610
|
+
async function guardedStat(target, options) {
|
|
611
|
+
assertMutationCurrent(options);
|
|
612
|
+
let stat;
|
|
613
|
+
try {
|
|
614
|
+
stat = await fs3.lstat(target);
|
|
615
|
+
} catch (error) {
|
|
616
|
+
assertMutationCurrent(options);
|
|
617
|
+
if (error.code === "ENOENT") return void 0;
|
|
618
|
+
throw error;
|
|
619
|
+
}
|
|
620
|
+
assertMutationCurrent(options);
|
|
621
|
+
return stat;
|
|
622
|
+
}
|
|
623
|
+
function rootForTarget(root, target, sessionDir) {
|
|
624
|
+
const sessionRoot = sessionDir ? sessionStorageRoot(root, sessionDir) : void 0;
|
|
625
|
+
if (sessionRoot && isPathInside(sessionRoot, target)) return sessionRoot;
|
|
626
|
+
return root;
|
|
627
|
+
}
|
|
628
|
+
async function prepareSnapshotWrite(root, target, deletePaths, options) {
|
|
629
|
+
const parentWillBeReplaced = await ensureSafeDirectory(root, path3.dirname(target), deletePaths, options);
|
|
630
|
+
assertMutationCurrent(options);
|
|
631
|
+
if (parentWillBeReplaced) return;
|
|
632
|
+
const stat = await guardedStat(target, options);
|
|
633
|
+
assertMutationCurrent(options);
|
|
634
|
+
if (stat?.isSymbolicLink()) throw new Error(`Refusing to overwrite symlink during snapshot apply: ${target}`);
|
|
635
|
+
if (stat?.isDirectory() && !deletePaths.has(target)) {
|
|
636
|
+
throw new Error(`Refusing to overwrite directory during snapshot apply: ${target}`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
async function ensureSafeDirectory(root, directory, deletePaths, options) {
|
|
640
|
+
assertMutationCurrent(options);
|
|
641
|
+
assertWithinRoot(root, directory);
|
|
642
|
+
const rootPath = path3.resolve(root);
|
|
643
|
+
const relative = path3.relative(rootPath, path3.resolve(directory));
|
|
644
|
+
let current = rootPath;
|
|
645
|
+
for (const part of relative.split(path3.sep).filter(Boolean)) {
|
|
646
|
+
current = path3.join(current, part);
|
|
647
|
+
const stat = await guardedStat(current, options);
|
|
648
|
+
assertMutationCurrent(options);
|
|
649
|
+
if (stat) {
|
|
650
|
+
if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
|
|
651
|
+
if (!stat.isDirectory()) {
|
|
652
|
+
if (deletePaths.has(current)) return true;
|
|
653
|
+
throw new Error(`Snapshot path parent is not a directory: ${current}`);
|
|
654
|
+
}
|
|
655
|
+
} else {
|
|
656
|
+
await fs3.mkdir(current);
|
|
657
|
+
assertMutationCurrent(options);
|
|
658
|
+
await syncDirectory(path3.dirname(current));
|
|
659
|
+
assertMutationCurrent(options);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
async function assertNoSymlinkParents(root, target, options) {
|
|
665
|
+
assertMutationCurrent(options);
|
|
666
|
+
assertWithinRoot(root, target);
|
|
667
|
+
const rootPath = path3.resolve(root);
|
|
668
|
+
const relative = path3.relative(rootPath, path3.resolve(target));
|
|
669
|
+
let current = rootPath;
|
|
670
|
+
const parts = relative.split(path3.sep).filter(Boolean);
|
|
671
|
+
for (const part of parts.slice(0, -1)) {
|
|
672
|
+
current = path3.join(current, part);
|
|
673
|
+
const stat = await guardedStat(current, options);
|
|
674
|
+
assertMutationCurrent(options);
|
|
675
|
+
if (!stat) return;
|
|
676
|
+
if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
|
|
677
|
+
if (!stat.isDirectory()) throw new Error(`Snapshot path parent is not a directory: ${current}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// src/sync/merge-apply.ts
|
|
682
|
+
async function preflightMergedTargets(before, after, options, protectedTarget) {
|
|
683
|
+
const root = agentDir();
|
|
684
|
+
const beforeHashes = Object.assign(/* @__PURE__ */ Object.create(null), fileHashMap(before));
|
|
685
|
+
const afterHashes = Object.assign(/* @__PURE__ */ Object.create(null), fileHashMap(after));
|
|
686
|
+
const paths = [.../* @__PURE__ */ new Set([...Object.keys(beforeHashes), ...Object.keys(afterHashes)])].sort();
|
|
687
|
+
const changed = new Set(paths.filter((item) => beforeHashes[item] !== afterHashes[item]));
|
|
688
|
+
await assertCanonicalMergedTargets(root, changed, options);
|
|
689
|
+
await assertDistinctMergedTargets(root, paths, changed, options, protectedTarget, new Set(Object.keys(afterHashes)));
|
|
690
|
+
const removed = replacementPaths(
|
|
691
|
+
new Set(paths.filter((item) => changed.has(item) && !afterHashes[item])),
|
|
692
|
+
afterHashes
|
|
693
|
+
);
|
|
694
|
+
const removedTargets = new Set([...removed].map((item) => snapshotTarget(root, item, options.sessionDir)));
|
|
695
|
+
for (const relative of paths) {
|
|
696
|
+
if (changed.has(relative)) await assertFilesystemTarget(root, relative, options, removedTargets, beforeHashes);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
function replacementPaths(removed, afterHashes) {
|
|
700
|
+
for (const relative of [...removed]) {
|
|
701
|
+
let parent = path4.posix.dirname(relative);
|
|
702
|
+
while (parent !== ".") {
|
|
703
|
+
if (afterHashes[parent]) {
|
|
704
|
+
for (let directory = path4.posix.dirname(relative); directory !== parent; directory = path4.posix.dirname(directory))
|
|
705
|
+
removed.add(directory);
|
|
706
|
+
removed.add(parent);
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
709
|
+
parent = path4.posix.dirname(parent);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return removed;
|
|
713
|
+
}
|
|
714
|
+
async function assertCanonicalMergedTargets(root, changed, options) {
|
|
715
|
+
const topLevel = new Map(
|
|
716
|
+
[...changed].filter((relative) => !relative.includes("/")).map((relative) => [relative.toLowerCase(), relative])
|
|
717
|
+
);
|
|
718
|
+
if (topLevel.size === 0) return;
|
|
719
|
+
options.signal?.throwIfAborted();
|
|
720
|
+
options.validateMutation?.();
|
|
721
|
+
let names;
|
|
722
|
+
try {
|
|
723
|
+
names = await fs4.readdir(root);
|
|
724
|
+
} catch (error) {
|
|
725
|
+
options.signal?.throwIfAborted();
|
|
726
|
+
options.validateMutation?.();
|
|
727
|
+
if (error.code !== "ENOENT") throw error;
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
options.signal?.throwIfAborted();
|
|
731
|
+
options.validateMutation?.();
|
|
732
|
+
for (const name of names) {
|
|
733
|
+
const canonical = topLevel.get(name.toLowerCase());
|
|
734
|
+
if (canonical !== void 0 && name !== canonical)
|
|
735
|
+
throw new Error(
|
|
736
|
+
"Merged target has a noncanonical case variant; normalize selected top-level file spelling before retrying. Journal retained if already committed."
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
async function assertDistinctMergedTargets(root, paths, changed, options, protectedTarget, finalPaths = new Set(paths)) {
|
|
741
|
+
const protectedKey = protectedTarget ? mergePathIdentity(await resolvedTargetIdentity(path4.resolve(protectedTarget), options)) : void 0;
|
|
742
|
+
options.signal?.throwIfAborted();
|
|
743
|
+
options.validateMutation?.();
|
|
744
|
+
const keys = /* @__PURE__ */ new Map();
|
|
745
|
+
for (const relative of paths) {
|
|
746
|
+
options.signal?.throwIfAborted();
|
|
747
|
+
const target = snapshotTarget(root, relative, options.sessionDir);
|
|
748
|
+
const key = mergePathIdentity(await resolvedTargetIdentity(target, options));
|
|
749
|
+
options.signal?.throwIfAborted();
|
|
750
|
+
options.validateMutation?.();
|
|
751
|
+
const existing = keys.get(key);
|
|
752
|
+
if (existing && (mergePathIdentity(existing) !== mergePathIdentity(relative) || finalPaths.has(existing) && finalPaths.has(relative)))
|
|
753
|
+
throw new Error("Merged paths resolve to the same file; reviewed directional recovery is required.");
|
|
754
|
+
if (key === protectedKey && changed.has(relative))
|
|
755
|
+
throw new Error("A merged transfer targets the current session; review is required.");
|
|
756
|
+
keys.set(key, relative);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
async function assertReviewedTree(directory, relative, beforeHashes) {
|
|
760
|
+
for (const name of await fs4.readdir(directory)) {
|
|
761
|
+
const child = path4.join(directory, name);
|
|
762
|
+
const childRelative = `${relative}/${name}`;
|
|
763
|
+
const stat = await fs4.lstat(child);
|
|
764
|
+
if (stat.isDirectory()) {
|
|
765
|
+
await assertReviewedTree(child, childRelative, beforeHashes);
|
|
766
|
+
} else if (stat.isFile() && stat.nlink === 1 && beforeHashes[childRelative] === createHash3("sha256").update(await fs4.readFile(child)).digest("hex")) {
|
|
767
|
+
continue;
|
|
768
|
+
} else {
|
|
769
|
+
throw new Error("Reviewed directory contains unknown or changed files; publication refused.");
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
function hasBlockingFileAncestor(target, files) {
|
|
774
|
+
for (let parent = path4.dirname(target); parent !== path4.dirname(parent); parent = path4.dirname(parent))
|
|
775
|
+
if (files.has(parent)) return true;
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
async function resolvedTargetIdentity(target, options) {
|
|
779
|
+
let ancestor = target;
|
|
780
|
+
while (true) {
|
|
781
|
+
options.signal?.throwIfAborted();
|
|
782
|
+
try {
|
|
783
|
+
const resolved = await fs4.realpath(ancestor);
|
|
784
|
+
return path4.resolve(resolved, path4.relative(ancestor, target));
|
|
785
|
+
} catch (error) {
|
|
786
|
+
const code = error.code;
|
|
787
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
|
|
788
|
+
const parent = path4.dirname(ancestor);
|
|
789
|
+
if (parent === ancestor) return target;
|
|
790
|
+
ancestor = parent;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
async function assertFilesystemTarget(root, relative, options, removedTargets = /* @__PURE__ */ new Set(), beforeHashes) {
|
|
795
|
+
const target = snapshotTarget(root, relative, options.sessionDir);
|
|
796
|
+
const boundary = relative.startsWith("sessions/") && options.sessionDir ? path4.resolve(sessionStorageRoot(root, options.sessionDir)) : path4.resolve(root);
|
|
797
|
+
for (let parent = path4.dirname(target); mergePathIdentity(parent) !== mergePathIdentity(boundary); parent = path4.dirname(parent)) {
|
|
798
|
+
options.signal?.throwIfAborted();
|
|
799
|
+
if (path4.dirname(parent) === parent) throw new Error("Unowned merged target.");
|
|
800
|
+
try {
|
|
801
|
+
const stat = await fs4.lstat(parent);
|
|
802
|
+
if ((!stat.isDirectory() || stat.isSymbolicLink()) && !(stat.isFile() && stat.nlink === 1 && removedTargets.has(parent)))
|
|
803
|
+
throw new Error("Unsafe merge filesystem layout; review the selected paths before transfer.");
|
|
804
|
+
} catch (error) {
|
|
805
|
+
const code = error.code;
|
|
806
|
+
if (code !== "ENOENT") throw error;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
options.signal?.throwIfAborted();
|
|
810
|
+
try {
|
|
811
|
+
const stat = await fs4.lstat(target);
|
|
812
|
+
if (!stat.isFile() && !removedTargets.has(target) || stat.isFile() && stat.nlink > 1)
|
|
813
|
+
throw new Error("Merge target is non-regular or hard-linked; review a directional operation before transfer.");
|
|
814
|
+
if (stat.isDirectory() && beforeHashes) await assertReviewedTree(target, relative, beforeHashes);
|
|
815
|
+
} catch (error) {
|
|
816
|
+
const code = error.code;
|
|
817
|
+
if (code !== "ENOENT" && !(code === "ENOTDIR" && removedTargets.size > 0)) throw error;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
async function applyMergedSnapshot(before, after, protectedPaths, options, validate, protectedTarget) {
|
|
821
|
+
const root = agentDir();
|
|
822
|
+
const beforeHashes = Object.assign(/* @__PURE__ */ Object.create(null), fileHashMap(before));
|
|
823
|
+
const afterHashes = Object.assign(/* @__PURE__ */ Object.create(null), fileHashMap(after));
|
|
824
|
+
const paths = [.../* @__PURE__ */ new Set([...Object.keys(beforeHashes), ...Object.keys(afterHashes)])].sort();
|
|
825
|
+
const replacements = replacementPaths(
|
|
826
|
+
new Set(paths.filter((item) => beforeHashes[item] && !afterHashes[item])),
|
|
827
|
+
afterHashes
|
|
828
|
+
);
|
|
829
|
+
const intermediateDirectories = [...replacements].filter((item) => !beforeHashes[item] && !afterHashes[item]);
|
|
830
|
+
const changed = [
|
|
831
|
+
.../* @__PURE__ */ new Set([...paths.filter((item) => beforeHashes[item] !== afterHashes[item]), ...intermediateDirectories])
|
|
832
|
+
].sort();
|
|
833
|
+
const protectedKeys = new Set([...protectedPaths].map(mergePathIdentity));
|
|
834
|
+
if (changed.some((item) => protectedKeys.has(mergePathIdentity(item)))) {
|
|
835
|
+
throw new Error("A merged transfer targets the current session; review is required.");
|
|
836
|
+
}
|
|
837
|
+
const targets = changed.map((item) => snapshotTarget(root, item, options.sessionDir));
|
|
838
|
+
const relativeByTarget = new Map(targets.map((target, index) => [target, changed[index]]));
|
|
839
|
+
const targetSet = new Set(targets);
|
|
840
|
+
await assertDistinctMergedTargets(
|
|
841
|
+
root,
|
|
842
|
+
paths,
|
|
843
|
+
new Set(changed),
|
|
844
|
+
options,
|
|
845
|
+
protectedTarget,
|
|
846
|
+
new Set(Object.keys(afterHashes))
|
|
847
|
+
);
|
|
848
|
+
async function acquire(index) {
|
|
849
|
+
const target = targets[index];
|
|
850
|
+
if (target) return withFileMutationQueue(target, () => acquire(index + 1));
|
|
851
|
+
await validate();
|
|
852
|
+
await assertCanonicalMergedTargets(root, changed, options);
|
|
853
|
+
await validate();
|
|
854
|
+
const current = await createSnapshot(before.profile, options);
|
|
855
|
+
const currentHashes = Object.assign(/* @__PURE__ */ Object.create(null), fileHashMap(current));
|
|
856
|
+
const blockingFiles = new Set(
|
|
857
|
+
paths.filter(
|
|
858
|
+
(relative) => beforeHashes[relative] && !afterHashes[relative] || afterHashes[relative] && currentHashes[relative] === afterHashes[relative]
|
|
859
|
+
).map((relative) => snapshotTarget(root, relative, options.sessionDir))
|
|
860
|
+
);
|
|
861
|
+
for (const item of changed) {
|
|
862
|
+
if (currentHashes[item] !== beforeHashes[item] && currentHashes[item] !== afterHashes[item]) {
|
|
863
|
+
throw new Error(
|
|
864
|
+
"Local content changed during merged transfer; journal retained for review. No newer bytes were replaced."
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
const target2 = snapshotTarget(root, item, options.sessionDir);
|
|
868
|
+
try {
|
|
869
|
+
const stat = await fs4.lstat(target2);
|
|
870
|
+
if (!stat.isFile() && !stat.isDirectory() || stat.isFile() && stat.nlink > 1)
|
|
871
|
+
throw new Error("Merged transfer target is no longer an independent regular file.");
|
|
872
|
+
} catch (error) {
|
|
873
|
+
const code = error.code;
|
|
874
|
+
if (code !== "ENOENT" && !(code === "ENOTDIR" && hasBlockingFileAncestor(target2, blockingFiles))) throw error;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
const plan = preflightSnapshotApply(root, after, current, options);
|
|
878
|
+
plan.writes = plan.writes.filter((item) => targetSet.has(item.target));
|
|
879
|
+
plan.deletes = plan.deletes.filter((target2) => targetSet.has(target2));
|
|
880
|
+
await preflightSnapshotMutations(root, plan, options.sessionDir, options);
|
|
881
|
+
const deletedTargets = new Set(plan.deletes);
|
|
882
|
+
for (const relative of intermediateDirectories) {
|
|
883
|
+
for (let parent = path4.posix.dirname(relative); parent !== "."; parent = path4.posix.dirname(parent)) {
|
|
884
|
+
if (afterHashes[parent] && deletedTargets.has(snapshotTarget(root, parent, options.sessionDir))) {
|
|
885
|
+
plan.deletes.push(snapshotTarget(root, relative, options.sessionDir));
|
|
886
|
+
break;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
plan.deletes.sort(
|
|
891
|
+
(left, right) => right.split(path4.sep).length - left.split(path4.sep).length || left.localeCompare(right)
|
|
892
|
+
);
|
|
893
|
+
const removed = replacementPaths(
|
|
894
|
+
new Set(paths.filter((relative) => deletedTargets.has(snapshotTarget(root, relative, options.sessionDir)))),
|
|
895
|
+
afterHashes
|
|
896
|
+
);
|
|
897
|
+
const removedTargets = new Set([...removed].map((item) => snapshotTarget(root, item, options.sessionDir)));
|
|
898
|
+
await assertCanonicalMergedTargets(root, changed, options);
|
|
899
|
+
await validate();
|
|
900
|
+
const revalidateTarget = async (target2) => {
|
|
901
|
+
await validate();
|
|
902
|
+
const relative = relativeByTarget.get(target2);
|
|
903
|
+
if (!relative) throw new Error("Unowned merge target.");
|
|
904
|
+
await assertFilesystemTarget(root, relative, options, removedTargets, currentHashes);
|
|
905
|
+
await validate();
|
|
906
|
+
let hash;
|
|
907
|
+
try {
|
|
908
|
+
const stat = await fs4.lstat(target2);
|
|
909
|
+
if (stat.isDirectory() && removed.has(relative)) {
|
|
910
|
+
if ((await fs4.readdir(target2)).length)
|
|
911
|
+
throw new Error("Reviewed directory has remaining contents; journal retained for review.");
|
|
912
|
+
} else {
|
|
913
|
+
if (!stat.isFile() || stat.nlink > 1)
|
|
914
|
+
throw new Error("Merged transfer target is no longer an independent regular file.");
|
|
915
|
+
hash = createHash3("sha256").update(await fs4.readFile(target2)).digest("hex");
|
|
916
|
+
}
|
|
917
|
+
} catch (error) {
|
|
918
|
+
if (error.code !== "ENOENT") throw error;
|
|
919
|
+
}
|
|
920
|
+
options.signal?.throwIfAborted();
|
|
921
|
+
options.validateMutation?.();
|
|
922
|
+
if (hash !== beforeHashes[relative] && hash !== afterHashes[relative])
|
|
923
|
+
throw new Error("Local content changed at the apply boundary; journal retained, newer bytes untouched.");
|
|
924
|
+
};
|
|
925
|
+
for (const target2 of plan.deletes) {
|
|
926
|
+
await revalidateTarget(target2);
|
|
927
|
+
try {
|
|
928
|
+
const stat = await fs4.lstat(target2);
|
|
929
|
+
await validate();
|
|
930
|
+
if (stat.isDirectory())
|
|
931
|
+
await fs4.rmdir(target2);
|
|
932
|
+
else await fs4.rm(target2, { force: true });
|
|
933
|
+
} catch (error) {
|
|
934
|
+
if (error.code !== "ENOENT") throw error;
|
|
935
|
+
}
|
|
936
|
+
await syncDirectory(path4.dirname(target2));
|
|
937
|
+
}
|
|
938
|
+
for (const item of plan.writes) {
|
|
939
|
+
await validate();
|
|
940
|
+
const relative = relativeByTarget.get(item.target);
|
|
941
|
+
if (!relative) throw new Error("Unowned merge write target.");
|
|
942
|
+
await assertFilesystemTarget(root, relative, options, /* @__PURE__ */ new Set());
|
|
943
|
+
await fs4.mkdir(path4.dirname(item.target), { recursive: true });
|
|
944
|
+
const temporary = path4.join(path4.dirname(item.target), `.pi-sync.json.${randomUUID()}.apply`);
|
|
945
|
+
let mode = 384;
|
|
946
|
+
try {
|
|
947
|
+
mode = (await fs4.stat(item.target)).mode & 511;
|
|
948
|
+
} catch (error) {
|
|
949
|
+
if (error.code !== "ENOENT") throw error;
|
|
950
|
+
}
|
|
951
|
+
try {
|
|
952
|
+
const handle = await fs4.open(temporary, "wx", mode);
|
|
953
|
+
try {
|
|
954
|
+
await handle.writeFile(item.content);
|
|
955
|
+
await handle.sync();
|
|
956
|
+
} finally {
|
|
957
|
+
await handle.close();
|
|
958
|
+
}
|
|
959
|
+
await revalidateTarget(item.target);
|
|
960
|
+
await fs4.rename(temporary, item.target);
|
|
961
|
+
await syncDirectory(path4.dirname(item.target));
|
|
962
|
+
} finally {
|
|
963
|
+
await fs4.rm(temporary, { force: true });
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
await syncMutationParents(
|
|
967
|
+
targets,
|
|
968
|
+
[root, ...options.sessionDir ? [sessionStorageRoot(root, options.sessionDir)] : []],
|
|
969
|
+
options
|
|
970
|
+
);
|
|
971
|
+
await validate();
|
|
972
|
+
}
|
|
973
|
+
await acquire(0);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// src/sync/partial-progress.ts
|
|
977
|
+
function acceptedMergeFiles(decisions, localFiles, withheld) {
|
|
978
|
+
const localByPath = new Map(localFiles.map((file) => [file.path, file]));
|
|
979
|
+
return decisions.flatMap((decision) => {
|
|
980
|
+
const file = withheld.has(decision.path) ? localByPath.get(decision.path) : decision.kind === "accepted" ? decision.file : void 0;
|
|
981
|
+
return file ? [file] : [];
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
function progressState(config, head, snapshot, progress) {
|
|
985
|
+
const withheld = new Set(progress.groups.flatMap((group) => group.paths));
|
|
986
|
+
const current = fileHashMap(snapshot);
|
|
987
|
+
const hashes = Object.assign(/* @__PURE__ */ Object.create(null), progress.previous.lastFileHashes);
|
|
988
|
+
for (const key of /* @__PURE__ */ new Set([...Object.keys(hashes), ...Object.keys(current)])) {
|
|
989
|
+
if (withheld.has(key)) continue;
|
|
990
|
+
const value = current[key];
|
|
991
|
+
if (value === void 0) delete hashes[key];
|
|
992
|
+
else hashes[key] = value;
|
|
993
|
+
}
|
|
994
|
+
return {
|
|
995
|
+
...progress.previous,
|
|
996
|
+
version: 3,
|
|
997
|
+
profile: config.snapshotIdentity,
|
|
998
|
+
lastFileHashes: hashes,
|
|
999
|
+
include: [...config.include],
|
|
1000
|
+
localFields: config.localFields,
|
|
1001
|
+
lastObservedSnapshot: head.snapshotId,
|
|
1002
|
+
lastObservedRevision: head.revision,
|
|
1003
|
+
unresolved: progress.groups
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// src/sync/settings-conflicts.ts
|
|
1008
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
1009
|
+
|
|
1010
|
+
// src/sync/settings-merge.ts
|
|
1011
|
+
var COUPLED_FIELDS = [
|
|
1012
|
+
["defaultProvider", "defaultModel"],
|
|
1013
|
+
["queueMode", "steeringMode"],
|
|
1014
|
+
["websockets", "transport"],
|
|
1015
|
+
["skills", "enableSkillCommands"],
|
|
1016
|
+
["enableAnalytics", "trackingId"]
|
|
1017
|
+
];
|
|
1018
|
+
function mergeSettingsJson(ancestor, local, remote) {
|
|
1019
|
+
let documents;
|
|
1020
|
+
try {
|
|
1021
|
+
documents = [ancestor, local, remote].map(parseSettingsDocument);
|
|
1022
|
+
} catch {
|
|
1023
|
+
return { kind: "review", reason: "unsupported-format", fields: [] };
|
|
1024
|
+
}
|
|
1025
|
+
const [base, left, right] = documents;
|
|
1026
|
+
const selected = /* @__PURE__ */ new Map();
|
|
1027
|
+
const fields = new Set(documents.flatMap((document) => [...document.root.members.keys()]));
|
|
1028
|
+
const units = COUPLED_FIELDS.map((group) => [...group]);
|
|
1029
|
+
const coupled = new Set(units.flat());
|
|
1030
|
+
units.push(...[...fields].filter((field) => !coupled.has(field)).map((field) => [field]));
|
|
1031
|
+
const conflicts = [];
|
|
1032
|
+
for (const unit of units) {
|
|
1033
|
+
const equal = (first, second) => unit.every(
|
|
1034
|
+
(field) => jsonEqual(first.root.members.get(field)?.node.value, second.root.members.get(field)?.node.value)
|
|
1035
|
+
);
|
|
1036
|
+
const source = equal(left, right) ? left : equal(left, base) ? right : equal(right, base) ? left : void 0;
|
|
1037
|
+
if (!source) {
|
|
1038
|
+
conflicts.push(...unit.filter((field) => fields.has(field)));
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
for (const field of unit) {
|
|
1042
|
+
const member = source.root.members.get(field);
|
|
1043
|
+
if (member) selected.set(field, { document: source, node: member.node });
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
if (conflicts.length) return { kind: "review", reason: "field-conflict", fields: conflicts };
|
|
1047
|
+
try {
|
|
1048
|
+
return { kind: "merged", content: renderSettingsMembers(left, selected) };
|
|
1049
|
+
} catch {
|
|
1050
|
+
return { kind: "review", reason: "unsupported-format", fields: [] };
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// src/sync/settings-conflicts.ts
|
|
1055
|
+
async function resolveSettingsConflicts(config, state, local, remote, plan) {
|
|
1056
|
+
if (plan.kind !== "planned") return { plan, fields: [] };
|
|
1057
|
+
const fields = [];
|
|
1058
|
+
const decisions = [...plan.decisions];
|
|
1059
|
+
for (let index = 0; index < decisions.length; index++) {
|
|
1060
|
+
const decision = decisions[index];
|
|
1061
|
+
if (decision?.kind !== "conflict" || decision.reason !== "both-changed" || decision.path !== "settings.json")
|
|
1062
|
+
continue;
|
|
1063
|
+
const left = local.files.find((file) => file.path === decision.path);
|
|
1064
|
+
const right = remote.files.find((file) => file.path === decision.path);
|
|
1065
|
+
if (!left || !right) continue;
|
|
1066
|
+
const ancestor = await readMergeAncestor(config, state, decision.path);
|
|
1067
|
+
if (!ancestor) continue;
|
|
1068
|
+
const merged = mergeSettingsJson(
|
|
1069
|
+
ancestor,
|
|
1070
|
+
Buffer.from(left.contentBase64, "base64"),
|
|
1071
|
+
Buffer.from(right.contentBase64, "base64")
|
|
1072
|
+
);
|
|
1073
|
+
if (merged.kind === "review") {
|
|
1074
|
+
fields.push(...merged.fields);
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
decisions[index] = {
|
|
1078
|
+
kind: "accepted",
|
|
1079
|
+
path: decision.path,
|
|
1080
|
+
source: "merged",
|
|
1081
|
+
file: {
|
|
1082
|
+
path: decision.path,
|
|
1083
|
+
contentBase64: merged.content.toString("base64"),
|
|
1084
|
+
sha256: createHash4("sha256").update(merged.content).digest("hex")
|
|
1085
|
+
}
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
return {
|
|
1089
|
+
plan: { kind: "planned", decisions, conflicts: decisions.filter((decision) => decision.kind === "conflict") },
|
|
1090
|
+
fields
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// src/sync/sync-decision.ts
|
|
1095
|
+
function createSyncDecision(options) {
|
|
1096
|
+
const { config, state, local, remote, kind } = options;
|
|
1097
|
+
const policyChanged = Boolean(state.lastAppliedSnapshot) && syncPolicyChanged(state, config);
|
|
1098
|
+
const previousInclude = includeFromSelectionConfig(state);
|
|
1099
|
+
const currentInclude = [...config.include];
|
|
1100
|
+
const causes = {
|
|
1101
|
+
localChanged: options.localChanged,
|
|
1102
|
+
remoteChanged: options.remoteChanged,
|
|
1103
|
+
policyChanged
|
|
1104
|
+
};
|
|
1105
|
+
const causeLines = kind === "first-sync-settings-diverged" ? ["This machine and the remote have different Pi settings on first sync."] : kind === "first-sync-sessions-diverged" ? ["Pi settings match, but local and remote sessions differ on first sync."] : kind === "remote-empty" ? ["The remote storage location is empty."] : [
|
|
1106
|
+
...causes.localChanged ? ["Local content changed since the last sync."] : [],
|
|
1107
|
+
...causes.remoteChanged ? ["Remote content changed since the last sync."] : [],
|
|
1108
|
+
...causes.policyChanged ? ["Included content changed since the last sync."] : []
|
|
1109
|
+
];
|
|
1110
|
+
const comparison = remote ? formatDiff(local, remote) : formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local);
|
|
1111
|
+
const review = [
|
|
1112
|
+
`Sync setup: ${safeTerminalText(config.setupName)}`,
|
|
1113
|
+
"",
|
|
1114
|
+
"Why a decision is required:",
|
|
1115
|
+
...causeLines,
|
|
1116
|
+
...policyChanged ? ["", `Previously included: ${safeList(previousInclude)}`, `Currently included: ${safeList(currentInclude)}`] : [],
|
|
1117
|
+
"",
|
|
1118
|
+
"Observed differences:",
|
|
1119
|
+
...comparison.split("\n").map(safeTerminalText)
|
|
1120
|
+
].join("\n");
|
|
1121
|
+
return new SyncDecisionRequiredError({
|
|
1122
|
+
kind,
|
|
1123
|
+
setupName: config.setupName,
|
|
1124
|
+
configIdentity: syncConfigReviewIdentity(config),
|
|
1125
|
+
causes,
|
|
1126
|
+
...policyChanged ? { previousInclude: [...previousInclude] } : {},
|
|
1127
|
+
currentInclude,
|
|
1128
|
+
review,
|
|
1129
|
+
directions: kind === "remote-empty" ? ["push"] : ["push", "pull"],
|
|
1130
|
+
directMessage: options.directMessage
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
function safeList(values) {
|
|
1134
|
+
return values.length > 0 ? values.map(safeTerminalText).join(", ") : "none";
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// src/sync/merged-sync.ts
|
|
1138
|
+
var MAX_ATTEMPTS = 3;
|
|
1139
|
+
async function mergeSync(ctx, options, factory = createSyncBackend, resolution) {
|
|
1140
|
+
const validateOwner = captureMutationOwner(ctx, options.signal);
|
|
1141
|
+
const isCurrent = () => {
|
|
1142
|
+
try {
|
|
1143
|
+
validateOwner();
|
|
1144
|
+
return true;
|
|
1145
|
+
} catch {
|
|
1146
|
+
return false;
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
const config = await loadConfig(options.setup);
|
|
1150
|
+
const configToken = syncCheckConfigFingerprint(config);
|
|
1151
|
+
const sessionRoot = config.include.includes("sessions") ? await effectiveSessionRoot(ctx) : void 0;
|
|
1152
|
+
validateOwner();
|
|
1153
|
+
const snapshotOptions = {
|
|
1154
|
+
...snapshotOptionsForContext(ctx, config),
|
|
1155
|
+
sessionDir: sessionRoot,
|
|
1156
|
+
signal: options.signal
|
|
1157
|
+
};
|
|
1158
|
+
const validate = async () => {
|
|
1159
|
+
validateOwner();
|
|
1160
|
+
const latest = await loadConfig(options.setup);
|
|
1161
|
+
validateOwner();
|
|
1162
|
+
if (configToken !== syncCheckConfigFingerprint(latest))
|
|
1163
|
+
throw new Error("Sync setup or settings changed during transfer; reopen sync.");
|
|
1164
|
+
if (sessionRoot !== void 0) {
|
|
1165
|
+
const currentRoot = await effectiveSessionRoot(ctx);
|
|
1166
|
+
validateOwner();
|
|
1167
|
+
if (currentRoot !== sessionRoot)
|
|
1168
|
+
throw new Error("Session storage root changed during transfer; evidence retained for review.");
|
|
1169
|
+
}
|
|
1170
|
+
if (options.auto && (!latest.automaticTransfer || !ctx.isIdle()))
|
|
1171
|
+
throw new Error("Automatic transfer is no longer authorized at this idle boundary.");
|
|
1172
|
+
};
|
|
1173
|
+
await validate();
|
|
1174
|
+
const backend = await factory(config);
|
|
1175
|
+
await validate();
|
|
1176
|
+
if (options.auto && backend.capability === "read-check-write-verify") {
|
|
1177
|
+
throw new Error(
|
|
1178
|
+
"Automatic transfer requires conditional publication or a lease; this backend supports only read/check/write/verify. Use manual sync."
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
const pending = await readMergeJournal(config);
|
|
1182
|
+
await validate();
|
|
1183
|
+
if (pending) {
|
|
1184
|
+
const recovered = await completeJournal(ctx, config, backend, pending, validate, options.signal, options.auto);
|
|
1185
|
+
if (!recovered) {
|
|
1186
|
+
if ((options.auto || !options.silent) && !options.signal?.aborted)
|
|
1187
|
+
ctx.ui.notify(
|
|
1188
|
+
"Interrupted candidate is not active; local content and baseline were unchanged. Evidence was reconciled without retrying publication. Run sync again for a fresh plan.",
|
|
1189
|
+
"warning"
|
|
1190
|
+
);
|
|
1191
|
+
return "cancelled";
|
|
1192
|
+
}
|
|
1193
|
+
if ((options.auto || !options.silent) && !options.signal?.aborted)
|
|
1194
|
+
ctx.ui.notify("Recovered the committed merged transfer. Run sync again to inspect newer changes.", "info");
|
|
1195
|
+
return "applied";
|
|
1196
|
+
}
|
|
1197
|
+
const textBudget = { remaining: TEXT_MERGE_WORK_LIMIT };
|
|
1198
|
+
const createdArtifacts = [];
|
|
1199
|
+
const ownedStatePath = statePathForConfig(config);
|
|
1200
|
+
try {
|
|
1201
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
1202
|
+
await validate();
|
|
1203
|
+
const state = await readStateForConfig(config);
|
|
1204
|
+
if (state.unresolved?.length && !config.partialSync)
|
|
1205
|
+
throw new Error(
|
|
1206
|
+
"Partial progress remains unresolved; re-enable partial sync or review an explicit force direction."
|
|
1207
|
+
);
|
|
1208
|
+
const localRaw = await createSnapshot(config.snapshotIdentity, snapshotOptions);
|
|
1209
|
+
const local = portableSnapshot(localRaw, config.localFields);
|
|
1210
|
+
if (!sameLocalFields(state.localFields, config.localFields))
|
|
1211
|
+
throw new Error("Local-field rules changed; review and confirm a directional migration first.");
|
|
1212
|
+
const head = await backend.readHead(options.signal);
|
|
1213
|
+
await validate();
|
|
1214
|
+
const rawRemote = head ? await readSnapshotForHead(backend, head, options.signal) : void 0;
|
|
1215
|
+
await validate();
|
|
1216
|
+
if (rawRemote) {
|
|
1217
|
+
const files = [...local.files, ...rawRemote.files];
|
|
1218
|
+
if (files.length > 16384 || files.reduce((bytes, file) => bytes + file.contentBase64.length * 0.75, 0) > 64 * 1024 * 1024) {
|
|
1219
|
+
throw new Error(
|
|
1220
|
+
"Merged transfer exceeds the 64 MiB / 16,384 input-file bound; review an explicit push or pull instead."
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
const remote = rawRemote ? portableSnapshot(filterSnapshotForConfigPolicy(rawRemote, config), rawRemote.localFields) : void 0;
|
|
1225
|
+
if (rawRemote) {
|
|
1226
|
+
requireCompatibleRemoteSelection(config, rawRemote);
|
|
1227
|
+
const validation = planFileMerge({
|
|
1228
|
+
baseline: state.lastFileHashes,
|
|
1229
|
+
local: local.files,
|
|
1230
|
+
remote: rawRemote.files,
|
|
1231
|
+
selectionCompatible: true
|
|
1232
|
+
});
|
|
1233
|
+
const managedPaths = /* @__PURE__ */ new Set([
|
|
1234
|
+
...Object.keys(state.lastFileHashes),
|
|
1235
|
+
...[...local.files, ...remote?.files ?? []].map((file) => file.path)
|
|
1236
|
+
]);
|
|
1237
|
+
if (validation.kind === "planned" && validation.conflicts.some(
|
|
1238
|
+
(item) => item.reason === "path-collision" && (!config.partialSync || !managedPaths.has(item.path))
|
|
1239
|
+
))
|
|
1240
|
+
throw new Error("Remote snapshot contains path collisions; review a directional recovery before merging.");
|
|
1241
|
+
}
|
|
1242
|
+
const review = (message, kind = "both-changed") => createSyncDecision({
|
|
1243
|
+
kind,
|
|
1244
|
+
config,
|
|
1245
|
+
state,
|
|
1246
|
+
local,
|
|
1247
|
+
remote,
|
|
1248
|
+
localChanged: hasLocalChanges(local, state, config),
|
|
1249
|
+
remoteChanged: remote ? hasRemoteChanges(remote, state, config) : Boolean(state.lastAppliedSnapshot),
|
|
1250
|
+
directMessage: message
|
|
1251
|
+
});
|
|
1252
|
+
if (!state.lastAppliedSnapshot) throw review("Choose the initial source before enabling automatic transfer.");
|
|
1253
|
+
if (syncPolicyChanged(state, config))
|
|
1254
|
+
throw review("Included content changed; review the selection before merging.", "remote-or-policy-changed");
|
|
1255
|
+
if (!head || !remote || !rawRemote)
|
|
1256
|
+
throw review("The established remote is missing; choose an explicit recovery direction.", "remote-empty");
|
|
1257
|
+
const selectionState = inspectRemoteSelection(config.include, rawRemote);
|
|
1258
|
+
const selectionCompatible = selectionState.kind === "same";
|
|
1259
|
+
let plan = planFileMerge({
|
|
1260
|
+
baseline: state.lastFileHashes,
|
|
1261
|
+
local: local.files,
|
|
1262
|
+
remote: remote.files,
|
|
1263
|
+
selectionCompatible,
|
|
1264
|
+
protectedPaths: protectedSessionPaths(ctx, sessionRoot)
|
|
1265
|
+
});
|
|
1266
|
+
let resolved = { plan, fields: [] };
|
|
1267
|
+
if (config.mergeSettings) {
|
|
1268
|
+
try {
|
|
1269
|
+
resolved = await resolveSettingsConflicts(config, state, local, remote, plan);
|
|
1270
|
+
} catch (error) {
|
|
1271
|
+
if (!config.partialSync) throw error;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
await validate();
|
|
1275
|
+
plan = await resolveContentConflicts(
|
|
1276
|
+
config,
|
|
1277
|
+
state,
|
|
1278
|
+
local,
|
|
1279
|
+
remote,
|
|
1280
|
+
resolved.plan,
|
|
1281
|
+
protectedSessionPaths(ctx, sessionRoot),
|
|
1282
|
+
{ textBudget, signal: options.signal }
|
|
1283
|
+
);
|
|
1284
|
+
await validate();
|
|
1285
|
+
if (resolution) {
|
|
1286
|
+
plan = await resolveReviewedGroup(
|
|
1287
|
+
config,
|
|
1288
|
+
backend,
|
|
1289
|
+
state,
|
|
1290
|
+
local,
|
|
1291
|
+
remote,
|
|
1292
|
+
head,
|
|
1293
|
+
plan,
|
|
1294
|
+
resolution,
|
|
1295
|
+
protectedSessionPaths(ctx, sessionRoot)
|
|
1296
|
+
);
|
|
1297
|
+
await validate();
|
|
1298
|
+
}
|
|
1299
|
+
if (plan.kind !== "planned" || plan.conflicts.length && (!config.partialSync || options.auto)) {
|
|
1300
|
+
const decision = review(
|
|
1301
|
+
selectionCompatible ? `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(", ")}` : ""}` : "Remote selection metadata is unavailable; review the included content and choose an explicit direction before merging.",
|
|
1302
|
+
selectionCompatible ? "both-changed" : "remote-or-policy-changed"
|
|
1303
|
+
);
|
|
1304
|
+
if (!selectionCompatible)
|
|
1305
|
+
decision.decision.review += `
|
|
1306
|
+
|
|
1307
|
+
${formatRemoteSelectionStatus(selectionState)}
|
|
1308
|
+
An explicit direction adopts this setup's included-content policy; no automatic merge was performed.`;
|
|
1309
|
+
throw decision;
|
|
1310
|
+
}
|
|
1311
|
+
if (plan.kind !== "planned") throw new Error("Cannot partition an unverified merge plan.");
|
|
1312
|
+
const groups = conflictGroups(plan, config);
|
|
1313
|
+
const withheld = new Set(groups.flatMap((group) => group.paths));
|
|
1314
|
+
const persistProgress = async () => {
|
|
1315
|
+
if (!groups.length) return;
|
|
1316
|
+
let ancestors = [];
|
|
1317
|
+
try {
|
|
1318
|
+
ancestors = (await readMergeAncestors(config, state) ?? []).filter((file) => withheld.has(file.path));
|
|
1319
|
+
} catch {
|
|
1320
|
+
}
|
|
1321
|
+
await validate();
|
|
1322
|
+
const previousArtifacts = /* @__PURE__ */ new Map();
|
|
1323
|
+
for (const token of new Set(state.unresolved?.map((group) => group.artifact) ?? [])) {
|
|
1324
|
+
try {
|
|
1325
|
+
const artifact2 = await readConflictArtifact(config, backend.identity, token);
|
|
1326
|
+
previousArtifacts.set(token, {
|
|
1327
|
+
artifact: artifact2,
|
|
1328
|
+
local: fileHashMap(artifact2.local),
|
|
1329
|
+
remote: fileHashMap(artifact2.remote)
|
|
1330
|
+
});
|
|
1331
|
+
} catch {
|
|
1332
|
+
}
|
|
1333
|
+
await validate();
|
|
1334
|
+
}
|
|
1335
|
+
const localHashes = fileHashMap(local);
|
|
1336
|
+
const remoteHashes = fileHashMap(remote);
|
|
1337
|
+
const previousGroups = new Map(state.unresolved?.map((item) => [JSON.stringify(item.paths), item]));
|
|
1338
|
+
const pending2 = groups.map((group) => {
|
|
1339
|
+
const previous = previousGroups.get(JSON.stringify(group.paths));
|
|
1340
|
+
const original = previous ? previousArtifacts.get(previous.artifact) : void 0;
|
|
1341
|
+
if (!original || !previous) return { group };
|
|
1342
|
+
return group.paths.every(
|
|
1343
|
+
(filePath) => localHashes[filePath] === original.local[filePath] && remoteHashes[filePath] === original.remote[filePath] && state.lastFileHashes[filePath] === original.artifact.state.lastFileHashes[filePath]
|
|
1344
|
+
) ? { group, artifact: previous.artifact } : { group };
|
|
1345
|
+
});
|
|
1346
|
+
let artifact;
|
|
1347
|
+
const newGroups = pending2.filter((item) => !item.artifact).map((item) => item.group);
|
|
1348
|
+
const newPaths = new Set(newGroups.flatMap((group) => group.paths));
|
|
1349
|
+
if (newGroups.length)
|
|
1350
|
+
artifact = await saveConflictArtifact(
|
|
1351
|
+
config,
|
|
1352
|
+
backend.identity,
|
|
1353
|
+
{
|
|
1354
|
+
state,
|
|
1355
|
+
local: { ...local, files: local.files.filter((file) => newPaths.has(file.path)) },
|
|
1356
|
+
remote: { ...remote, files: remote.files.filter((file) => newPaths.has(file.path)) },
|
|
1357
|
+
ancestors: ancestors.filter((file) => newPaths.has(file.path)),
|
|
1358
|
+
groups: newGroups,
|
|
1359
|
+
observed: { snapshotId: head.snapshotId, revision: head.revision }
|
|
1360
|
+
},
|
|
1361
|
+
captureMutationOwner(ctx, options.signal),
|
|
1362
|
+
(created) => createdArtifacts.push(created)
|
|
1363
|
+
);
|
|
1364
|
+
await validate();
|
|
1365
|
+
return {
|
|
1366
|
+
previous: state,
|
|
1367
|
+
groups: pending2.map((item) => ({ paths: item.group.paths, artifact: item.artifact ?? artifact ?? "" }))
|
|
1368
|
+
};
|
|
1369
|
+
};
|
|
1370
|
+
let progress;
|
|
1371
|
+
const accepted = regenerateSnapshotIdentity({
|
|
1372
|
+
...local,
|
|
1373
|
+
files: acceptedMergeFiles(plan.decisions, local.files, withheld)
|
|
1374
|
+
});
|
|
1375
|
+
const after = overlayLocalFields(accepted, localRaw, config.localFields);
|
|
1376
|
+
if (config.include.includes("sessions")) requireStableMergeSessionRoot(localRaw, after);
|
|
1377
|
+
const publication = regenerateSnapshotIdentity({
|
|
1378
|
+
...accepted,
|
|
1379
|
+
version: config.partialSync ? 3 : accepted.version,
|
|
1380
|
+
files: [
|
|
1381
|
+
...accepted.files.filter((file) => !withheld.has(file.path)),
|
|
1382
|
+
...remote.files.filter((file) => withheld.has(file.path))
|
|
1383
|
+
]
|
|
1384
|
+
});
|
|
1385
|
+
const upload = mergeRemotePreservedFiles(publication, rawRemote, config);
|
|
1386
|
+
const publish = !sameHashes(fileHashMap(publication), fileHashMap(remote)) || Boolean(config.partialSync && rawRemote.version !== 3);
|
|
1387
|
+
const apply = !sameHashes(fileHashMap(localRaw), fileHashMap(after));
|
|
1388
|
+
if (!publish && !apply) {
|
|
1389
|
+
const beforeEvidence = await backend.readHead(options.signal);
|
|
1390
|
+
await validate();
|
|
1391
|
+
if (!beforeEvidence || !backend.sameRevision(beforeEvidence.revision, head.revision))
|
|
1392
|
+
throw new Error("Remote changed before baseline acceptance; retry from a fresh observation.");
|
|
1393
|
+
progress = await persistProgress();
|
|
1394
|
+
await validate();
|
|
1395
|
+
const currentLocal = await createSnapshot(config.snapshotIdentity, snapshotOptions);
|
|
1396
|
+
await validate();
|
|
1397
|
+
if (!sameHashes(fileHashMap(currentLocal), fileHashMap(localRaw)) || syncStateFingerprint(await readStateForConfig(config)) !== syncStateFingerprint(state))
|
|
1398
|
+
throw new Error(
|
|
1399
|
+
"Local content or baseline changed before baseline acceptance; retry from a fresh observation."
|
|
1400
|
+
);
|
|
1401
|
+
await validate();
|
|
1402
|
+
const currentHead = await backend.readHead(options.signal);
|
|
1403
|
+
await validate();
|
|
1404
|
+
if (!currentHead || !backend.sameRevision(currentHead.revision, head.revision))
|
|
1405
|
+
throw new Error("Remote changed before baseline acceptance; retry from a fresh observation.");
|
|
1406
|
+
await writeAcceptedState(config, head, accepted, validate, progress);
|
|
1407
|
+
await pruneMergeBaselines(
|
|
1408
|
+
config,
|
|
1409
|
+
acceptedState(config, head, accepted, progress),
|
|
1410
|
+
captureMutationOwner(ctx, options.signal)
|
|
1411
|
+
);
|
|
1412
|
+
await validate();
|
|
1413
|
+
await pruneCompletedConflicts(
|
|
1414
|
+
config,
|
|
1415
|
+
backend.identity,
|
|
1416
|
+
acceptedState(config, head, accepted, progress),
|
|
1417
|
+
accepted,
|
|
1418
|
+
remote,
|
|
1419
|
+
captureMutationOwner(ctx, options.signal)
|
|
1420
|
+
);
|
|
1421
|
+
await validate();
|
|
1422
|
+
if (!options.silent)
|
|
1423
|
+
ctx.ui.notify(
|
|
1424
|
+
progress ? `No independent transfer required; ${groups.length} conflict groups remain withheld. Review /sync conflicts.` : "Pi Sync is already up to date.",
|
|
1425
|
+
progress ? "warning" : "info"
|
|
1426
|
+
);
|
|
1427
|
+
return "applied";
|
|
1428
|
+
}
|
|
1429
|
+
await preflightMergedTargets(localRaw, after, snapshotOptions, ctx.sessionManager.getSessionFile?.());
|
|
1430
|
+
await validate();
|
|
1431
|
+
if (!config.skipSecretScan && scanSnapshot(upload).length)
|
|
1432
|
+
throw new Error("Refusing to merge possible secrets. Review managed content before syncing.");
|
|
1433
|
+
if (!options.yes && !await confirmMergeReview(
|
|
1434
|
+
ctx,
|
|
1435
|
+
"Merge independent Pi changes?",
|
|
1436
|
+
[
|
|
1437
|
+
`Sync setup: ${safeTerminalText(config.setupName)}`,
|
|
1438
|
+
`Storage location: ${safeTerminalText(backend.destination)}`,
|
|
1439
|
+
`Sessions: ${config.include.includes("sessions") ? "included \u2014 may contain private conversations" : "not included"}`,
|
|
1440
|
+
`Local writes/deletions: ${plan.decisions.filter((item) => item.kind === "accepted" && (item.source === "remote" || item.source === "merged")).length}`,
|
|
1441
|
+
formatApplyPreview(localRaw, after).split("\n").map(safeTerminalText).join("\n"),
|
|
1442
|
+
`Unresolved dependency groups: ${groups.length} (withheld versions stay on each side; baselines do not advance)`,
|
|
1443
|
+
...groups.flatMap((group) => group.paths.map((filePath) => `Withheld: ${safeTerminalText(filePath)}`)),
|
|
1444
|
+
`Remote publication: ${publish ? "yes" : "no"}`,
|
|
1445
|
+
formatPublicationPreview(rawRemote, upload).split("\n").map(safeTerminalText).join("\n"),
|
|
1446
|
+
`Backend publication: ${backend.capability}`,
|
|
1447
|
+
"The current session is protected. A backup and recovery journal are retained. Resources are not reloaded."
|
|
1448
|
+
].join("\n"),
|
|
1449
|
+
options.signal,
|
|
1450
|
+
isCurrent
|
|
1451
|
+
))
|
|
1452
|
+
return "cancelled";
|
|
1453
|
+
await validate();
|
|
1454
|
+
const refreshed = await createSnapshot(config.snapshotIdentity, snapshotOptions);
|
|
1455
|
+
if (!sameHashes(fileHashMap(refreshed), fileHashMap(localRaw)))
|
|
1456
|
+
throw new Error("Local content changed during review; no merged transfer was performed.");
|
|
1457
|
+
if (syncStateFingerprint(await readStateForConfig(config)) !== syncStateFingerprint(state))
|
|
1458
|
+
throw new Error("Sync baseline changed during review; retry from a fresh observation.");
|
|
1459
|
+
await validate();
|
|
1460
|
+
const refreshedHead = await backend.readHead(options.signal);
|
|
1461
|
+
await validate();
|
|
1462
|
+
if (!refreshedHead || !backend.sameRevision(refreshedHead.revision, head.revision))
|
|
1463
|
+
throw new Error("Remote changed during review; no merged transfer was performed.");
|
|
1464
|
+
progress = await persistProgress();
|
|
1465
|
+
await validate();
|
|
1466
|
+
const backup = await backupLocal(config.snapshotIdentity, snapshotOptions, options.signal);
|
|
1467
|
+
await validate();
|
|
1468
|
+
const journal = {
|
|
1469
|
+
version: 1,
|
|
1470
|
+
identity: mergeJournalIdentity(config, backend.identity),
|
|
1471
|
+
before: localRaw,
|
|
1472
|
+
after,
|
|
1473
|
+
accepted,
|
|
1474
|
+
...progress ? { progress } : {},
|
|
1475
|
+
upload: publish ? upload : rawRemote,
|
|
1476
|
+
expectedHead: head,
|
|
1477
|
+
backup,
|
|
1478
|
+
stateIdentity: syncStateFingerprint(state),
|
|
1479
|
+
...sessionRoot !== void 0 ? { sessionRoot } : {},
|
|
1480
|
+
...!publish ? { committedHead: head, applyOnly: true } : {}
|
|
1481
|
+
};
|
|
1482
|
+
await writeMergeJournal(config, journal);
|
|
1483
|
+
const atCommitState = syncStateFingerprint(await readStateForConfig(config));
|
|
1484
|
+
await validate();
|
|
1485
|
+
const atCommit = await createSnapshot(config.snapshotIdentity, snapshotOptions);
|
|
1486
|
+
await validate();
|
|
1487
|
+
if (!sameHashes(fileHashMap(atCommit), fileHashMap(localRaw)) || atCommitState !== journal.stateIdentity) {
|
|
1488
|
+
await validate();
|
|
1489
|
+
await clearMergeJournal(config);
|
|
1490
|
+
throw new Error(
|
|
1491
|
+
"Local content or baseline changed before publication; candidate retired without transfer. Review a fresh sync."
|
|
1492
|
+
);
|
|
1493
|
+
}
|
|
1494
|
+
try {
|
|
1495
|
+
await preflightMergedTargets(atCommit, after, snapshotOptions, ctx.sessionManager.getSessionFile?.());
|
|
1496
|
+
await validate();
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
await validate();
|
|
1499
|
+
await clearMergeJournal(config);
|
|
1500
|
+
throw error;
|
|
1501
|
+
}
|
|
1502
|
+
try {
|
|
1503
|
+
if (publish) {
|
|
1504
|
+
const result = await backend.publishSnapshot(upload, expectedRemoteHead(head), {
|
|
1505
|
+
signal: options.signal,
|
|
1506
|
+
onCommit: options.onCommit
|
|
1507
|
+
});
|
|
1508
|
+
journal.committedHead = result.head;
|
|
1509
|
+
journal.warnings = result.warnings;
|
|
1510
|
+
await writeMergeJournal(config, journal);
|
|
1511
|
+
} else options.onCommit?.();
|
|
1512
|
+
} catch (error) {
|
|
1513
|
+
if (error instanceof SyncBackendConflictError && error.phase === "before-commit" && !error.candidateMayHaveBeenActive) {
|
|
1514
|
+
await clearMergeJournal(config);
|
|
1515
|
+
if (attempt + 1 < MAX_ATTEMPTS) continue;
|
|
1516
|
+
throw new Error("Remote changed before commit in all three attempts; retry later.", { cause: error });
|
|
1517
|
+
}
|
|
1518
|
+
throw new Error(
|
|
1519
|
+
"Merged transfer interrupted; journal and backup retained. Run /sync sync to reconcile before further mutations.",
|
|
1520
|
+
{ cause: error }
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
const completed = await completeJournal(ctx, config, backend, journal, validate, options.signal, options.auto);
|
|
1524
|
+
if (!completed) {
|
|
1525
|
+
if (!options.signal?.aborted && (options.auto || !options.silent))
|
|
1526
|
+
ctx.ui.notify(
|
|
1527
|
+
"Remote changed before local apply; candidate retired without transfer. Run sync again.",
|
|
1528
|
+
"warning"
|
|
1529
|
+
);
|
|
1530
|
+
return "cancelled";
|
|
1531
|
+
}
|
|
1532
|
+
if (!options.signal?.aborted && (options.auto || !options.silent))
|
|
1533
|
+
ctx.ui.notify(
|
|
1534
|
+
[
|
|
1535
|
+
`Synced independent changes for \u201C${safeTerminalText(config.setupName)}\u201D. ${apply ? "Local files changed; reload or restart Pi when ready to use changed resources." : "Remote updated."} No automatic reload.`,
|
|
1536
|
+
...progress ? [
|
|
1537
|
+
`Partial progress: ${groups.length} dependency groups remain unresolved; neither side's withheld versions or baseline hashes were replaced. Review /sync conflicts.`
|
|
1538
|
+
] : [],
|
|
1539
|
+
...(journal.warnings ?? []).map(safeTerminalText)
|
|
1540
|
+
].join("\n"),
|
|
1541
|
+
progress || journal.warnings?.length ? "warning" : "info"
|
|
1542
|
+
);
|
|
1543
|
+
return "applied";
|
|
1544
|
+
}
|
|
1545
|
+
} finally {
|
|
1546
|
+
await releaseConflictArtifacts(config, ownedStatePath, createdArtifacts);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
async function completeJournal(ctx, config, backend, journal, validate, signal, auto = false) {
|
|
1550
|
+
if (journal.identity !== mergeJournalIdentity(config, backend.identity) || !sameLocalFields(journal.upload.localFields, config.localFields))
|
|
1551
|
+
throw new Error(
|
|
1552
|
+
"Merge journal belongs to a different setup, selection or local-field policy; preserve it for reviewed recovery."
|
|
1553
|
+
);
|
|
1554
|
+
await validate();
|
|
1555
|
+
const sessionRoot = config.include.includes("sessions") ? await effectiveSessionRoot(ctx) : void 0;
|
|
1556
|
+
await validate();
|
|
1557
|
+
if (sessionRoot !== void 0 && journal.sessionRoot !== sessionRoot) {
|
|
1558
|
+
throw new Error(
|
|
1559
|
+
"Merge journal session root is missing or differs from this context; preserve evidence and use reviewed directional recovery."
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
const snapshotOptions = { ...snapshotOptionsForContext(ctx, config), sessionDir: sessionRoot, signal };
|
|
1563
|
+
const head = await backend.readHead(signal);
|
|
1564
|
+
if (!journal.committedHead && head && backend.sameRevision(head.revision, journal.expectedHead.revision)) {
|
|
1565
|
+
await validate();
|
|
1566
|
+
const local = await createSnapshot(config.snapshotIdentity, snapshotOptions);
|
|
1567
|
+
if (!sameHashes(fileHashMap(local), fileHashMap(journal.before)) || syncStateFingerprint(await readStateForConfig(config)) !== journal.stateIdentity) {
|
|
1568
|
+
throw new Error(
|
|
1569
|
+
"Uncertain publication is not active, but local content or baseline changed; keep the journal for reviewed recovery."
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
await clearMergeJournal(config);
|
|
1573
|
+
return false;
|
|
1574
|
+
}
|
|
1575
|
+
await validate();
|
|
1576
|
+
if (journal.committedHead && (!head || !backend.sameRevision(head.revision, journal.committedHead.revision))) {
|
|
1577
|
+
const state2 = await readStateForConfig(config);
|
|
1578
|
+
await validate();
|
|
1579
|
+
if (matchesAcceptedState(state2, config, journal.committedHead, journal.accepted ?? journal.after, journal.progress)) {
|
|
1580
|
+
await clearMergeJournal(config);
|
|
1581
|
+
await validate();
|
|
1582
|
+
await pruneMergeBaselines(config, state2, captureMutationOwner(ctx, signal));
|
|
1583
|
+
await validate();
|
|
1584
|
+
return true;
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
if (journal.applyOnly && (!head || !backend.sameRevision(head.revision, journal.expectedHead.revision))) {
|
|
1588
|
+
const local = await createSnapshot(config.snapshotIdentity, snapshotOptions);
|
|
1589
|
+
const state2 = await readStateForConfig(config);
|
|
1590
|
+
await validate();
|
|
1591
|
+
if (sameHashes(fileHashMap(local), fileHashMap(journal.before)) && syncStateFingerprint(state2) === journal.stateIdentity) {
|
|
1592
|
+
await clearMergeJournal(config);
|
|
1593
|
+
return false;
|
|
1594
|
+
}
|
|
1595
|
+
throw new Error("Apply-only remote head advanced after local changes; preserve the journal for reviewed recovery.");
|
|
1596
|
+
}
|
|
1597
|
+
if (!head || head.snapshotId !== journal.upload.id || journal.committedHead && !backend.sameRevision(head.revision, journal.committedHead.revision)) {
|
|
1598
|
+
throw new Error(
|
|
1599
|
+
"Publication outcome cannot be reconciled with the current remote head. Journal and backup retained; do not retry publication or restore old bytes blindly."
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
const observed = await readSnapshotForHead(backend, head, signal);
|
|
1603
|
+
requireCompatibleRemoteSelection(config, observed);
|
|
1604
|
+
if (!sameHashes(fileHashMap(observed), fileHashMap(journal.upload)) || JSON.stringify(observed.selection) !== JSON.stringify(journal.upload.selection))
|
|
1605
|
+
throw new Error("Remote snapshot does not match the recorded merge publication.");
|
|
1606
|
+
const state = await readStateForConfig(config);
|
|
1607
|
+
if (matchesAcceptedState(state, config, head, journal.accepted ?? journal.after, journal.progress)) {
|
|
1608
|
+
await validate();
|
|
1609
|
+
await clearMergeJournal(config);
|
|
1610
|
+
await validate();
|
|
1611
|
+
await pruneMergeBaselines(config, state, captureMutationOwner(ctx, signal));
|
|
1612
|
+
await validate();
|
|
1613
|
+
return true;
|
|
1614
|
+
}
|
|
1615
|
+
if (syncStateFingerprint(state) !== journal.stateIdentity) {
|
|
1616
|
+
throw new Error("Sync baseline changed after the interrupted merge; preserve its journal and review recovery.");
|
|
1617
|
+
}
|
|
1618
|
+
if (inspectRemoteSelection(config.include, observed).kind !== "same")
|
|
1619
|
+
throw new Error(
|
|
1620
|
+
"Pending merge has unavailable remote selection metadata; preserve its journal and choose a reviewed explicit direction."
|
|
1621
|
+
);
|
|
1622
|
+
if (config.include.includes("sessions")) requireStableMergeSessionRoot(journal.before, journal.after);
|
|
1623
|
+
await validate();
|
|
1624
|
+
const validateOwner = captureMutationOwner(ctx, signal);
|
|
1625
|
+
const validateMutation = () => {
|
|
1626
|
+
validateOwner();
|
|
1627
|
+
if (auto && !ctx.isIdle()) throw new Error("Automatic file apply is no longer idle; evidence retained.");
|
|
1628
|
+
};
|
|
1629
|
+
await applyMergedSnapshot(
|
|
1630
|
+
journal.before,
|
|
1631
|
+
journal.after,
|
|
1632
|
+
protectedSessionPaths(ctx, sessionRoot),
|
|
1633
|
+
{ ...snapshotOptions, validateMutation },
|
|
1634
|
+
validate,
|
|
1635
|
+
ctx.sessionManager.getSessionFile?.()
|
|
1636
|
+
);
|
|
1637
|
+
await validate();
|
|
1638
|
+
const headAtAcceptance = await backend.readHead(signal);
|
|
1639
|
+
await validate();
|
|
1640
|
+
if (!headAtAcceptance || !backend.sameRevision(headAtAcceptance.revision, head.revision))
|
|
1641
|
+
throw new Error("Remote changed during merged apply; journal and backup retained for review.");
|
|
1642
|
+
await writeAcceptedState(config, head, journal.accepted ?? journal.after, validate, journal.progress);
|
|
1643
|
+
await validate();
|
|
1644
|
+
await clearMergeJournal(config);
|
|
1645
|
+
await validate();
|
|
1646
|
+
await pruneMergeBaselines(
|
|
1647
|
+
config,
|
|
1648
|
+
acceptedState(config, head, journal.accepted ?? journal.after, journal.progress),
|
|
1649
|
+
captureMutationOwner(ctx, signal)
|
|
1650
|
+
);
|
|
1651
|
+
await validate();
|
|
1652
|
+
await pruneCompletedConflicts(
|
|
1653
|
+
config,
|
|
1654
|
+
backend.identity,
|
|
1655
|
+
acceptedState(config, head, journal.accepted ?? journal.after, journal.progress),
|
|
1656
|
+
journal.accepted ?? journal.after,
|
|
1657
|
+
journal.upload,
|
|
1658
|
+
captureMutationOwner(ctx, signal)
|
|
1659
|
+
);
|
|
1660
|
+
await validate();
|
|
1661
|
+
return true;
|
|
1662
|
+
}
|
|
1663
|
+
function matchesAcceptedState(state, config, head, snapshot, progress) {
|
|
1664
|
+
return [true, false].some(
|
|
1665
|
+
(canonical) => syncStateFingerprint(state) === syncStateFingerprint(acceptedState(config, head, snapshot, progress, canonical))
|
|
1666
|
+
);
|
|
1667
|
+
}
|
|
1668
|
+
function acceptedState(config, head, snapshot, progress, canonical = true) {
|
|
1669
|
+
if (progress) return progressState(config, head, snapshot, progress);
|
|
1670
|
+
return {
|
|
1671
|
+
version: 1,
|
|
1672
|
+
profile: config.snapshotIdentity,
|
|
1673
|
+
lastAppliedSnapshot: head.snapshotId,
|
|
1674
|
+
lastRemoteRevision: head.revision,
|
|
1675
|
+
lastFileHashes: fileHashMap(canonical ? portableSnapshot(snapshot, config.localFields) : snapshot),
|
|
1676
|
+
include: [...config.include],
|
|
1677
|
+
...config.localFields !== void 0 ? { localFields: config.localFields } : {}
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
async function writeAcceptedState(config, head, snapshot, validate, progress) {
|
|
1681
|
+
const accepted = portableSnapshot(snapshot, config.localFields);
|
|
1682
|
+
const state = acceptedState(config, head, accepted, progress);
|
|
1683
|
+
await stageMergeBaseline(config, accepted, state, progress?.previous);
|
|
1684
|
+
await validate();
|
|
1685
|
+
await writeStateForConfig(config, state);
|
|
1686
|
+
await validate();
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
export {
|
|
1690
|
+
confirmMergeReview,
|
|
1691
|
+
formatDiff,
|
|
1692
|
+
formatSnapshotOnlyDiff,
|
|
1693
|
+
formatPushSummary,
|
|
1694
|
+
formatPullSummary,
|
|
1695
|
+
formatRollbackSummary,
|
|
1696
|
+
countPreservedRemoteFiles,
|
|
1697
|
+
publicationCapabilityDescription,
|
|
1698
|
+
applySnapshot,
|
|
1699
|
+
createSyncDecision,
|
|
1700
|
+
mergeSync
|
|
1701
|
+
};
|
|
1702
|
+
//# sourceMappingURL=chunk-2GUXI2XT.ts.map
|