@narumitw/pi-sync 0.49.3 → 0.49.5
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 +34 -8
- package/package.json +7 -4
- package/src/command.ts +6 -1
- package/src/config.ts +2 -3
- package/src/file-selection.ts +51 -1
- package/src/git-backend.ts +5 -2
- package/src/git-storage.ts +17 -1
- package/src/lock-policy.ts +2 -0
- package/src/lock.ts +5 -43
- package/src/lockfile-fs.ts +25 -0
- package/src/paths.ts +2 -0
- package/src/remote-selection-ui.ts +279 -0
- package/src/remote-snapshot.ts +59 -0
- package/src/s3-backend.ts +4 -0
- package/src/settings-ui.ts +16 -1
- package/src/snapshot-codec.ts +3 -0
- package/src/snapshot.ts +33 -2
- package/src/state-directory.ts +184 -0
- package/src/sync-backend.ts +3 -1
- package/src/sync-format.ts +6 -0
- package/src/sync-operations.ts +44 -10
- package/src/sync-policy.ts +183 -7
- package/src/sync.ts +81 -17
- package/src/types.ts +9 -0
- package/src/webdav-backend.ts +4 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { lstatSync } from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import lockfile from "proper-lockfile";
|
|
6
|
+
import { LOCK_GUARD_STALE_MS } from "./lock-policy.js";
|
|
7
|
+
import { LOCKFILE_FS_ADAPTER } from "./lockfile-fs.js";
|
|
8
|
+
|
|
9
|
+
const CANONICAL_DIRECTORY_NAME = "pi-sync";
|
|
10
|
+
const LEGACY_DIRECTORY_NAME = ".pisync";
|
|
11
|
+
const MIGRATION_LOCK_NAME = ".pi-sync-state-migration.lock";
|
|
12
|
+
const MIGRATION_LOCK_STALE_MS = 30_000;
|
|
13
|
+
const MIGRATION_LOCK_UPDATE_MS = 10_000;
|
|
14
|
+
|
|
15
|
+
export type StateDirectoryPreparation =
|
|
16
|
+
| { status: "ready" }
|
|
17
|
+
| { status: "migrated"; message: string }
|
|
18
|
+
| { status: "deferred"; message: string };
|
|
19
|
+
|
|
20
|
+
interface StateDirectoryGuard {
|
|
21
|
+
release: () => Promise<void>;
|
|
22
|
+
throwIfCompromised: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function stateDir() {
|
|
26
|
+
const roots = inspectStateRoots();
|
|
27
|
+
if (roots.canonical) return canonicalStateDir();
|
|
28
|
+
if (roots.legacy) return legacyStateDir();
|
|
29
|
+
return canonicalStateDir();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function legacyStateDir() {
|
|
33
|
+
return path.join(getAgentDir(), LEGACY_DIRECTORY_NAME);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function stateDirectoryMigrationNotice() {
|
|
37
|
+
const roots = inspectStateRoots();
|
|
38
|
+
if (!roots.legacy) return undefined;
|
|
39
|
+
return "Legacy pi-sync state is still stored in .pisync. Close other Pi sessions, then run /sync migrate-state to move it to pi-sync/.";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function withStateDirectoryAccess<T>(fn: () => Promise<T>): Promise<T> {
|
|
43
|
+
return runWithStateDirectoryGuard(await acquireStateDirectoryGuard(), fn);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function migrateLegacyStateDirectory(): Promise<StateDirectoryPreparation> {
|
|
47
|
+
const initial = inspectStateRoots();
|
|
48
|
+
if (initial.canonical || !initial.legacy) return { status: "ready" };
|
|
49
|
+
|
|
50
|
+
let guard: StateDirectoryGuard;
|
|
51
|
+
try {
|
|
52
|
+
guard = await acquireStateDirectoryGuard();
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (!isLockHeldError(error)) throw error;
|
|
55
|
+
return {
|
|
56
|
+
status: "deferred",
|
|
57
|
+
message:
|
|
58
|
+
"pi-sync state migration was deferred because another state user is active. Close other Pi sessions and retry.",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return runWithStateDirectoryGuard(guard, async () => {
|
|
62
|
+
const roots = inspectStateRoots();
|
|
63
|
+
if (roots.canonical || !roots.legacy) return { status: "ready" };
|
|
64
|
+
|
|
65
|
+
const legacyLock = path.join(legacyStateDir(), "lock");
|
|
66
|
+
const legacyGuardHeld = await lockfile.check(legacyLock, {
|
|
67
|
+
fs: LOCKFILE_FS_ADAPTER,
|
|
68
|
+
lockfilePath: `${legacyLock}.guard`,
|
|
69
|
+
realpath: false,
|
|
70
|
+
stale: LOCK_GUARD_STALE_MS,
|
|
71
|
+
});
|
|
72
|
+
if (legacyGuardHeld || (await pathExists(legacyLock))) {
|
|
73
|
+
return {
|
|
74
|
+
status: "deferred",
|
|
75
|
+
message:
|
|
76
|
+
"pi-sync state migration was deferred because the legacy directory is busy. Close other Pi sessions, clear any confirmed stale sync lock, and retry.",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
guard.throwIfCompromised();
|
|
80
|
+
await fs.rename(legacyStateDir(), canonicalStateDir());
|
|
81
|
+
guard.throwIfCompromised();
|
|
82
|
+
inspectStateRoots();
|
|
83
|
+
return {
|
|
84
|
+
status: "migrated",
|
|
85
|
+
message: `Migrated pi-sync state from ${legacyStateDir()} to ${canonicalStateDir()}.`,
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function acquireStateDirectoryGuard(): Promise<StateDirectoryGuard> {
|
|
91
|
+
let compromisedError: Error | undefined;
|
|
92
|
+
const release = await lockfile.lock(getAgentDir(), {
|
|
93
|
+
fs: LOCKFILE_FS_ADAPTER,
|
|
94
|
+
lockfilePath: migrationLockPath(),
|
|
95
|
+
realpath: false,
|
|
96
|
+
stale: MIGRATION_LOCK_STALE_MS,
|
|
97
|
+
update: MIGRATION_LOCK_UPDATE_MS,
|
|
98
|
+
retries: { retries: 20, minTimeout: 10, maxTimeout: 50 },
|
|
99
|
+
onCompromised: (error) => {
|
|
100
|
+
compromisedError = error;
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
release,
|
|
105
|
+
throwIfCompromised: () => {
|
|
106
|
+
if (compromisedError) throw compromisedError;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function runWithStateDirectoryGuard<T>(guard: StateDirectoryGuard, fn: () => Promise<T>) {
|
|
112
|
+
let result: T | undefined;
|
|
113
|
+
let failed = false;
|
|
114
|
+
let failure: unknown;
|
|
115
|
+
try {
|
|
116
|
+
guard.throwIfCompromised();
|
|
117
|
+
result = await fn();
|
|
118
|
+
guard.throwIfCompromised();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
failed = true;
|
|
121
|
+
failure = error;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
await guard.release();
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (!failed) {
|
|
127
|
+
failed = true;
|
|
128
|
+
failure = error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (failed) throw failure;
|
|
132
|
+
return result as T;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isLockHeldError(error: unknown) {
|
|
136
|
+
return (error as NodeJS.ErrnoException).code === "ELOCKED";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function canonicalStateDir() {
|
|
140
|
+
return path.join(getAgentDir(), CANONICAL_DIRECTORY_NAME);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function migrationLockPath() {
|
|
144
|
+
return path.join(getAgentDir(), MIGRATION_LOCK_NAME);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function inspectStateRoots() {
|
|
148
|
+
const canonical = isDirectoryRoot(canonicalStateDir(), "canonical");
|
|
149
|
+
const legacy = isDirectoryRoot(legacyStateDir(), "legacy");
|
|
150
|
+
if (canonical && legacy) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`Both legacy ${legacyStateDir()} and canonical ${canonicalStateDir()} pi-sync state directories exist. Close all Pi sessions and reconcile the directories manually; pi-sync will not merge or delete either directory.`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return { canonical, legacy };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isDirectoryRoot(directory: string, label: string) {
|
|
159
|
+
try {
|
|
160
|
+
const entry = lstatSync(directory);
|
|
161
|
+
if (entry.isSymbolicLink()) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Refusing to use ${label} pi-sync state directory symbolic link: ${directory}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (!entry.isDirectory()) {
|
|
167
|
+
throw new Error(`${label} pi-sync state path is not a directory: ${directory}`);
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function pathExists(filePath: string) {
|
|
177
|
+
try {
|
|
178
|
+
await fs.lstat(filePath);
|
|
179
|
+
return true;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
package/src/sync-backend.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Snapshot } from "./types.js";
|
|
1
|
+
import type { Snapshot, SnapshotSelection } from "./types.js";
|
|
2
2
|
|
|
3
3
|
export type PublicationCapability =
|
|
4
4
|
| "read-check-write-verify"
|
|
@@ -15,6 +15,8 @@ export interface RemoteHead {
|
|
|
15
15
|
createdAt: string;
|
|
16
16
|
machine: string;
|
|
17
17
|
syncSessions: boolean;
|
|
18
|
+
/** Validated lightweight projection; revalidate it against the immutable snapshot before adoption. */
|
|
19
|
+
selection?: SnapshotSelection;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export interface RemoteHistoryEntry {
|
package/src/sync-format.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { agentDir } from "./config.js";
|
|
2
2
|
import type { PublicationCapability, RemoteHead } from "./sync-backend.js";
|
|
3
|
+
import { inspectRemoteSelection } from "./sync-policy.js";
|
|
3
4
|
import { fileHashMap } from "./sync-state.js";
|
|
4
5
|
import type { CommonSyncConfig, Snapshot } from "./types.js";
|
|
5
6
|
|
|
@@ -50,6 +51,11 @@ export function formatPushSummary(
|
|
|
50
51
|
`Sessions: ${upload.syncSessions ? "included — may contain private conversations" : "not included"}`,
|
|
51
52
|
head ? `Remote latest: ${head.snapshotId}` : "Remote latest: empty",
|
|
52
53
|
"Publication effect: the backend's active head will reference the new immutable snapshot.",
|
|
54
|
+
...(remote && inspectRemoteSelection(config.include, remote).kind === "different"
|
|
55
|
+
? [
|
|
56
|
+
"Included-content policy effect: replace the differing remote selection with this setup's local selection.",
|
|
57
|
+
]
|
|
58
|
+
: []),
|
|
53
59
|
formatPublicationPreview(remote, upload),
|
|
54
60
|
preservedRemoteFileCount > 0
|
|
55
61
|
? `Possible secrets in locally managed files were scanned before this prompt; ${preservedRemoteFileCount} preserved remote file(s) were not rescanned.`
|
package/src/sync-operations.ts
CHANGED
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
writeStateForConfig,
|
|
12
12
|
} from "./config.js";
|
|
13
13
|
import { inspectLock, isLockGuardHeld, isStaleLock, withLock } from "./lock.js";
|
|
14
|
+
import {
|
|
15
|
+
formatRemoteSelectionStatus,
|
|
16
|
+
readSnapshotForHead,
|
|
17
|
+
requireCompatibleRemoteSelection,
|
|
18
|
+
} from "./remote-snapshot.js";
|
|
14
19
|
import {
|
|
15
20
|
createSnapshot,
|
|
16
21
|
filterSnapshotForConfigPolicy,
|
|
@@ -41,6 +46,7 @@ import {
|
|
|
41
46
|
publicationCapabilityDescription,
|
|
42
47
|
safeTerminalText,
|
|
43
48
|
} from "./sync-format.js";
|
|
49
|
+
import { inspectRemoteSelection, RemoteSelectionMismatchError } from "./sync-policy.js";
|
|
44
50
|
import {
|
|
45
51
|
canPullRemoteSessionsOnFirstSync,
|
|
46
52
|
canPullRemoteSettingsOnFirstSync,
|
|
@@ -124,6 +130,9 @@ export async function status(
|
|
|
124
130
|
throwIfAborted(options.signal);
|
|
125
131
|
const head = await backend.readHead(options.signal);
|
|
126
132
|
throwIfAborted(options.signal);
|
|
133
|
+
const selectionState = head
|
|
134
|
+
? inspectRemoteSelection(config.include, { selection: head.selection, files: [] })
|
|
135
|
+
: undefined;
|
|
127
136
|
const localChanged = hasLocalChanges(local, state, config);
|
|
128
137
|
|
|
129
138
|
const remoteText = head
|
|
@@ -143,12 +152,15 @@ export async function status(
|
|
|
143
152
|
`included content: ${config.include.join(", ") || "none"}`,
|
|
144
153
|
`sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`,
|
|
145
154
|
remoteText,
|
|
155
|
+
formatRemoteSelectionStatus(selectionState),
|
|
146
156
|
`local files: ${local.files.length}`,
|
|
147
157
|
`local changed since last sync: ${localChanged ? "yes" : "no"}`,
|
|
148
158
|
`remote changed since last sync: ${remoteChanged ? "yes" : "no"}`,
|
|
149
159
|
...warnings,
|
|
150
160
|
].join("\n"),
|
|
151
|
-
localChanged || remoteChanged || warnings.length > 0
|
|
161
|
+
localChanged || remoteChanged || selectionState?.kind === "different" || warnings.length > 0
|
|
162
|
+
? "warning"
|
|
163
|
+
: "info",
|
|
152
164
|
);
|
|
153
165
|
}
|
|
154
166
|
|
|
@@ -166,7 +178,12 @@ export async function diff(
|
|
|
166
178
|
snapshotOptionsForContext(ctx, config),
|
|
167
179
|
);
|
|
168
180
|
throwIfAborted(options.signal);
|
|
169
|
-
const { snapshot: remote } = await readRemoteSnapshot(
|
|
181
|
+
const { snapshot: remote, selectionState } = await readRemoteSnapshot(
|
|
182
|
+
backend,
|
|
183
|
+
config,
|
|
184
|
+
options.signal,
|
|
185
|
+
{ allowSelectionDifference: true },
|
|
186
|
+
);
|
|
170
187
|
throwIfAborted(options.signal);
|
|
171
188
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
172
189
|
|
|
@@ -177,9 +194,10 @@ export async function diff(
|
|
|
177
194
|
`storage location: ${safeTerminalText(backend.destination)}`,
|
|
178
195
|
`included content: ${config.include.join(", ") || "none"}`,
|
|
179
196
|
`sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`,
|
|
197
|
+
formatRemoteSelectionStatus(selectionState),
|
|
180
198
|
...warnings,
|
|
181
199
|
].join("\n");
|
|
182
|
-
const level = warnings.length > 0 ? "warning" : "info";
|
|
200
|
+
const level = warnings.length > 0 || selectionState?.kind === "different" ? "warning" : "info";
|
|
183
201
|
if (!remote) {
|
|
184
202
|
ctx.ui.notify(
|
|
185
203
|
`${header}\n\n${formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local)}`,
|
|
@@ -299,6 +317,18 @@ export async function push(
|
|
|
299
317
|
state,
|
|
300
318
|
options.signal,
|
|
301
319
|
);
|
|
320
|
+
if (
|
|
321
|
+
!options.force &&
|
|
322
|
+
!remoteForUpload &&
|
|
323
|
+
head?.selection &&
|
|
324
|
+
inspectRemoteSelection(config.include, { selection: head.selection, files: [] }).kind ===
|
|
325
|
+
"different"
|
|
326
|
+
) {
|
|
327
|
+
remoteForUpload = await readSnapshotForHead(backend, head, options.signal);
|
|
328
|
+
}
|
|
329
|
+
if (remoteForUpload && !options.force) {
|
|
330
|
+
requireCompatibleRemoteSelection(config, remoteForUpload);
|
|
331
|
+
}
|
|
302
332
|
if (
|
|
303
333
|
remoteChangedSinceState(head, state, config, (left, right) =>
|
|
304
334
|
backend.sameRevision(left, right),
|
|
@@ -886,16 +916,20 @@ async function readRemoteSnapshot(
|
|
|
886
916
|
backend: SyncBackend,
|
|
887
917
|
config: AnySyncConfig,
|
|
888
918
|
signal?: AbortSignal,
|
|
919
|
+
options: { allowSelectionDifference?: boolean } = {},
|
|
889
920
|
) {
|
|
890
921
|
const head = await backend.readHead(signal);
|
|
891
|
-
if (!head) return { head: undefined, snapshot: undefined };
|
|
892
|
-
const snapshot = await backend
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
);
|
|
922
|
+
if (!head) return { head: undefined, snapshot: undefined, selectionState: undefined };
|
|
923
|
+
const snapshot = await readSnapshotForHead(backend, head, signal);
|
|
924
|
+
const selectionState = inspectRemoteSelection(config.include, snapshot);
|
|
925
|
+
if (!options.allowSelectionDifference && selectionState.kind === "different") {
|
|
926
|
+
throw new RemoteSelectionMismatchError(config.include, selectionState.include);
|
|
897
927
|
}
|
|
898
|
-
return {
|
|
928
|
+
return {
|
|
929
|
+
head,
|
|
930
|
+
snapshot: filterSnapshotForConfigPolicy(snapshot, config),
|
|
931
|
+
selectionState,
|
|
932
|
+
};
|
|
899
933
|
}
|
|
900
934
|
|
|
901
935
|
async function confirmPush(
|
package/src/sync-policy.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { isDeniedPath, toPosix } from "./paths.js";
|
|
3
|
+
import type { Snapshot, SnapshotSelection } from "./types.js";
|
|
3
4
|
|
|
4
5
|
export const BUILT_IN_SYNC_ROOTS = [
|
|
5
6
|
"settings.json",
|
|
@@ -15,6 +16,15 @@ export const BUILT_IN_SYNC_ROOTS = [
|
|
|
15
16
|
|
|
16
17
|
export const DEFAULT_SYNC_INCLUDE = [...BUILT_IN_SYNC_ROOTS] as const;
|
|
17
18
|
export type BuiltInSyncFile = (typeof BUILT_IN_SYNC_ROOTS)[number];
|
|
19
|
+
const SNAPSHOT_SELECTION_VERSION = 1;
|
|
20
|
+
const MAX_SYNC_INCLUDE_ITEMS = 1_024;
|
|
21
|
+
const MAX_SYNC_INCLUDE_PATH_BYTES = 4_096;
|
|
22
|
+
const MAX_SYNC_INCLUDE_TOTAL_BYTES = 256 * 1_024;
|
|
23
|
+
|
|
24
|
+
interface IncludePathNode {
|
|
25
|
+
selected?: string;
|
|
26
|
+
children: Map<string, IncludePathNode>;
|
|
27
|
+
}
|
|
18
28
|
|
|
19
29
|
const BUILT_IN_BY_LOWER = new Map<string, BuiltInSyncFile>(
|
|
20
30
|
BUILT_IN_SYNC_ROOTS.map((fileName) => [fileName.toLowerCase(), fileName]),
|
|
@@ -34,12 +44,31 @@ export function normalizeSyncInclude(value: unknown): string[] {
|
|
|
34
44
|
if (!Array.isArray(value)) {
|
|
35
45
|
throw new Error("Invalid pi-sync settings: sync.include must be an array.");
|
|
36
46
|
}
|
|
47
|
+
if (value.length > MAX_SYNC_INCLUDE_ITEMS) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Invalid pi-sync settings: sync.include has too many items; limit: ${MAX_SYNC_INCLUDE_ITEMS}.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
37
52
|
const result: string[] = [];
|
|
38
53
|
const seen = new Set<string>();
|
|
54
|
+
const pathRoot: IncludePathNode = { children: new Map() };
|
|
55
|
+
let totalBytes = 0;
|
|
39
56
|
for (const item of value) {
|
|
40
57
|
if (typeof item !== "string") {
|
|
41
58
|
throw new Error("Invalid pi-sync settings: sync.include items must be strings.");
|
|
42
59
|
}
|
|
60
|
+
const itemBytes = Buffer.byteLength(item, "utf8");
|
|
61
|
+
if (itemBytes > MAX_SYNC_INCLUDE_PATH_BYTES) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Invalid pi-sync settings: sync.include item is too long; limit: ${MAX_SYNC_INCLUDE_PATH_BYTES} bytes.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
totalBytes += itemBytes;
|
|
67
|
+
if (totalBytes > MAX_SYNC_INCLUDE_TOTAL_BYTES) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Invalid pi-sync settings: sync.include is too large; limit: ${MAX_SYNC_INCLUDE_TOTAL_BYTES} bytes.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
43
72
|
const trimmed = item.trim();
|
|
44
73
|
const builtIn = BUILT_IN_BY_LOWER.get(trimmed.toLowerCase());
|
|
45
74
|
const normalized = builtIn ?? (trimmed.toLowerCase() === "sessions" ? "sessions" : trimmed);
|
|
@@ -48,19 +77,34 @@ export function normalizeSyncInclude(value: unknown): string[] {
|
|
|
48
77
|
if (seen.has(identity)) {
|
|
49
78
|
throw new Error(`Invalid pi-sync settings: duplicate sync.include item: ${item}`);
|
|
50
79
|
}
|
|
51
|
-
|
|
52
|
-
if (identity.startsWith(`${previous}/`) || previous.startsWith(`${identity}/`)) {
|
|
53
|
-
throw new Error(
|
|
54
|
-
`Invalid pi-sync settings: overlapping sync.include items are ambiguous: ${item}`,
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
80
|
+
addIncludePath(pathRoot, identity, item);
|
|
58
81
|
seen.add(identity);
|
|
59
82
|
result.push(normalized);
|
|
60
83
|
}
|
|
61
84
|
return result;
|
|
62
85
|
}
|
|
63
86
|
|
|
87
|
+
function addIncludePath(root: IncludePathNode, identity: string, source: string) {
|
|
88
|
+
let node = root;
|
|
89
|
+
for (const segment of identity.split("/")) {
|
|
90
|
+
if (node.selected !== undefined) throwOverlappingInclude(source);
|
|
91
|
+
let child = node.children.get(segment);
|
|
92
|
+
if (!child) {
|
|
93
|
+
child = { children: new Map() };
|
|
94
|
+
node.children.set(segment, child);
|
|
95
|
+
}
|
|
96
|
+
node = child;
|
|
97
|
+
}
|
|
98
|
+
if (node.children.size > 0) throwOverlappingInclude(source);
|
|
99
|
+
node.selected = identity;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function throwOverlappingInclude(item: string): never {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Invalid pi-sync settings: overlapping sync.include items are ambiguous: ${item}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
64
108
|
function validateAgentRelativeInclude(value: string) {
|
|
65
109
|
const normalized = toPosix(value);
|
|
66
110
|
const topLevel = normalized.split("/")[0]?.toLowerCase();
|
|
@@ -89,6 +133,138 @@ function validateAgentRelativeInclude(value: string) {
|
|
|
89
133
|
}
|
|
90
134
|
}
|
|
91
135
|
|
|
136
|
+
export function portableSnapshotSelection(value: unknown): SnapshotSelection {
|
|
137
|
+
const selection = value as Partial<SnapshotSelection> | null;
|
|
138
|
+
if (
|
|
139
|
+
!selection ||
|
|
140
|
+
typeof selection !== "object" ||
|
|
141
|
+
Array.isArray(selection) ||
|
|
142
|
+
selection.version !== SNAPSHOT_SELECTION_VERSION ||
|
|
143
|
+
!Object.hasOwn(selection, "include") ||
|
|
144
|
+
Object.keys(selection).some((key) => key !== "version" && key !== "include")
|
|
145
|
+
) {
|
|
146
|
+
throw new Error("Invalid snapshot selection policy.");
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
version: SNAPSHOT_SELECTION_VERSION,
|
|
150
|
+
include: normalizeSyncInclude(selection.include),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function snapshotSelectionInclude(snapshot: Pick<Snapshot, "selection">) {
|
|
155
|
+
return snapshot.selection === undefined
|
|
156
|
+
? undefined
|
|
157
|
+
: portableSnapshotSelection(snapshot.selection).include;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function selectionForSnapshot(include: unknown): SnapshotSelection {
|
|
161
|
+
return { version: SNAPSHOT_SELECTION_VERSION, include: normalizeSyncInclude(include) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function sameSyncInclude(left: unknown, right: unknown) {
|
|
165
|
+
const normalizedLeft = normalizeSyncInclude(left);
|
|
166
|
+
const normalizedRight = normalizeSyncInclude(right);
|
|
167
|
+
return (
|
|
168
|
+
normalizedLeft.length === normalizedRight.length &&
|
|
169
|
+
normalizedLeft.every((item, index) => item === normalizedRight[index])
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function compareSyncInclude(local: unknown, remote: unknown) {
|
|
174
|
+
const localInclude = normalizeSyncInclude(local);
|
|
175
|
+
const remoteInclude = normalizeSyncInclude(remote);
|
|
176
|
+
const localSet = new Set(localInclude);
|
|
177
|
+
const remoteSet = new Set(remoteInclude);
|
|
178
|
+
return {
|
|
179
|
+
same: sameSyncInclude(localInclude, remoteInclude),
|
|
180
|
+
remoteOnly: remoteInclude.filter((item) => !localSet.has(item)),
|
|
181
|
+
localOnly: localInclude.filter((item) => !remoteSet.has(item)),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export type RemoteSelectionState =
|
|
186
|
+
| { kind: "legacy"; discovered: string[] }
|
|
187
|
+
| { kind: "same"; include: string[] }
|
|
188
|
+
| {
|
|
189
|
+
kind: "different";
|
|
190
|
+
include: string[];
|
|
191
|
+
remoteOnly: string[];
|
|
192
|
+
localOnly: string[];
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export function inspectRemoteSelection(
|
|
196
|
+
localInclude: unknown,
|
|
197
|
+
snapshot: Pick<Snapshot, "selection" | "files">,
|
|
198
|
+
): RemoteSelectionState {
|
|
199
|
+
const remoteInclude = snapshotSelectionInclude(snapshot);
|
|
200
|
+
if (!remoteInclude) {
|
|
201
|
+
return { kind: "legacy", discovered: discoverLegacySnapshotInclude(snapshot) };
|
|
202
|
+
}
|
|
203
|
+
const comparison = compareSyncInclude(localInclude, remoteInclude);
|
|
204
|
+
return comparison.same
|
|
205
|
+
? { kind: "same", include: remoteInclude }
|
|
206
|
+
: { kind: "different", include: remoteInclude, ...comparison };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export class RemoteSelectionMismatchError extends Error {
|
|
210
|
+
readonly localInclude: string[];
|
|
211
|
+
readonly remoteInclude: string[];
|
|
212
|
+
|
|
213
|
+
constructor(localInclude: unknown, remoteInclude: unknown) {
|
|
214
|
+
const local = normalizeSyncInclude(localInclude);
|
|
215
|
+
const remote = normalizeSyncInclude(remoteInclude);
|
|
216
|
+
super(
|
|
217
|
+
"Remote included content differs from this sync setup. Review Remote included content in /sync Settings, or use a reviewed force push to keep local selection.",
|
|
218
|
+
);
|
|
219
|
+
this.name = "RemoteSelectionMismatchError";
|
|
220
|
+
this.localInclude = local;
|
|
221
|
+
this.remoteInclude = remote;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function discoverLegacySnapshotInclude(snapshot: Pick<Snapshot, "files">) {
|
|
226
|
+
const builtIns = new Set<BuiltInSyncFile>();
|
|
227
|
+
const custom = new Set<string>();
|
|
228
|
+
let sessions = false;
|
|
229
|
+
for (const file of snapshot.files) {
|
|
230
|
+
const normalized = toPosix(file.path);
|
|
231
|
+
if (
|
|
232
|
+
!normalized ||
|
|
233
|
+
file.path.includes("\\") ||
|
|
234
|
+
normalized.length > 4096 ||
|
|
235
|
+
normalized.startsWith("../") ||
|
|
236
|
+
path.posix.isAbsolute(normalized) ||
|
|
237
|
+
path.posix.normalize(normalized) !== normalized ||
|
|
238
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Ignore unsafe legacy paths.
|
|
239
|
+
/[\u0000-\u001f\u007f-\u009f]/u.test(normalized) ||
|
|
240
|
+
isDeniedPath(normalized)
|
|
241
|
+
) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const [topLevel, ...rest] = normalized.split("/");
|
|
245
|
+
if (!topLevel) continue;
|
|
246
|
+
if (topLevel === "sessions" && rest.length > 0) {
|
|
247
|
+
sessions = true;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const builtIn = BUILT_IN_BY_LOWER.get(topLevel.toLowerCase());
|
|
251
|
+
if (
|
|
252
|
+
builtIn &&
|
|
253
|
+
((TOP_LEVEL_DIRS.has(builtIn) && rest.length > 0) ||
|
|
254
|
+
(!TOP_LEVEL_DIRS.has(builtIn) && rest.length === 0))
|
|
255
|
+
) {
|
|
256
|
+
builtIns.add(builtIn);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (isSafeCustomIncludePath(topLevel)) custom.add(topLevel);
|
|
260
|
+
}
|
|
261
|
+
return [
|
|
262
|
+
...BUILT_IN_SYNC_ROOTS.filter((item) => builtIns.has(item)),
|
|
263
|
+
...[...custom].sort((left, right) => left.localeCompare(right)),
|
|
264
|
+
...(sessions ? ["sessions"] : []),
|
|
265
|
+
];
|
|
266
|
+
}
|
|
267
|
+
|
|
92
268
|
export function syncIncludeSelection(value: unknown) {
|
|
93
269
|
const include = normalizeSyncInclude(value);
|
|
94
270
|
const builtIns = include.filter((item): item is BuiltInSyncFile =>
|