@narumitw/pi-sync 0.31.0 → 0.34.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.
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { isDeniedPath, toPosix } from "./paths.js";
3
3
 
4
- export const DEFAULT_SYNC_FILES = [
4
+ export const BUILT_IN_SYNC_ROOTS = [
5
5
  "settings.json",
6
6
  "keybindings.json",
7
7
  "models.json",
@@ -13,100 +13,148 @@ export const DEFAULT_SYNC_FILES = [
13
13
  "extensions",
14
14
  ] as const;
15
15
 
16
- export type BuiltInSyncFile = (typeof DEFAULT_SYNC_FILES)[number];
16
+ export const DEFAULT_SYNC_INCLUDE = [...BUILT_IN_SYNC_ROOTS] as const;
17
+ export type BuiltInSyncFile = (typeof BUILT_IN_SYNC_ROOTS)[number];
17
18
 
18
19
  const BUILT_IN_BY_LOWER = new Map<string, BuiltInSyncFile>(
19
- DEFAULT_SYNC_FILES.map((fileName) => [fileName.toLowerCase(), fileName]),
20
+ BUILT_IN_SYNC_ROOTS.map((fileName) => [fileName.toLowerCase(), fileName]),
20
21
  );
21
22
  const TOP_LEVEL_FILE_PATHS = new Map<string, string>(
22
- DEFAULT_SYNC_FILES.filter((fileName) => fileName.includes(".")).map((fileName) => [
23
+ BUILT_IN_SYNC_ROOTS.filter((fileName) => fileName.includes(".")).map((fileName) => [
23
24
  fileName.toLowerCase(),
24
25
  fileName,
25
26
  ]),
26
27
  );
27
- const TOP_LEVEL_FILE_NAMES = new Set<string>(TOP_LEVEL_FILE_PATHS.keys());
28
28
  const TOP_LEVEL_DIRS = new Set<string>(
29
- DEFAULT_SYNC_FILES.filter((fileName) => !fileName.includes(".")),
29
+ BUILT_IN_SYNC_ROOTS.filter((fileName) => !fileName.includes(".")),
30
30
  );
31
31
  const RESERVED_TOP_LEVEL_NAMES = new Set<string>([...TOP_LEVEL_DIRS, "sessions"]);
32
32
 
33
- export function normalizeSyncFiles(value: unknown): BuiltInSyncFile[] {
34
- if (value === undefined) return [...DEFAULT_SYNC_FILES];
35
- if (!Array.isArray(value)) throw new Error("syncFiles must be an array of built-in file names.");
36
-
37
- const result: BuiltInSyncFile[] = [];
33
+ export function normalizeSyncInclude(value: unknown): string[] {
34
+ if (!Array.isArray(value)) {
35
+ throw new Error("Invalid pi-sync settings: sync.include must be an array.");
36
+ }
37
+ const result: string[] = [];
38
38
  const seen = new Set<string>();
39
39
  for (const item of value) {
40
- if (typeof item !== "string") throw new Error("syncFiles items must be strings.");
41
- const canonical = BUILT_IN_BY_LOWER.get(item.trim().toLowerCase());
42
- if (!canonical) throw new Error(`Unknown syncFiles item: ${item}`);
43
- const lower = canonical.toLowerCase();
44
- if (seen.has(lower)) continue;
45
- seen.add(lower);
46
- result.push(canonical);
40
+ if (typeof item !== "string") {
41
+ throw new Error("Invalid pi-sync settings: sync.include items must be strings.");
42
+ }
43
+ const trimmed = item.trim();
44
+ const builtIn = BUILT_IN_BY_LOWER.get(trimmed.toLowerCase());
45
+ const normalized = builtIn ?? (trimmed.toLowerCase() === "sessions" ? "sessions" : trimmed);
46
+ if (!builtIn && normalized !== "sessions") validateAgentRelativeInclude(normalized);
47
+ const identity = normalized.toLowerCase();
48
+ if (seen.has(identity)) {
49
+ throw new Error(`Invalid pi-sync settings: duplicate sync.include item: ${item}`);
50
+ }
51
+ for (const previous of seen) {
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
+ }
58
+ seen.add(identity);
59
+ result.push(normalized);
47
60
  }
48
61
  return result;
49
62
  }
50
63
 
51
- export function normalizeExtraFiles(value: unknown) {
52
- if (!Array.isArray(value)) return [];
53
- const seen = new Set<string>();
54
- return value
55
- .filter((item): item is string => typeof item === "string")
56
- .map((item) => item.trim())
57
- .filter((item) => {
58
- const lower = item.toLowerCase();
59
- if (
60
- item === "" ||
61
- item === "." ||
62
- item === ".." ||
63
- item.includes("/") ||
64
- item.includes("\\") ||
65
- TOP_LEVEL_FILE_NAMES.has(lower) ||
66
- isDeniedPath(item) ||
67
- RESERVED_TOP_LEVEL_NAMES.has(lower) ||
68
- seen.has(lower)
69
- ) {
70
- return false;
71
- }
72
- seen.add(lower);
73
- return true;
74
- });
64
+ function validateAgentRelativeInclude(value: string) {
65
+ const normalized = toPosix(value);
66
+ const topLevel = normalized.split("/")[0]?.toLowerCase();
67
+ if (
68
+ !normalized ||
69
+ normalized === "." ||
70
+ normalized === ".." ||
71
+ normalized.startsWith("../") ||
72
+ path.posix.isAbsolute(normalized) ||
73
+ normalized.includes("\\") ||
74
+ path.posix.normalize(normalized) !== normalized ||
75
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Include paths cannot contain controls.
76
+ /[\u0000-\u001f\u007f-\u009f]/u.test(normalized)
77
+ ) {
78
+ throw new Error(
79
+ `Invalid pi-sync settings: sync.include item must be a safe agent-relative path: ${value}`,
80
+ );
81
+ }
82
+ if (isDeniedPath(normalized)) {
83
+ throw new Error(`Invalid pi-sync settings: ${value} cannot be synced.`);
84
+ }
85
+ if (topLevel && RESERVED_TOP_LEVEL_NAMES.has(topLevel)) {
86
+ throw new Error(
87
+ `Invalid pi-sync settings: use the canonical ${topLevel} root instead of a nested sync.include path.`,
88
+ );
89
+ }
90
+ }
91
+
92
+ export function syncIncludeSelection(value: unknown) {
93
+ const include = normalizeSyncInclude(value);
94
+ const builtIns = include.filter((item): item is BuiltInSyncFile =>
95
+ BUILT_IN_BY_LOWER.has(item.toLowerCase()),
96
+ );
97
+ const custom = include.filter(
98
+ (item) => item !== "sessions" && !BUILT_IN_BY_LOWER.has(item.toLowerCase()),
99
+ );
100
+ return { include, builtIns, custom, sessions: include.includes("sessions") };
101
+ }
102
+
103
+ export function customIncludePathsByLower(value: unknown) {
104
+ return new Map(
105
+ syncIncludeSelection(value).custom.map((relativePath) => [
106
+ relativePath.toLowerCase(),
107
+ relativePath,
108
+ ]),
109
+ );
75
110
  }
76
111
 
77
- export function extraFilePathsByLower(value: unknown) {
78
- return new Map(normalizeExtraFiles(value).map((fileName) => [fileName.toLowerCase(), fileName]));
112
+ export interface SyncSelectionConfig {
113
+ include?: unknown;
114
+ syncFiles?: unknown;
115
+ syncSessions?: boolean;
116
+ extraFiles?: unknown;
79
117
  }
80
118
 
81
- export function selectedSyncFileSet(value: unknown) {
82
- return new Set(normalizeSyncFiles(value));
119
+ export function includeFromSelectionConfig(config: SyncSelectionConfig) {
120
+ if (config.include !== undefined) return normalizeSyncInclude(config.include);
121
+ return [
122
+ ...normalizeSyncFiles(config.syncFiles),
123
+ ...normalizeExtraFiles(config.extraFiles),
124
+ ...(config.syncSessions ? ["sessions"] : []),
125
+ ];
83
126
  }
84
127
 
85
128
  export function isConfiguredSnapshotPath(
86
129
  relativePath: string,
87
- config: { syncFiles?: unknown; syncSessions: boolean },
88
- extraFiles: Set<string>,
130
+ config: SyncSelectionConfig,
131
+ _legacyExtraFiles?: Set<string>,
89
132
  ) {
90
133
  const normalized = toPosix(relativePath);
91
- if (normalized.startsWith("sessions/")) return config.syncSessions;
92
- const selected = selectedSyncFileSet(config.syncFiles);
134
+ const selection = syncIncludeSelection(includeFromSelectionConfig(config));
135
+ if (normalized.startsWith("sessions/")) return selection.sessions;
136
+ const lower = normalized.toLowerCase();
93
137
  if (!normalized.includes("/")) {
94
- const lower = normalized.toLowerCase();
95
138
  const builtIn = BUILT_IN_BY_LOWER.get(lower);
96
- return builtIn ? selected.has(builtIn) && !TOP_LEVEL_DIRS.has(builtIn) : extraFiles.has(lower);
139
+ if (builtIn) return selection.builtIns.includes(builtIn) && !TOP_LEVEL_DIRS.has(builtIn);
97
140
  }
98
141
  const topLevel = normalized.slice(0, normalized.indexOf("/"));
99
- return selected.has(topLevel as BuiltInSyncFile) && TOP_LEVEL_DIRS.has(topLevel);
142
+ if (selection.builtIns.includes(topLevel as BuiltInSyncFile) && TOP_LEVEL_DIRS.has(topLevel)) {
143
+ return true;
144
+ }
145
+ return selection.custom.some((candidate) => {
146
+ const candidateLower = candidate.toLowerCase();
147
+ return lower === candidateLower || lower.startsWith(`${candidateLower}/`);
148
+ });
100
149
  }
101
150
 
102
151
  export function canonicalSnapshotPathForConfig(
103
152
  relativePath: string,
104
- extraFilePaths: Map<string, string>,
153
+ includePaths: Map<string, string>,
105
154
  ) {
106
155
  const normalized = toPosix(relativePath);
107
- if (normalized.includes("/")) return normalized;
108
156
  const lower = normalized.toLowerCase();
109
- return TOP_LEVEL_FILE_PATHS.get(lower) ?? extraFilePaths.get(lower) ?? normalized;
157
+ return TOP_LEVEL_FILE_PATHS.get(lower) ?? includePaths.get(lower) ?? normalized;
110
158
  }
111
159
 
112
160
  export function isPreservableUnmanagedSnapshotPath(relativePath: string) {
@@ -115,15 +163,43 @@ export function isPreservableUnmanagedSnapshotPath(relativePath: string) {
115
163
  if (normalized.startsWith("sessions/")) return normalized.endsWith(".jsonl");
116
164
  if (!normalized.includes("/")) {
117
165
  const lower = normalized.toLowerCase();
118
- return TOP_LEVEL_FILE_NAMES.has(lower) || !RESERVED_TOP_LEVEL_NAMES.has(lower);
166
+ return TOP_LEVEL_FILE_PATHS.has(lower) || !RESERVED_TOP_LEVEL_NAMES.has(lower);
119
167
  }
120
- return TOP_LEVEL_DIRS.has(normalized.slice(0, normalized.indexOf("/")));
168
+ return true;
121
169
  }
122
170
 
123
- export function isSafeExtraFileName(fileName: string) {
124
- return normalizeExtraFiles([fileName]).length === 1;
171
+ export function isSafeCustomIncludePath(relativePath: string) {
172
+ try {
173
+ validateAgentRelativeInclude(relativePath);
174
+ return true;
175
+ } catch {
176
+ return false;
177
+ }
125
178
  }
126
179
 
127
180
  export function isBuiltInTopLevelFile(fileName: string) {
128
- return TOP_LEVEL_FILE_NAMES.has(path.posix.basename(fileName).toLowerCase());
181
+ return TOP_LEVEL_FILE_PATHS.has(path.posix.basename(fileName).toLowerCase());
182
+ }
183
+
184
+ /** Compatibility projection for backend-neutral output while callers move to sync.include. */
185
+ export function normalizeSyncFiles(value: unknown): BuiltInSyncFile[] {
186
+ if (value === undefined) return [...DEFAULT_SYNC_INCLUDE];
187
+ if (Array.isArray(value)) {
188
+ return normalizeSyncInclude(value).filter((item): item is BuiltInSyncFile =>
189
+ BUILT_IN_BY_LOWER.has(item.toLowerCase()),
190
+ );
191
+ }
192
+ throw new Error("Invalid pi-sync settings: expected an include array.");
129
193
  }
194
+
195
+ /** Compatibility projection for old rendering helpers; v3 persistence never writes this split field. */
196
+ export function normalizeExtraFiles(value: unknown) {
197
+ if (!Array.isArray(value)) return [];
198
+ return value.filter(
199
+ (item): item is string => typeof item === "string" && isSafeCustomIncludePath(item),
200
+ );
201
+ }
202
+
203
+ export const extraFilePathsByLower = customIncludePathsByLower;
204
+ export const isSafeExtraFileName = isSafeCustomIncludePath;
205
+ export const selectedSyncFileSet = (value: unknown) => new Set(normalizeSyncFiles(value));
@@ -0,0 +1,177 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { loadConfig, readLocalConfigObject } from "./config.js";
3
+ import { errorMessage, ownRecord, safeTerminalText } from "./manager-helpers.js";
4
+ import { syncIncludeSelection } from "./sync-policy.js";
5
+
6
+ const BACK = "Back";
7
+
8
+ export async function countValidSyncSetups(
9
+ setups: Record<string, unknown> | undefined,
10
+ signal?: AbortSignal,
11
+ ) {
12
+ let count = 0;
13
+ for (const name of Object.keys(setups ?? {})) {
14
+ throwIfAborted(signal);
15
+ let valid = false;
16
+ try {
17
+ await loadConfig(name);
18
+ valid = true;
19
+ } catch {
20
+ // Invalid setups stay visible in management but are not switchable.
21
+ }
22
+ throwIfAborted(signal);
23
+ if (valid) count += 1;
24
+ }
25
+ return count;
26
+ }
27
+
28
+ function throwIfAborted(signal?: AbortSignal) {
29
+ if (!signal?.aborted) return;
30
+ throw signal.reason instanceof Error
31
+ ? signal.reason
32
+ : new DOMException("The operation was aborted", "AbortError");
33
+ }
34
+
35
+ type SyncSetupActions = {
36
+ add(signal?: AbortSignal): Promise<void>;
37
+ edit(name: string, signal?: AbortSignal): Promise<void>;
38
+ makeCurrent(name: string, signal?: AbortSignal): Promise<"exit" | undefined>;
39
+ remove(name: string, signal?: AbortSignal): Promise<void>;
40
+ };
41
+
42
+ export async function showSyncSetups(
43
+ ctx: ExtensionCommandContext,
44
+ actions: SyncSetupActions,
45
+ signal?: AbortSignal,
46
+ ): Promise<"exit" | undefined> {
47
+ while (!signal?.aborted) {
48
+ const raw = await readLocalConfigObject();
49
+ throwIfAborted(signal);
50
+ const setups = ownRecord(raw?.syncSetups) ?? {};
51
+ const active = typeof raw?.activeSyncSetup === "string" ? raw.activeSyncSetup : undefined;
52
+ const labels = new Map<string, string>();
53
+ for (const name of Object.keys(setups).sort((left, right) => left.localeCompare(right))) {
54
+ const label = `${safeTerminalText(name)}${name === active ? " (current)" : ""}`;
55
+ labels.set(label, name);
56
+ }
57
+ const selected = await ctx.ui.select(
58
+ "Sync setups",
59
+ ["Add sync setup", ...labels.keys(), BACK],
60
+ { signal },
61
+ );
62
+ if (signal?.aborted || !selected || selected === BACK) return;
63
+ if (selected === "Add sync setup") {
64
+ try {
65
+ await actions.add(signal);
66
+ } catch (error) {
67
+ if (signal?.aborted) return;
68
+ ctx.ui.notify(
69
+ `Sync setup was not added: ${menuErrorMessage(error)} Retry from Add sync setup.`,
70
+ "error",
71
+ );
72
+ }
73
+ continue;
74
+ }
75
+ const name = labels.get(selected);
76
+ if (!name) continue;
77
+ const result = await showSyncSetupDetail(ctx, name, actions, signal);
78
+ if (result === "exit") return "exit";
79
+ }
80
+ }
81
+
82
+ async function showSyncSetupDetail(
83
+ ctx: ExtensionCommandContext,
84
+ name: string,
85
+ actions: SyncSetupActions,
86
+ signal?: AbortSignal,
87
+ ): Promise<"exit" | undefined> {
88
+ while (!signal?.aborted) {
89
+ const raw = await readLocalConfigObject();
90
+ throwIfAborted(signal);
91
+ const setups = ownRecord(raw?.syncSetups) ?? {};
92
+ const setup = ownRecord(setups[name]);
93
+ if (!setup) {
94
+ ctx.ui.notify(`Sync setup “${safeTerminalText(name)}” no longer exists.`, "warning");
95
+ return;
96
+ }
97
+ const active = typeof raw?.activeSyncSetup === "string" ? raw.activeSyncSetup : undefined;
98
+ const setupCount = Object.keys(setups).length;
99
+ const isCurrent = name === active;
100
+ let detail: string[];
101
+ let valid = true;
102
+ try {
103
+ const config = await loadConfig(name);
104
+ throwIfAborted(signal);
105
+ const selection = syncIncludeSelection(config.include);
106
+ detail = [
107
+ `Status: ${isCurrent ? "Current" : "Not current"}`,
108
+ `Storage connection: ${safeTerminalText(config.connectionName)}`,
109
+ `Endpoint: ${storageEndpoint(config)}`,
110
+ `Storage location: ${storageLocation(config)}`,
111
+ `Included content: ${selection.builtIns.length} built-in groups · ${selection.custom.length} extra paths`,
112
+ `Sessions: ${selection.sessions ? "On — privacy-sensitive" : "Off"}`,
113
+ `Automatic sync: ${config.automatic ? "On" : "Off"}`,
114
+ ];
115
+ } catch (error) {
116
+ valid = false;
117
+ detail = [
118
+ `Status: Invalid${isCurrent ? " current setup" : ""}`,
119
+ `Reason: ${menuErrorMessage(error)}`,
120
+ "Make current and sync are unavailable until this setup is repaired.",
121
+ ];
122
+ }
123
+ const removeUnavailable = isCurrent && setupCount > 1;
124
+ if (removeUnavailable) detail.push("Remove unavailable: switch to another setup first.");
125
+ const selected = await ctx.ui.select(
126
+ [`Sync setup “${safeTerminalText(name)}”`, "", ...detail].join("\n"),
127
+ [
128
+ ...(!isCurrent && valid ? ["Make current…"] : []),
129
+ "Edit sync setup…",
130
+ ...(removeUnavailable ? [] : ["Remove sync setup…"]),
131
+ BACK,
132
+ ],
133
+ { signal },
134
+ );
135
+ if (signal?.aborted || !selected || selected === BACK) return;
136
+ try {
137
+ if (selected === "Make current…") return actions.makeCurrent(name, signal);
138
+ if (selected === "Edit sync setup…") await actions.edit(name, signal);
139
+ else if (selected === "Remove sync setup…") {
140
+ await actions.remove(name, signal);
141
+ return;
142
+ }
143
+ } catch (error) {
144
+ if (signal?.aborted) return;
145
+ ctx.ui.notify(
146
+ `Sync setup “${safeTerminalText(name)}” was not changed: ${menuErrorMessage(error)} Reopen it and retry.`,
147
+ "error",
148
+ );
149
+ }
150
+ }
151
+ }
152
+
153
+ function menuErrorMessage(error: unknown) {
154
+ return safeTerminalText(errorMessage(error));
155
+ }
156
+
157
+ function storageEndpoint(config: Awaited<ReturnType<typeof loadConfig>>) {
158
+ switch (config.backend.type) {
159
+ case "s3":
160
+ return safeTerminalText(config.backend.profile.endpoint);
161
+ case "git":
162
+ return safeTerminalText(config.backend.profile.remote);
163
+ case "webdav":
164
+ return safeTerminalText(config.backend.profile.url);
165
+ }
166
+ }
167
+
168
+ function storageLocation(config: Awaited<ReturnType<typeof loadConfig>>) {
169
+ switch (config.backend.type) {
170
+ case "s3":
171
+ return safeTerminalText(`${config.backend.destination.bucket}/${config.storagePath}`);
172
+ case "git":
173
+ return safeTerminalText(`Git · ${config.backend.destination.branch}:${config.storagePath}`);
174
+ case "webdav":
175
+ return safeTerminalText(`WebDAV · ${config.storagePath}`);
176
+ }
177
+ }
package/src/sync-state.ts CHANGED
@@ -5,28 +5,43 @@ import {
5
5
  isConfiguredSnapshotPath,
6
6
  isSessionPath,
7
7
  } from "./snapshot.js";
8
- import { extraFilePathsByLower, normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
9
- import type { LatestPointer, RemoteObject, Snapshot, SyncConfig, SyncState } from "./types.js";
8
+ import type { RemoteHead } from "./sync-backend.js";
9
+ import {
10
+ customIncludePathsByLower,
11
+ includeFromSelectionConfig,
12
+ normalizeSyncInclude,
13
+ type SyncSelectionConfig,
14
+ } from "./sync-policy.js";
15
+ import type { Snapshot, SyncState } from "./types.js";
16
+
17
+ type SyncPolicyConfig = SyncSelectionConfig;
10
18
 
11
- export function hasLocalChanges(local: Snapshot, state: SyncState, config: SyncConfig) {
19
+ export function hasLocalChanges(local: Snapshot, state: SyncState, config: SyncPolicyConfig) {
12
20
  return !sameHashes(fileHashMap(local), stateHashMapForConfig(state, config));
13
21
  }
14
22
 
15
23
  export function remoteChangedSinceState(
16
- latest: RemoteObject<LatestPointer>,
24
+ head: RemoteHead | undefined,
17
25
  state: SyncState,
18
- config: SyncConfig,
26
+ config: SyncPolicyConfig,
27
+ sameRevision: (left: string, right: string) => boolean,
19
28
  ) {
20
- if (latest.missing) return Boolean(state.lastAppliedSnapshot);
21
- if (latest.value?.snapshot !== state.lastAppliedSnapshot) return true;
22
- if (syncFilesChanged(state, config) || extraFilesChanged(state, config)) return true;
23
- return config.syncSessions && state.syncSessions !== true && latest.value?.syncSessions === true;
29
+ if (!head) return Boolean(state.lastAppliedSnapshot);
30
+ if (head.snapshotId !== state.lastAppliedSnapshot) return true;
31
+ if (state.lastRemoteRevision && !sameRevision(head.revision, state.lastRemoteRevision))
32
+ return true;
33
+ if (syncIncludeChanged(state, config)) return true;
34
+ return (
35
+ includeFromSelectionConfig(config).includes("sessions") &&
36
+ !state.include?.includes("sessions") &&
37
+ head.syncSessions
38
+ );
24
39
  }
25
40
 
26
41
  export function hasRemoteChanges(
27
42
  remote: Snapshot,
28
43
  state: SyncState,
29
- config: SyncConfig,
44
+ config: SyncPolicyConfig,
30
45
  ignoredPaths = new Set<string>(),
31
46
  ) {
32
47
  if (remote.id === state.lastAppliedSnapshot && !syncPolicyChanged(state, config)) return false;
@@ -40,9 +55,7 @@ export function hasRemoteChanges(
40
55
 
41
56
  export function sameHashes(left: Record<string, string>, right: Record<string, string>) {
42
57
  const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
43
- for (const key of keys) {
44
- if (left[key] !== right[key]) return false;
45
- }
58
+ for (const key of keys) if (left[key] !== right[key]) return false;
46
59
  return true;
47
60
  }
48
61
 
@@ -50,23 +63,19 @@ export function fileHashMap(snapshot: Snapshot) {
50
63
  return Object.fromEntries(snapshot.files.map((file) => [file.path, file.sha256]));
51
64
  }
52
65
 
53
- function stateHashMapForConfig(
54
- state: SyncState,
55
- config: Pick<SyncConfig, "syncFiles" | "syncSessions" | "extraFiles">,
56
- ) {
57
- const extraFilePaths = extraFilePathsByLower(config.extraFiles);
58
- const extraFiles = new Set(extraFilePaths.keys());
66
+ function stateHashMapForConfig(state: SyncState, config: SyncPolicyConfig) {
67
+ const includePaths = customIncludePathsByLower(includeFromSelectionConfig(config));
59
68
  return Object.fromEntries(
60
69
  Object.entries(state.lastFileHashes)
61
- .filter(([filePath]) => isConfiguredSnapshotPath(filePath, config, extraFiles))
62
- .map(([filePath, hash]) => [canonicalSnapshotPathForConfig(filePath, extraFilePaths), hash]),
70
+ .filter(([filePath]) => isConfiguredSnapshotPath(filePath, config))
71
+ .map(([filePath, hash]) => [canonicalSnapshotPathForConfig(filePath, includePaths), hash]),
63
72
  );
64
73
  }
65
74
 
66
75
  export function snapshotHashesMatchState(
67
76
  snapshot: Snapshot,
68
77
  state: SyncState,
69
- config: Pick<SyncConfig, "syncFiles" | "syncSessions" | "extraFiles">,
78
+ config: SyncPolicyConfig,
70
79
  ignoredPaths = new Set<string>(),
71
80
  ) {
72
81
  return sameHashes(
@@ -88,41 +97,32 @@ function withoutHashPaths(hashes: Record<string, string>, ignoredPaths: Set<stri
88
97
  );
89
98
  }
90
99
 
91
- export function syncPolicyChanged(
92
- state: SyncState,
93
- config: Pick<SyncConfig, "syncFiles" | "syncSessions" | "extraFiles">,
94
- ) {
95
- return (
96
- syncFilesChanged(state, config) ||
97
- (state.syncSessions ?? false) !== config.syncSessions ||
98
- extraFilesChanged(state, config)
99
- );
100
+ export function syncPolicyChanged(state: SyncState, config: SyncPolicyConfig) {
101
+ return syncIncludeChanged(state, config);
100
102
  }
101
103
 
102
104
  export function shouldRefreshSyncedState(
103
105
  remote: Snapshot,
106
+ head: RemoteHead | undefined,
104
107
  state: SyncState,
105
- config: Pick<SyncConfig, "syncFiles" | "syncSessions" | "extraFiles">,
108
+ config: SyncPolicyConfig,
109
+ sameRevision: (left: string, right: string) => boolean,
106
110
  ) {
107
- return remote.id !== state.lastAppliedSnapshot || syncPolicyChanged(state, config);
108
- }
109
-
110
- function syncFilesChanged(state: SyncState, config: Pick<SyncConfig, "syncFiles">) {
111
- return !sameStringSet(normalizeSyncFiles(state.syncFiles), normalizeSyncFiles(config.syncFiles));
112
- }
113
-
114
- function extraFilesChanged(state: SyncState, config: Pick<SyncConfig, "extraFiles">) {
115
- return !sameStringSet(
116
- normalizeExtraFiles(state.extraFiles),
117
- normalizeExtraFiles(config.extraFiles),
111
+ return (
112
+ remote.id !== state.lastAppliedSnapshot ||
113
+ Boolean(
114
+ head && (!state.lastRemoteRevision || !sameRevision(head.revision, state.lastRemoteRevision)),
115
+ ) ||
116
+ syncPolicyChanged(state, config)
118
117
  );
119
118
  }
120
119
 
121
- function sameStringSet(left: string[], right: string[]) {
122
- const leftSet = new Set(left);
123
- const rightSet = new Set(right);
124
- if (leftSet.size !== rightSet.size) return false;
125
- return [...leftSet].every((item) => rightSet.has(item));
120
+ function syncIncludeChanged(state: SyncState, config: SyncPolicyConfig) {
121
+ const stored = state.include
122
+ ? normalizeSyncInclude(state.include)
123
+ : includeFromSelectionConfig(state);
124
+ const current = includeFromSelectionConfig(config);
125
+ return stored.length !== current.length || stored.some((item, index) => item !== current[index]);
126
126
  }
127
127
 
128
128
  export function settingsHashMap(snapshot: Snapshot) {