@narumitw/pi-sync 0.51.1 → 0.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/README.md +30 -5
  2. package/dist/chunks/{chunk-7KHKWL3Q.ts → chunk-2CBF6SMT.ts} +8 -7
  3. package/dist/chunks/chunk-2CBF6SMT.ts.map +7 -0
  4. package/dist/chunks/{chunk-YFADF5ZQ.ts → chunk-2DI6PDZV.ts} +5 -1
  5. package/dist/chunks/chunk-2DI6PDZV.ts.map +7 -0
  6. package/dist/chunks/{chunk-4MPIH46N.ts → chunk-3B4Z2SKV.ts} +40 -6
  7. package/dist/chunks/chunk-3B4Z2SKV.ts.map +7 -0
  8. package/dist/chunks/{chunk-YPXGU5OR.ts → chunk-4QVPTIZB.ts} +16 -6
  9. package/dist/chunks/{chunk-YPXGU5OR.ts.map → chunk-4QVPTIZB.ts.map} +2 -2
  10. package/dist/chunks/{chunk-UA7RXOIT.ts → chunk-4SBTAVFL.ts} +6 -6
  11. package/dist/chunks/{chunk-6546TS54.ts → chunk-5RZPINUQ.ts} +7 -4
  12. package/dist/chunks/{chunk-6546TS54.ts.map → chunk-5RZPINUQ.ts.map} +2 -2
  13. package/dist/chunks/chunk-76NB4R7U.ts +107 -0
  14. package/dist/chunks/chunk-76NB4R7U.ts.map +7 -0
  15. package/dist/chunks/{chunk-DM4X4FIA.ts → chunk-7D6RDCVC.ts} +8 -10
  16. package/dist/chunks/{chunk-DM4X4FIA.ts.map → chunk-7D6RDCVC.ts.map} +2 -2
  17. package/dist/chunks/chunk-A6HU4ERM.ts +124 -0
  18. package/dist/chunks/chunk-A6HU4ERM.ts.map +7 -0
  19. package/dist/chunks/chunk-AMJRUXFT.ts +17 -0
  20. package/dist/chunks/chunk-AMJRUXFT.ts.map +7 -0
  21. package/dist/chunks/chunk-ANWNQIR7.ts +1013 -0
  22. package/dist/chunks/chunk-ANWNQIR7.ts.map +7 -0
  23. package/dist/chunks/{chunk-T56RNRZJ.ts → chunk-BDB4IDM3.ts} +19 -10
  24. package/dist/chunks/chunk-BDB4IDM3.ts.map +7 -0
  25. package/dist/chunks/{chunk-2RZSQWBH.ts → chunk-CHSIAC4N.ts} +5 -5
  26. package/dist/chunks/{chunk-2RZSQWBH.ts.map → chunk-CHSIAC4N.ts.map} +2 -2
  27. package/dist/chunks/chunk-HUZ5CR4W.ts +110 -0
  28. package/dist/chunks/chunk-HUZ5CR4W.ts.map +7 -0
  29. package/dist/chunks/{chunk-QLSN7AGX.ts → chunk-IJ6VV6FN.ts} +81 -19
  30. package/dist/chunks/chunk-IJ6VV6FN.ts.map +7 -0
  31. package/dist/chunks/chunk-NLS2NV3K.ts +1746 -0
  32. package/dist/chunks/chunk-NLS2NV3K.ts.map +7 -0
  33. package/dist/chunks/chunk-ORQRHEEO.ts +362 -0
  34. package/dist/chunks/chunk-ORQRHEEO.ts.map +7 -0
  35. package/dist/chunks/chunk-QE7NZZWT.ts +380 -0
  36. package/dist/chunks/chunk-QE7NZZWT.ts.map +7 -0
  37. package/dist/chunks/{chunk-HGJH56KR.ts → chunk-QHSQCK7L.ts} +4 -4
  38. package/dist/chunks/chunk-QOFMXPM5.ts +735 -0
  39. package/dist/chunks/chunk-QOFMXPM5.ts.map +7 -0
  40. package/dist/chunks/{chunk-DZUSTGQV.ts → chunk-UQ3ALBIB.ts} +4 -4
  41. package/dist/chunks/{chunk-Q32CQWPM.ts → chunk-V6VZ5TLG.ts} +13 -6
  42. package/dist/chunks/chunk-V6VZ5TLG.ts.map +7 -0
  43. package/dist/chunks/{chunk-E42GXD6D.ts → chunk-WKPX2SIC.ts} +4 -4
  44. package/dist/chunks/chunk-ZW5ZLGBI.ts +154 -0
  45. package/dist/chunks/chunk-ZW5ZLGBI.ts.map +7 -0
  46. package/dist/chunks/{chunk-6NT3YFGY.ts → chunk-ZX2Q6THB.ts} +8 -3
  47. package/dist/chunks/{chunk-6NT3YFGY.ts.map → chunk-ZX2Q6THB.ts.map} +2 -2
  48. package/dist/chunks/conflict-artifacts-BB2FZMIE.ts +27 -0
  49. package/dist/chunks/conflict-review-4WPGCXFB.ts +175 -0
  50. package/dist/chunks/conflict-review-4WPGCXFB.ts.map +7 -0
  51. package/dist/chunks/{file-selection-RAVHRYFG.ts → file-selection-WZX7HAY2.ts} +8 -7
  52. package/dist/chunks/{file-selection-RAVHRYFG.ts.map → file-selection-WZX7HAY2.ts.map} +1 -1
  53. package/dist/chunks/{git-backend-IAANDL3K.ts → git-backend-HLGKUQJT.ts} +19 -7
  54. package/dist/chunks/git-backend-HLGKUQJT.ts.map +7 -0
  55. package/dist/chunks/local-fields-6DGYOOIK.ts +22 -0
  56. package/dist/chunks/manager-result-dispatcher-VIDT3QBU.ts +23 -0
  57. package/dist/chunks/{manager-ui-763NEKJY.ts → manager-ui-HSMHS3OK.ts} +236 -57
  58. package/dist/chunks/manager-ui-HSMHS3OK.ts.map +7 -0
  59. package/dist/chunks/merge-baseline-store-EVQAAML6.ts +22 -0
  60. package/dist/chunks/remote-selection-ui-SMF5CLFO.ts +22 -0
  61. package/dist/chunks/{s3-backend-XGXUOPUN.ts → s3-backend-DJ2NT3SZ.ts} +16 -9
  62. package/dist/chunks/s3-backend-DJ2NT3SZ.ts.map +7 -0
  63. package/dist/chunks/{setup-switch-5G2VNJHL.ts → setup-switch-KTUWL6DE.ts} +9 -6
  64. package/dist/chunks/{setup-wizard-YIEWGX32.ts → setup-wizard-GSDPDFC4.ts} +10 -7
  65. package/dist/chunks/{setup-wizard-YIEWGX32.ts.map → setup-wizard-GSDPDFC4.ts.map} +1 -1
  66. package/dist/chunks/{snapshot-JL5DFSXI.ts → snapshot-THS5EY2L.ts} +6 -9
  67. package/dist/chunks/snapshot-THS5EY2L.ts.map +7 -0
  68. package/dist/chunks/{sync-inspection-P3ENJLNF.ts → sync-inspection-VBJF2FWC.ts} +10 -10
  69. package/dist/chunks/sync-inspection-VBJF2FWC.ts.map +7 -0
  70. package/dist/chunks/{sync-operations-AR76EB6C.ts → sync-operations-RHNECOOG.ts} +309 -508
  71. package/dist/chunks/sync-operations-RHNECOOG.ts.map +7 -0
  72. package/dist/chunks/{sync-state-VHJUZ4YG.ts → sync-state-CFL4RRRB.ts} +6 -6
  73. package/dist/chunks/sync-state-CFL4RRRB.ts.map +7 -0
  74. package/dist/chunks/{webdav-backend-3EGUONAT.ts → webdav-backend-ETQGSI6I.ts} +13 -7
  75. package/dist/chunks/webdav-backend-ETQGSI6I.ts.map +7 -0
  76. package/dist/index.ts +142 -43
  77. package/dist/index.ts.map +2 -2
  78. package/docs/content-merge-audit.md +156 -0
  79. package/docs/merge-implementation-audit.md +312 -0
  80. package/docs/settings-merge-audit.md +153 -0
  81. package/docs/settings.md +36 -0
  82. package/package.json +5 -5
  83. package/src/backends/git/git-backend.ts +2 -0
  84. package/src/backends/git/git-storage.ts +8 -3
  85. package/src/backends/s3/s3-backend.ts +2 -1
  86. package/src/backends/webdav/webdav-backend.ts +2 -1
  87. package/src/commands/command-execution.ts +10 -0
  88. package/src/commands/command.ts +4 -0
  89. package/src/settings/config.ts +12 -0
  90. package/src/settings/settings-management.ts +2 -2
  91. package/src/settings/settings-types.ts +14 -1
  92. package/src/settings/settings-validation.ts +24 -3
  93. package/src/snapshot/session-paths.ts +34 -7
  94. package/src/snapshot/snapshot-apply.ts +81 -32
  95. package/src/snapshot/snapshot-case-replacement.ts +80 -0
  96. package/src/snapshot/snapshot-codec.ts +6 -1
  97. package/src/snapshot/snapshot-root-transition.ts +154 -0
  98. package/src/snapshot/snapshot-transaction-plan.ts +72 -0
  99. package/src/snapshot/snapshot-transaction.ts +844 -102
  100. package/src/snapshot/snapshot-types.ts +5 -0
  101. package/src/state/json-file.ts +32 -5
  102. package/src/state/merge-baseline-store.ts +182 -0
  103. package/src/state/mutation-directory-sync.ts +34 -0
  104. package/src/state/state-types.ts +4 -0
  105. package/src/state/sync-state-store.ts +14 -1
  106. package/src/sync/automatic-sync.ts +11 -3
  107. package/src/sync/conflict-artifact-ownership.ts +63 -0
  108. package/src/sync/conflict-artifacts.ts +195 -0
  109. package/src/sync/conflict-groups.ts +68 -0
  110. package/src/sync/conflict-preview.ts +56 -0
  111. package/src/sync/conflict-resolution.ts +93 -0
  112. package/src/sync/conflict-retention.ts +72 -0
  113. package/src/sync/conflict-review.ts +95 -0
  114. package/src/sync/content-conflicts.ts +73 -0
  115. package/src/sync/field-migration.ts +39 -0
  116. package/src/sync/file-merge-planner.ts +143 -0
  117. package/src/sync/json-document.ts +184 -0
  118. package/src/sync/local-fields.ts +134 -0
  119. package/src/sync/merge-apply.ts +428 -0
  120. package/src/sync/merge-journal.ts +219 -0
  121. package/src/sync/merged-sync.ts +723 -0
  122. package/src/sync/partial-progress.ts +54 -0
  123. package/src/sync/remote-snapshot.ts +6 -0
  124. package/src/sync/session-merge.ts +194 -0
  125. package/src/sync/settings-conflicts.ts +52 -0
  126. package/src/sync/settings-merge.ts +57 -0
  127. package/src/sync/startup-check.ts +90 -5
  128. package/src/sync/sync-inspection.ts +1 -0
  129. package/src/sync/sync-local.ts +61 -0
  130. package/src/sync/sync-mutations.ts +256 -114
  131. package/src/sync/sync-queries.ts +6 -2
  132. package/src/sync/sync-state.ts +14 -5
  133. package/src/sync/text-merge.ts +133 -0
  134. package/src/sync-extension.ts +8 -0
  135. package/src/ui/manager-state.ts +12 -1
  136. package/src/ui/manager-ui.ts +10 -0
  137. package/src/ui/merge-review.ts +28 -0
  138. package/src/ui/remote-selection-ui.ts +1 -0
  139. package/src/ui/settings-ui.ts +183 -2
  140. package/src/ui/setup/setup-actions.ts +2 -2
  141. package/src/ui/setup/setup-switcher.ts +2 -1
  142. package/src/ui/storage-connections-ui.ts +11 -7
  143. package/src/ui/sync-attention.ts +3 -0
  144. package/src/ui/sync-format.ts +16 -12
  145. package/src/ui/sync-setups-ui.ts +1 -0
  146. package/src/ui/terminal-text.ts +9 -2
  147. package/dist/chunks/chunk-4AIHXRKE.ts +0 -197
  148. package/dist/chunks/chunk-4AIHXRKE.ts.map +0 -7
  149. package/dist/chunks/chunk-4MPIH46N.ts.map +0 -7
  150. package/dist/chunks/chunk-7KHKWL3Q.ts.map +0 -7
  151. package/dist/chunks/chunk-EEJPYTWE.ts +0 -348
  152. package/dist/chunks/chunk-EEJPYTWE.ts.map +0 -7
  153. package/dist/chunks/chunk-IVXEDPAD.ts +0 -370
  154. package/dist/chunks/chunk-IVXEDPAD.ts.map +0 -7
  155. package/dist/chunks/chunk-MSSW6F3R.ts +0 -12
  156. package/dist/chunks/chunk-MSSW6F3R.ts.map +0 -7
  157. package/dist/chunks/chunk-OVDTF5CH.ts +0 -90
  158. package/dist/chunks/chunk-OVDTF5CH.ts.map +0 -7
  159. package/dist/chunks/chunk-Q32CQWPM.ts.map +0 -7
  160. package/dist/chunks/chunk-QLSN7AGX.ts.map +0 -7
  161. package/dist/chunks/chunk-SWUXA3MH.ts +0 -18
  162. package/dist/chunks/chunk-SWUXA3MH.ts.map +0 -7
  163. package/dist/chunks/chunk-T56RNRZJ.ts.map +0 -7
  164. package/dist/chunks/chunk-YFADF5ZQ.ts.map +0 -7
  165. package/dist/chunks/git-backend-IAANDL3K.ts.map +0 -7
  166. package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +0 -23
  167. package/dist/chunks/manager-ui-763NEKJY.ts.map +0 -7
  168. package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +0 -22
  169. package/dist/chunks/s3-backend-XGXUOPUN.ts.map +0 -7
  170. package/dist/chunks/sync-operations-AR76EB6C.ts.map +0 -7
  171. package/dist/chunks/webdav-backend-3EGUONAT.ts.map +0 -7
  172. /package/dist/chunks/{chunk-UA7RXOIT.ts.map → chunk-4SBTAVFL.ts.map} +0 -0
  173. /package/dist/chunks/{chunk-HGJH56KR.ts.map → chunk-QHSQCK7L.ts.map} +0 -0
  174. /package/dist/chunks/{chunk-DZUSTGQV.ts.map → chunk-UQ3ALBIB.ts.map} +0 -0
  175. /package/dist/chunks/{chunk-E42GXD6D.ts.map → chunk-WKPX2SIC.ts.map} +0 -0
  176. /package/dist/chunks/{manager-result-dispatcher-OTUMQM26.ts.map → conflict-artifacts-BB2FZMIE.ts.map} +0 -0
  177. /package/dist/chunks/{remote-selection-ui-TZTJF32Y.ts.map → local-fields-6DGYOOIK.ts.map} +0 -0
  178. /package/dist/chunks/{setup-switch-5G2VNJHL.ts.map → manager-result-dispatcher-VIDT3QBU.ts.map} +0 -0
  179. /package/dist/chunks/{snapshot-JL5DFSXI.ts.map → merge-baseline-store-EVQAAML6.ts.map} +0 -0
  180. /package/dist/chunks/{sync-inspection-P3ENJLNF.ts.map → remote-selection-ui-SMF5CLFO.ts.map} +0 -0
  181. /package/dist/chunks/{sync-state-VHJUZ4YG.ts.map → setup-switch-KTUWL6DE.ts.map} +0 -0
@@ -0,0 +1,56 @@
1
+ import type { SnapshotFile } from "../snapshot/snapshot-types.js";
2
+ import { safeTerminalText } from "../ui/terminal-text.js";
3
+ import type { ConflictArtifact } from "./conflict-artifacts.js";
4
+
5
+ export const CONFLICT_DISPLAY_BYTES = 2 * 1024 * 1024;
6
+ const tooLarge = () =>
7
+ new Error(
8
+ "Conflict review exceeds the 2 MiB display bound; inspect private evidence and use a reviewed explicit direction.",
9
+ );
10
+
11
+ /** Index once and refuse a version before decoding if it cannot fit the remaining display budget. */
12
+ export function conflictPreview(artifact: ConflictArtifact, paths: readonly string[], destination: string) {
13
+ const index = (files: SnapshotFile[]) => new Map(files.map((file) => [file.path, file]));
14
+ const ancestors = index(artifact.ancestors ?? []);
15
+ const local = index(artifact.local.files);
16
+ const remote = index(artifact.remote.files);
17
+ const parts: string[] = [];
18
+ let size = 0;
19
+ const remaining = () => CONFLICT_DISPLAY_BYTES - size - (parts.length ? 1 : 0);
20
+ const append = (value: string, prefix = "") => {
21
+ if (Buffer.byteLength(value) + Buffer.byteLength(prefix) > remaining()) throw tooLarge();
22
+ // Sanitize at the display boundary, retaining line endings and raw evidence unchanged.
23
+ parts.push(prefix + value.split("\n").map(safeTerminalText).join("\n"));
24
+ size += Buffer.byteLength(prefix) + Buffer.byteLength(value) + (parts.length > 1 ? 1 : 0);
25
+ };
26
+ const version = (files: Map<string, SnapshotFile>, filePath: string) => {
27
+ const file = files.get(filePath);
28
+ if (!file) return append("(absent)");
29
+ append(file.sha256, "sha256: ");
30
+ if (Buffer.byteLength(file.contentBase64, "base64") > remaining()) throw tooLarge();
31
+ const bytes = Buffer.from(file.contentBase64, "base64");
32
+ let value: string;
33
+ try {
34
+ value = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
35
+ } catch {
36
+ return append(file.contentBase64, "base64: ");
37
+ }
38
+ append(value);
39
+ };
40
+ append(safeTerminalText(destination), "Storage: ");
41
+ append(
42
+ "Private conflict versions. Historical artifact is not authority for changed bytes/head. No automatic activation.",
43
+ );
44
+ for (const filePath of paths) {
45
+ append(safeTerminalText(filePath), "Path: ");
46
+ append("Base:");
47
+ const hash = artifact.state.lastFileHashes[filePath];
48
+ if (hash && !ancestors.has(filePath)) append(`(verified ancestor unavailable; sha256: ${hash})`);
49
+ else version(ancestors, filePath);
50
+ append("Local:");
51
+ version(local, filePath);
52
+ append("Remote:");
53
+ version(remote, filePath);
54
+ }
55
+ return parts.join("\n");
56
+ }
@@ -0,0 +1,93 @@
1
+ import type { RemoteHead, SyncBackend } from "../backends/sync-backend.js";
2
+ import type { AnySyncConfig } from "../settings/settings-types.js";
3
+ import type { Snapshot } from "../snapshot/snapshot-types.js";
4
+ import type { SyncState } from "../state/state-types.js";
5
+ import { syncStateFingerprint } from "../state/sync-state-store.js";
6
+ import { conflictArtifactFingerprint, readConflictArtifact } from "./conflict-artifacts.js";
7
+ import { type FileMergePlan, mergePathIdentity, planFileMerge } from "./file-merge-planner.js";
8
+ import { fileHashMap } from "./sync-state.js";
9
+ export interface ConflictResolution {
10
+ token: string;
11
+ group: number;
12
+ source: "local" | "remote";
13
+ stateIdentity: string;
14
+ artifactIdentity: string;
15
+ }
16
+ export async function resolveReviewedGroup(
17
+ config: AnySyncConfig,
18
+ backend: SyncBackend,
19
+ state: SyncState,
20
+ local: Snapshot,
21
+ remote: Snapshot,
22
+ head: RemoteHead,
23
+ plan: FileMergePlan,
24
+ resolution: ConflictResolution,
25
+ protectedPaths: ReadonlySet<string>,
26
+ ): Promise<FileMergePlan> {
27
+ const artifact = await readConflictArtifact(config, backend.identity, resolution.token);
28
+ const group = artifact.groups[resolution.group];
29
+ if (
30
+ conflictArtifactFingerprint(artifact) !== resolution.artifactIdentity ||
31
+ !group ||
32
+ plan.kind !== "planned" ||
33
+ !state.unresolved?.some(
34
+ (item) => item.artifact === resolution.token && JSON.stringify(item.paths) === JSON.stringify(group.paths),
35
+ ) ||
36
+ syncStateFingerprint(state) !== resolution.stateIdentity ||
37
+ state.lastObservedSnapshot !== head.snapshotId ||
38
+ !state.lastObservedRevision ||
39
+ !backend.sameRevision(state.lastObservedRevision, head.revision)
40
+ )
41
+ throw new Error("Conflict review is stale; refresh sync and review again.");
42
+ const ours = fileHashMap(local);
43
+ const theirs = fileHashMap(remote);
44
+ const oldOurs = fileHashMap(artifact.local);
45
+ const oldTheirs = fileHashMap(artifact.remote);
46
+ if (
47
+ group.paths.some(
48
+ (filePath) =>
49
+ ours[filePath] !== oldOurs[filePath] ||
50
+ theirs[filePath] !== oldTheirs[filePath] ||
51
+ state.lastFileHashes[filePath] !== artifact.state.lastFileHashes[filePath],
52
+ )
53
+ )
54
+ throw new Error("Conflict versions changed; no resolution was applied.");
55
+ if (
56
+ group.paths.some((filePath) =>
57
+ [...protectedPaths].some((value) => mergePathIdentity(value) === mergePathIdentity(filePath)),
58
+ )
59
+ )
60
+ throw new Error(
61
+ "Current session conflict cannot be resolved from the loaded session; preserve evidence and close Pi.",
62
+ );
63
+ const selected = resolution.source === "local" ? local : remote;
64
+ const paths = new Set(group.paths);
65
+ for (const snapshot of [selected, local]) {
66
+ const layout = planFileMerge({
67
+ baseline: {},
68
+ local: snapshot.files.filter((file) => paths.has(file.path)),
69
+ remote: [],
70
+ selectionCompatible: true,
71
+ });
72
+ if (layout.kind !== "planned" || layout.conflicts.length)
73
+ throw new Error(
74
+ "Selected/current local group still has path collisions; preserve evidence, repair the layout manually and refresh sync.",
75
+ );
76
+ }
77
+ const selectedFiles = new Map(selected.files.map((file) => [file.path, file]));
78
+ const decisions = plan.decisions.map((item) =>
79
+ paths.has(item.path)
80
+ ? {
81
+ kind: "accepted" as const,
82
+ path: item.path,
83
+ source: resolution.source,
84
+ file: selectedFiles.get(item.path),
85
+ }
86
+ : item,
87
+ );
88
+ return {
89
+ kind: "planned",
90
+ decisions,
91
+ conflicts: decisions.filter((item): item is Extract<typeof item, { kind: "conflict" }> => item.kind === "conflict"),
92
+ };
93
+ }
@@ -0,0 +1,72 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { AnySyncConfig } from "../settings/settings-types.js";
4
+ import type { Snapshot } from "../snapshot/snapshot-types.js";
5
+ import { syncDirectory } from "../state/json-file.js";
6
+ import type { SyncState } from "../state/state-types.js";
7
+ import { conflictDirectory, readConflictArtifact } from "./conflict-artifacts.js";
8
+ import { fileHashMap } from "./sync-state.js";
9
+ /** Only after accepted state and journal retirement, under the operation lock. Unknown/unresolved evidence is never pruned. */
10
+ export async function pruneCompletedConflicts(
11
+ config: AnySyncConfig,
12
+ backend: string,
13
+ state: SyncState,
14
+ local: Snapshot,
15
+ remote: Snapshot,
16
+ validate: () => void,
17
+ ) {
18
+ validate();
19
+ const directory = conflictDirectory(config);
20
+ let names: string[];
21
+ try {
22
+ const stat = await fs.lstat(directory);
23
+ validate();
24
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Unsafe conflict retention directory.");
25
+ names = await fs.readdir(directory);
26
+ validate();
27
+ } catch (error) {
28
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
29
+ throw error;
30
+ }
31
+ const pinned = new Set(state.unresolved?.map((group) => group.artifact) ?? []);
32
+ const ours = fileHashMap(local);
33
+ const theirs = fileHashMap(remote);
34
+ const completed: { token: string; modified: number }[] = [];
35
+ for (const name of names) {
36
+ validate();
37
+ const token = name.replace(/\.json$/u, "");
38
+ if (!/^[a-f0-9-]{36}\.json$/u.test(name) || pinned.has(token)) continue;
39
+ try {
40
+ const artifact = await readConflictArtifact(config, backend, token);
41
+ validate();
42
+ const originalLocal = fileHashMap(artifact.local);
43
+ const originalRemote = fileHashMap(artifact.remote);
44
+ if (
45
+ !artifact.groups.every((group) =>
46
+ group.paths.every(
47
+ (filePath) =>
48
+ state.lastFileHashes[filePath] === ours[filePath] &&
49
+ ours[filePath] === theirs[filePath] &&
50
+ (ours[filePath] === originalLocal[filePath] || ours[filePath] === originalRemote[filePath]),
51
+ ),
52
+ )
53
+ )
54
+ continue;
55
+ const stat = await fs.lstat(path.join(directory, name));
56
+ validate();
57
+ if (stat.isFile()) completed.push({ token, modified: stat.mtimeMs });
58
+ } catch {
59
+ validate(); /* Corrupt/unknown evidence remains. */
60
+ }
61
+ }
62
+ completed.sort((left, right) => right.modified - left.modified || left.token.localeCompare(right.token));
63
+ for (const entry of completed.slice(32)) {
64
+ validate();
65
+ await fs.rm(path.join(directory, `${entry.token}.json`));
66
+ validate();
67
+ }
68
+ if (completed.length > 32) {
69
+ await syncDirectory(directory);
70
+ validate();
71
+ }
72
+ }
@@ -0,0 +1,95 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { createSyncBackend, type SyncBackendFactory } from "../backends/backend-factory.js";
4
+ import type { CommandOptions } from "../commands/command-types.js";
5
+ import { loadConfig, syncCheckConfigFingerprint } from "../settings/config.js";
6
+ import { readStateForConfig, syncStateFingerprint } from "../state/sync-state-store.js";
7
+ import { confirmMergeReview } from "../ui/merge-review.js";
8
+ import { safeTerminalText } from "../ui/terminal-text.js";
9
+ import { conflictArtifactFingerprint, readConflictArtifact } from "./conflict-artifacts.js";
10
+ import { conflictPreview } from "./conflict-preview.js";
11
+ import { mergeSync } from "./merged-sync.js";
12
+ import { captureMutationOwner } from "./sync-local.js";
13
+ export async function showConflicts(
14
+ ctx: ExtensionContext,
15
+ options: CommandOptions,
16
+ factory: SyncBackendFactory = createSyncBackend,
17
+ ) {
18
+ const validate = captureMutationOwner(ctx, options.signal);
19
+ const config = await loadConfig(options.setup);
20
+ validate();
21
+ const state = await readStateForConfig(config);
22
+ validate();
23
+ if (!state.unresolved?.length) {
24
+ ctx.ui.notify("No recorded unresolved conflict groups.", "info");
25
+ return;
26
+ }
27
+ if (!ctx.hasUI) {
28
+ ctx.ui.notify(
29
+ `${state.unresolved.length} conflict groups remain unresolved. Review with TUI/RPC; no transfer performed.`,
30
+ "warning",
31
+ );
32
+ return;
33
+ }
34
+ const backend = await factory(config);
35
+ validate();
36
+ const labels = state.unresolved.map(
37
+ (group, index) =>
38
+ `${index + 1}: ${group.paths.length} paths · ${group.paths
39
+ .slice(0, 3)
40
+ .map((value) => truncateToWidth(safeTerminalText(value), 80))
41
+ .join(", ")}${group.paths.length > 3 ? ", …" : ""}`,
42
+ );
43
+ const choice = await ctx.ui.select("Review unresolved dependency group", [...labels, "Keep all unresolved"], {
44
+ signal: options.signal,
45
+ });
46
+ validate();
47
+ const index = labels.indexOf(choice ?? "");
48
+ if (index < 0) return;
49
+ const reference = state.unresolved[index];
50
+ if (!reference) return;
51
+ const artifact = await readConflictArtifact(config, backend.identity, reference.artifact);
52
+ validate();
53
+ const groupIndex = artifact.groups.findIndex(
54
+ (group) => JSON.stringify(group.paths) === JSON.stringify(reference.paths),
55
+ );
56
+ if (groupIndex < 0) throw new Error("Conflict reference is stale; refresh sync.");
57
+ const body = conflictPreview(artifact, reference.paths, backend.destination);
58
+ if (
59
+ !(await confirmMergeReview(
60
+ ctx,
61
+ "Review private conflict versions",
62
+ body,
63
+ options.signal,
64
+ () => {
65
+ try {
66
+ validate();
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ },
72
+ "Continue to resolution choices",
73
+ ))
74
+ )
75
+ return;
76
+ validate();
77
+ const decision = await ctx.ui.select(
78
+ "Resolve the entire reviewed group",
79
+ ["Keep unresolved", "Use local group", "Use remote group"],
80
+ { signal: options.signal },
81
+ );
82
+ validate();
83
+ if (decision !== "Use local group" && decision !== "Use remote group") return;
84
+ if (syncCheckConfigFingerprint(config) !== syncCheckConfigFingerprint(await loadConfig(options.setup)))
85
+ throw new Error("Settings changed during conflict review.");
86
+ validate();
87
+ await mergeSync(ctx, { ...options, yes: false, auto: false }, factory, {
88
+ token: reference.artifact,
89
+ group: groupIndex,
90
+ source: decision === "Use local group" ? "local" : "remote",
91
+ stateIdentity: syncStateFingerprint(state),
92
+ artifactIdentity: conflictArtifactFingerprint(artifact),
93
+ });
94
+ validate();
95
+ }
@@ -0,0 +1,73 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { AnySyncConfig } from "../settings/settings-types.js";
3
+ import type { Snapshot, SnapshotFile } from "../snapshot/snapshot-types.js";
4
+ import { readMergeAncestors } from "../state/merge-baseline-store.js";
5
+ import type { SyncState } from "../state/state-types.js";
6
+ import { type FileMergePlan, mergePathIdentity } from "./file-merge-planner.js";
7
+ import { mergeSession } from "./session-merge.js";
8
+ import { isMergeTextPath, mergeText, TEXT_MERGE_WORK_LIMIT, type TextMergeBudget } from "./text-merge.js";
9
+ export function snapshotFile(filePath: string, bytes: Buffer): SnapshotFile {
10
+ return {
11
+ path: filePath,
12
+ contentBase64: bytes.toString("base64"),
13
+ sha256: createHash("sha256").update(bytes).digest("hex"),
14
+ };
15
+ }
16
+ export async function resolveContentConflicts(
17
+ config: AnySyncConfig,
18
+ state: SyncState,
19
+ local: Snapshot,
20
+ remote: Snapshot,
21
+ plan: FileMergePlan,
22
+ protectedPaths: ReadonlySet<string>,
23
+ options: { textBudget?: TextMergeBudget; signal?: AbortSignal } = {},
24
+ ): Promise<FileMergePlan> {
25
+ if (plan.kind !== "planned" || !config.mergeContent) return plan;
26
+ options.signal?.throwIfAborted();
27
+ const budget = options.textBudget ?? { remaining: TEXT_MERGE_WORK_LIMIT };
28
+ const decisions = [...plan.decisions];
29
+ let ancestors: SnapshotFile[] = [];
30
+ try {
31
+ ancestors = (await readMergeAncestors(config, state)) ?? [];
32
+ } catch {
33
+ options.signal?.throwIfAborted();
34
+ return plan;
35
+ }
36
+ options.signal?.throwIfAborted();
37
+ const oursByPath = new Map(local.files.map((file) => [file.path, file]));
38
+ const theirsByPath = new Map(remote.files.map((file) => [file.path, file]));
39
+ const ancestorsByPath = new Map(ancestors.map((file) => [file.path, file]));
40
+ const decisionIndex = new Map(decisions.map((decision, index) => [decision.path, index]));
41
+ const protectedKeys = new Set([...protectedPaths].map(mergePathIdentity));
42
+ for (const conflict of plan.conflicts) {
43
+ options.signal?.throwIfAborted();
44
+ if (conflict.reason !== "both-changed" || protectedKeys.has(mergePathIdentity(conflict.path))) continue;
45
+ const session = conflict.path.startsWith("sessions/") && conflict.path.endsWith(".jsonl");
46
+ if (!session && (!isMergeTextPath(conflict.path) || budget.remaining === 0)) continue;
47
+ const ours = oursByPath.get(conflict.path);
48
+ const theirs = theirsByPath.get(conflict.path);
49
+ if (!ours || !theirs) continue;
50
+ const ancestor = ancestorsByPath.get(conflict.path);
51
+ const base = ancestor ? Buffer.from(ancestor.contentBase64, "base64") : undefined;
52
+ if (!base) continue;
53
+ const localBytes = Buffer.from(ours.contentBase64, "base64");
54
+ const remoteBytes = Buffer.from(theirs.contentBase64, "base64");
55
+ const bytes = session
56
+ ? mergeSession(base, localBytes, remoteBytes)
57
+ : mergeText(base, localBytes, remoteBytes, { budget, signal: options.signal });
58
+ if (!bytes) continue;
59
+ const index = decisionIndex.get(conflict.path);
60
+ if (index === undefined) continue;
61
+ decisions[index] = {
62
+ kind: "accepted",
63
+ path: conflict.path,
64
+ source: "merged",
65
+ file: snapshotFile(conflict.path, bytes),
66
+ };
67
+ }
68
+ return {
69
+ kind: "planned",
70
+ decisions,
71
+ conflicts: decisions.filter((item): item is Extract<typeof item, { kind: "conflict" }> => item.kind === "conflict"),
72
+ };
73
+ }
@@ -0,0 +1,39 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AnySyncConfig } from "../settings/settings-types.js";
3
+ import type { Snapshot } from "../snapshot/snapshot-types.js";
4
+ import type { SyncState } from "../state/state-types.js";
5
+ import { safeTerminalText } from "../ui/terminal-text.js";
6
+ import { sameLocalFields } from "./local-fields.js";
7
+ import { captureMutationOwner } from "./sync-local.js";
8
+
9
+ export async function confirmFieldMigration(
10
+ ctx: ExtensionContext,
11
+ config: AnySyncConfig,
12
+ state: SyncState,
13
+ remote: Snapshot | undefined,
14
+ force: boolean,
15
+ signal?: AbortSignal,
16
+ ) {
17
+ if (remote?.localFields !== undefined && config.localFields === undefined)
18
+ throw new Error(
19
+ "Portable snapshots require settings version 4 and explicit localFields rules on this machine; older policy must not replace local-only values.",
20
+ );
21
+ const changed =
22
+ (Boolean(state.lastAppliedSnapshot) && !sameLocalFields(state.localFields, config.localFields)) ||
23
+ (remote !== undefined && !sameLocalFields(remote.localFields, config.localFields));
24
+ if (!changed) return true;
25
+ if (!force)
26
+ throw new Error(
27
+ "Local-field policy changed; review an explicit push --force or pull --force migration. Old remote history is not erased.",
28
+ );
29
+ const validate = captureMutationOwner(ctx, signal);
30
+ if (!ctx.hasUI) throw new Error("Local-field migration requires observable TUI/RPC confirmation.");
31
+ validate();
32
+ const confirmed = await ctx.ui.confirm(
33
+ "Migrate portable settings policy?",
34
+ `New machine-local fields: ${(config.localFields ?? []).map(safeTerminalText).join(", ") || "none"}. Additions remove fields from future snapshots, not old history. Removals make local-only values portable again and a pull may replace them. The selected direction is authoritative; inspect /sync diff first. This confirmation is required even with --yes.`,
35
+ { signal },
36
+ );
37
+ validate();
38
+ return confirmed;
39
+ }
@@ -0,0 +1,143 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ import { isDeniedPath } from "../paths.js";
4
+ import type { SnapshotFile } from "../snapshot/snapshot-types.js";
5
+
6
+ /** Absence and an empty file are different states. A missing ancestor is not absence. */
7
+ export type FileVersion = SnapshotFile | undefined;
8
+ export type FileMergeDecision =
9
+ | { kind: "accepted"; path: string; source: "equal" | "local" | "remote" | "merged"; file: FileVersion }
10
+ | { kind: "conflict"; path: string; reason: "both-changed" | "protected-session" | "path-collision" };
11
+
12
+ export type FileMergePlan =
13
+ | { kind: "review"; reason: "missing-baseline" | "selection-changed" }
14
+ | { kind: "planned"; decisions: FileMergeDecision[]; conflicts: Extract<FileMergeDecision, { kind: "conflict" }>[] };
15
+
16
+ export interface FileMergeInput {
17
+ /** Undefined means unavailable; an empty map is an established empty baseline. */
18
+ baseline: Readonly<Record<string, string>> | undefined;
19
+ local: readonly SnapshotFile[];
20
+ remote: readonly SnapshotFile[];
21
+ selectionCompatible: boolean;
22
+ protectedPaths?: ReadonlySet<string>;
23
+ }
24
+
25
+ /** Pure, deterministic decisions only. This does not authorize publication or mutation. */
26
+ export function planFileMerge(input: FileMergeInput): FileMergePlan {
27
+ if (!input.selectionCompatible) return { kind: "review", reason: "selection-changed" };
28
+ if (input.baseline === undefined) return { kind: "review", reason: "missing-baseline" };
29
+ const local = indexFiles(input.local);
30
+ const remote = indexFiles(input.remote);
31
+ const baseline = input.baseline;
32
+ for (const [filePath, hash] of Object.entries(baseline)) {
33
+ validatePath(filePath);
34
+ if (!/^[a-f0-9]{64}$/u.test(hash)) throw new Error("Invalid merge baseline hash.");
35
+ }
36
+ const paths = [...new Set([...Object.keys(baseline), ...local.keys(), ...remote.keys()])].sort();
37
+ const collisions = collidingPaths(paths);
38
+ const protectedKeys = new Set([...(input.protectedPaths ?? [])].map(mergePathIdentity));
39
+ const decisions: FileMergeDecision[] = paths.map((filePath) => {
40
+ if (collisions.has(filePath)) return { kind: "conflict", path: filePath, reason: "path-collision" };
41
+ const left = local.get(filePath);
42
+ const right = remote.get(filePath);
43
+ const ancestor = Object.hasOwn(baseline, filePath) ? baseline[filePath] : undefined;
44
+ let source: "equal" | "local" | "remote";
45
+ let file: FileVersion;
46
+ if (left?.sha256 === right?.sha256) {
47
+ source = "equal";
48
+ file = left;
49
+ } else if (right?.sha256 === ancestor) {
50
+ source = "local";
51
+ file = left;
52
+ } else if (left?.sha256 === ancestor) {
53
+ source = "remote";
54
+ file = right;
55
+ } else {
56
+ return { kind: "conflict", path: filePath, reason: "both-changed" };
57
+ }
58
+ if (source === "remote" && protectedKeys.has(mergePathIdentity(filePath))) {
59
+ return { kind: "conflict", path: filePath, reason: "protected-session" };
60
+ }
61
+ return { kind: "accepted", path: filePath, source, file };
62
+ });
63
+ return {
64
+ kind: "planned",
65
+ decisions,
66
+ conflicts: decisions.filter((decision) => decision.kind === "conflict"),
67
+ };
68
+ }
69
+
70
+ function indexFiles(files: readonly SnapshotFile[]) {
71
+ const result = new Map<string, SnapshotFile>();
72
+ for (const file of files) {
73
+ validatePath(file.path);
74
+ if (result.has(file.path)) throw new Error("Duplicate merge path.");
75
+ if (
76
+ !/^[A-Za-z0-9+/]*={0,2}$/u.test(file.contentBase64) ||
77
+ file.contentBase64.length % 4 !== 0 ||
78
+ Buffer.from(file.contentBase64, "base64").toString("base64") !== file.contentBase64 ||
79
+ createHash("sha256").update(Buffer.from(file.contentBase64, "base64")).digest("hex") !== file.sha256
80
+ ) {
81
+ throw new Error("Invalid merge file content or checksum.");
82
+ }
83
+ result.set(file.path, file);
84
+ }
85
+ return result;
86
+ }
87
+
88
+ function validatePath(filePath: string) {
89
+ if (
90
+ !filePath ||
91
+ filePath === "." ||
92
+ filePath === ".." ||
93
+ filePath.startsWith("../") ||
94
+ path.posix.isAbsolute(filePath) ||
95
+ filePath.includes("\\") ||
96
+ (process.platform === "win32" &&
97
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Native Windows filename restrictions.
98
+ (/[<>:"|?*\u0001-\u001f]/u.test(filePath) ||
99
+ filePath
100
+ .split("/")
101
+ .some(
102
+ (segment) => /[ .]$/u.test(segment) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(segment),
103
+ ))) ||
104
+ path.posix.normalize(filePath) !== filePath ||
105
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Reject unsafe snapshot paths.
106
+ /[\u0000\ud800-\udfff]/u.test(filePath) ||
107
+ isDeniedPath(filePath) ||
108
+ (filePath.startsWith("sessions/") && !filePath.endsWith(".jsonl"))
109
+ ) {
110
+ throw new Error("Unsafe merge path.");
111
+ }
112
+ }
113
+
114
+ export function mergePathIdentity(filePath: string) {
115
+ // Node path strings encode invalid UTF-16 as U+FFFD; context paths can still contain it.
116
+ return Buffer.from(filePath, "utf8").toString("utf8").normalize("NFC").toLowerCase();
117
+ }
118
+
119
+ /** Conservative dependency groups: never accept a case or file/directory transition independently. */
120
+ export function collidingPaths(paths: readonly string[]) {
121
+ const byLower = new Map<string, string[]>();
122
+ for (const filePath of paths) {
123
+ const lower = mergePathIdentity(filePath);
124
+ const variants = byLower.get(lower) ?? [];
125
+ variants.push(filePath);
126
+ byLower.set(lower, variants);
127
+ }
128
+ const result = new Set<string>();
129
+ for (const variants of byLower.values()) {
130
+ if (variants.length > 1) for (const variant of variants) result.add(variant);
131
+ }
132
+ for (const filePath of paths) {
133
+ const segments = mergePathIdentity(filePath).split("/");
134
+ for (let length = 1; length < segments.length; length += 1) {
135
+ const ancestors = byLower.get(segments.slice(0, length).join("/"));
136
+ if (ancestors) {
137
+ result.add(filePath);
138
+ for (const ancestor of ancestors) result.add(ancestor);
139
+ }
140
+ }
141
+ }
142
+ return result;
143
+ }