pushwork 1.0.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.
Files changed (184) hide show
  1. package/README.md +460 -0
  2. package/dist/browser/browser-sync-engine.d.ts +64 -0
  3. package/dist/browser/browser-sync-engine.d.ts.map +1 -0
  4. package/dist/browser/browser-sync-engine.js +303 -0
  5. package/dist/browser/browser-sync-engine.js.map +1 -0
  6. package/dist/browser/filesystem-adapter.d.ts +84 -0
  7. package/dist/browser/filesystem-adapter.d.ts.map +1 -0
  8. package/dist/browser/filesystem-adapter.js +413 -0
  9. package/dist/browser/filesystem-adapter.js.map +1 -0
  10. package/dist/browser/index.d.ts +36 -0
  11. package/dist/browser/index.d.ts.map +1 -0
  12. package/dist/browser/index.js +90 -0
  13. package/dist/browser/index.js.map +1 -0
  14. package/dist/browser/types.d.ts +70 -0
  15. package/dist/browser/types.d.ts.map +1 -0
  16. package/dist/browser/types.js +6 -0
  17. package/dist/browser/types.js.map +1 -0
  18. package/dist/cli/commands.d.ts +71 -0
  19. package/dist/cli/commands.d.ts.map +1 -0
  20. package/dist/cli/commands.js +794 -0
  21. package/dist/cli/commands.js.map +1 -0
  22. package/dist/cli/index.d.ts +2 -0
  23. package/dist/cli/index.d.ts.map +1 -0
  24. package/dist/cli/index.js +19 -0
  25. package/dist/cli/index.js.map +1 -0
  26. package/dist/cli.d.ts +3 -0
  27. package/dist/cli.d.ts.map +1 -0
  28. package/dist/cli.js +199 -0
  29. package/dist/cli.js.map +1 -0
  30. package/dist/config/index.d.ts +71 -0
  31. package/dist/config/index.d.ts.map +1 -0
  32. package/dist/config/index.js +314 -0
  33. package/dist/config/index.js.map +1 -0
  34. package/dist/core/change-detection.d.ts +78 -0
  35. package/dist/core/change-detection.d.ts.map +1 -0
  36. package/dist/core/change-detection.js +370 -0
  37. package/dist/core/change-detection.js.map +1 -0
  38. package/dist/core/index.d.ts +5 -0
  39. package/dist/core/index.d.ts.map +1 -0
  40. package/dist/core/index.js +22 -0
  41. package/dist/core/index.js.map +1 -0
  42. package/dist/core/isomorphic-snapshot.d.ts +58 -0
  43. package/dist/core/isomorphic-snapshot.d.ts.map +1 -0
  44. package/dist/core/isomorphic-snapshot.js +204 -0
  45. package/dist/core/isomorphic-snapshot.js.map +1 -0
  46. package/dist/core/move-detection.d.ts +72 -0
  47. package/dist/core/move-detection.d.ts.map +1 -0
  48. package/dist/core/move-detection.js +200 -0
  49. package/dist/core/move-detection.js.map +1 -0
  50. package/dist/core/snapshot.d.ts +109 -0
  51. package/dist/core/snapshot.d.ts.map +1 -0
  52. package/dist/core/snapshot.js +263 -0
  53. package/dist/core/snapshot.js.map +1 -0
  54. package/dist/core/sync-engine.d.ts +110 -0
  55. package/dist/core/sync-engine.d.ts.map +1 -0
  56. package/dist/core/sync-engine.js +817 -0
  57. package/dist/core/sync-engine.js.map +1 -0
  58. package/dist/index.d.ts +6 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +27 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/platform/browser-filesystem.d.ts +26 -0
  63. package/dist/platform/browser-filesystem.d.ts.map +1 -0
  64. package/dist/platform/browser-filesystem.js +91 -0
  65. package/dist/platform/browser-filesystem.js.map +1 -0
  66. package/dist/platform/filesystem.d.ts +29 -0
  67. package/dist/platform/filesystem.d.ts.map +1 -0
  68. package/dist/platform/filesystem.js +65 -0
  69. package/dist/platform/filesystem.js.map +1 -0
  70. package/dist/platform/node-filesystem.d.ts +21 -0
  71. package/dist/platform/node-filesystem.d.ts.map +1 -0
  72. package/dist/platform/node-filesystem.js +93 -0
  73. package/dist/platform/node-filesystem.js.map +1 -0
  74. package/dist/types/config.d.ts +119 -0
  75. package/dist/types/config.d.ts.map +1 -0
  76. package/dist/types/config.js +3 -0
  77. package/dist/types/config.js.map +1 -0
  78. package/dist/types/documents.d.ts +70 -0
  79. package/dist/types/documents.d.ts.map +1 -0
  80. package/dist/types/documents.js +23 -0
  81. package/dist/types/documents.js.map +1 -0
  82. package/dist/types/index.d.ts +4 -0
  83. package/dist/types/index.d.ts.map +1 -0
  84. package/dist/types/index.js +23 -0
  85. package/dist/types/index.js.map +1 -0
  86. package/dist/types/snapshot.d.ts +81 -0
  87. package/dist/types/snapshot.d.ts.map +1 -0
  88. package/dist/types/snapshot.js +17 -0
  89. package/dist/types/snapshot.js.map +1 -0
  90. package/dist/utils/content-similarity.d.ts +53 -0
  91. package/dist/utils/content-similarity.d.ts.map +1 -0
  92. package/dist/utils/content-similarity.js +155 -0
  93. package/dist/utils/content-similarity.js.map +1 -0
  94. package/dist/utils/content.d.ts +5 -0
  95. package/dist/utils/content.d.ts.map +1 -0
  96. package/dist/utils/content.js +30 -0
  97. package/dist/utils/content.js.map +1 -0
  98. package/dist/utils/fs-browser.d.ts +57 -0
  99. package/dist/utils/fs-browser.d.ts.map +1 -0
  100. package/dist/utils/fs-browser.js +311 -0
  101. package/dist/utils/fs-browser.js.map +1 -0
  102. package/dist/utils/fs-node.d.ts +53 -0
  103. package/dist/utils/fs-node.d.ts.map +1 -0
  104. package/dist/utils/fs-node.js +220 -0
  105. package/dist/utils/fs-node.js.map +1 -0
  106. package/dist/utils/fs.d.ts +62 -0
  107. package/dist/utils/fs.d.ts.map +1 -0
  108. package/dist/utils/fs.js +293 -0
  109. package/dist/utils/fs.js.map +1 -0
  110. package/dist/utils/index.d.ts +4 -0
  111. package/dist/utils/index.d.ts.map +1 -0
  112. package/dist/utils/index.js +23 -0
  113. package/dist/utils/index.js.map +1 -0
  114. package/dist/utils/isomorphic.d.ts +29 -0
  115. package/dist/utils/isomorphic.d.ts.map +1 -0
  116. package/dist/utils/isomorphic.js +139 -0
  117. package/dist/utils/isomorphic.js.map +1 -0
  118. package/dist/utils/mime-types.d.ts +13 -0
  119. package/dist/utils/mime-types.d.ts.map +1 -0
  120. package/dist/utils/mime-types.js +240 -0
  121. package/dist/utils/mime-types.js.map +1 -0
  122. package/dist/utils/network-sync.d.ts +12 -0
  123. package/dist/utils/network-sync.d.ts.map +1 -0
  124. package/dist/utils/network-sync.js +149 -0
  125. package/dist/utils/network-sync.js.map +1 -0
  126. package/dist/utils/pure.d.ts +25 -0
  127. package/dist/utils/pure.d.ts.map +1 -0
  128. package/dist/utils/pure.js +112 -0
  129. package/dist/utils/pure.js.map +1 -0
  130. package/dist/utils/repo-factory.d.ts +11 -0
  131. package/dist/utils/repo-factory.d.ts.map +1 -0
  132. package/dist/utils/repo-factory.js +77 -0
  133. package/dist/utils/repo-factory.js.map +1 -0
  134. package/package.json +83 -0
  135. package/src/cli/commands.ts +1053 -0
  136. package/src/cli/index.ts +2 -0
  137. package/src/cli.ts +287 -0
  138. package/src/config/index.ts +334 -0
  139. package/src/core/change-detection.ts +484 -0
  140. package/src/core/index.ts +5 -0
  141. package/src/core/move-detection.ts +269 -0
  142. package/src/core/snapshot.ts +285 -0
  143. package/src/core/sync-engine.ts +1167 -0
  144. package/src/index.ts +14 -0
  145. package/src/types/config.ts +130 -0
  146. package/src/types/documents.ts +72 -0
  147. package/src/types/index.ts +8 -0
  148. package/src/types/snapshot.ts +88 -0
  149. package/src/utils/content-similarity.ts +194 -0
  150. package/src/utils/content.ts +28 -0
  151. package/src/utils/fs.ts +289 -0
  152. package/src/utils/index.ts +8 -0
  153. package/src/utils/mime-types.ts +236 -0
  154. package/src/utils/network-sync.ts +153 -0
  155. package/src/utils/repo-factory.ts +58 -0
  156. package/test/README-TESTING-GAPS.md +174 -0
  157. package/test/integration/README.md +328 -0
  158. package/test/integration/clone-test.sh +310 -0
  159. package/test/integration/conflict-resolution-test.sh +309 -0
  160. package/test/integration/deletion-behavior-test.sh +487 -0
  161. package/test/integration/deletion-sync-test-simple.sh +193 -0
  162. package/test/integration/deletion-sync-test.sh +297 -0
  163. package/test/integration/exclude-patterns.test.ts +152 -0
  164. package/test/integration/full-integration-test.sh +363 -0
  165. package/test/integration/sync-deletion.test.ts +339 -0
  166. package/test/integration/sync-flow.test.ts +309 -0
  167. package/test/run-tests.sh +225 -0
  168. package/test/unit/content-similarity.test.ts +236 -0
  169. package/test/unit/deletion-behavior.test.ts +260 -0
  170. package/test/unit/enhanced-mime-detection.test.ts +266 -0
  171. package/test/unit/snapshot.test.ts +431 -0
  172. package/test/unit/sync-timing.test.ts +178 -0
  173. package/test/unit/utils.test.ts +368 -0
  174. package/tools/browser-sync/README.md +116 -0
  175. package/tools/browser-sync/package.json +44 -0
  176. package/tools/browser-sync/patchwork.json +1 -0
  177. package/tools/browser-sync/pnpm-lock.yaml +4202 -0
  178. package/tools/browser-sync/src/components/BrowserSyncTool.tsx +599 -0
  179. package/tools/browser-sync/src/index.ts +20 -0
  180. package/tools/browser-sync/src/polyfills.ts +31 -0
  181. package/tools/browser-sync/src/styles.css +290 -0
  182. package/tools/browser-sync/src/types.ts +27 -0
  183. package/tools/browser-sync/vite.config.ts +25 -0
  184. package/tsconfig.json +22 -0
@@ -0,0 +1,269 @@
1
+ import {
2
+ SyncSnapshot,
3
+ MoveCandidate,
4
+ FileType,
5
+ SnapshotFileEntry,
6
+ } from "../types";
7
+ import { ContentSimilarity, readFileContent, getRelativePath } from "../utils";
8
+ import { DetectedChange, ChangeType } from "./change-detection";
9
+
10
+ /**
11
+ * Move detection engine
12
+ */
13
+ export class MoveDetector {
14
+ private static readonly AUTO_THRESHOLD = 0.8;
15
+ private static readonly PROMPT_THRESHOLD = 0.5;
16
+
17
+ /**
18
+ * Detect file moves by analyzing deleted and created files
19
+ */
20
+ async detectMoves(
21
+ changes: DetectedChange[],
22
+ snapshot: SyncSnapshot,
23
+ rootPath: string
24
+ ): Promise<{ moves: MoveCandidate[]; remainingChanges: DetectedChange[] }> {
25
+ // Separate deletions and creations
26
+ const deletedFiles = changes.filter(
27
+ (c) => !c.localContent && c.changeType === ChangeType.LOCAL_ONLY
28
+ );
29
+ const createdFiles = changes.filter(
30
+ (c) =>
31
+ c.localContent &&
32
+ c.changeType === ChangeType.LOCAL_ONLY &&
33
+ !snapshot.files.has(c.path)
34
+ );
35
+
36
+ if (deletedFiles.length === 0 || createdFiles.length === 0) {
37
+ return { moves: [], remainingChanges: changes };
38
+ }
39
+
40
+ const moves: MoveCandidate[] = [];
41
+ const usedCreations = new Set<string>();
42
+ const usedDeletions = new Set<string>();
43
+
44
+ // Find potential moves by comparing content
45
+ for (const deletedFile of deletedFiles) {
46
+ const deletedContent = await this.getDeletedFileContent(
47
+ deletedFile,
48
+ snapshot
49
+ );
50
+ if (!deletedContent) continue;
51
+
52
+ let bestMatch: { file: DetectedChange; similarity: number } | null = null;
53
+
54
+ for (const createdFile of createdFiles) {
55
+ if (usedCreations.has(createdFile.path)) continue;
56
+ if (!createdFile.localContent) continue;
57
+
58
+ const similarity = await ContentSimilarity.calculateSimilarity(
59
+ deletedContent,
60
+ createdFile.localContent
61
+ );
62
+
63
+ if (similarity >= MoveDetector.PROMPT_THRESHOLD) {
64
+ if (!bestMatch || similarity > bestMatch.similarity) {
65
+ bestMatch = { file: createdFile, similarity };
66
+ }
67
+ }
68
+ }
69
+
70
+ if (bestMatch) {
71
+ const confidence = ContentSimilarity.getConfidenceLevel(
72
+ bestMatch.similarity
73
+ );
74
+
75
+ // Always report the potential move (for logging/prompting)
76
+ moves.push({
77
+ fromPath: deletedFile.path,
78
+ toPath: bestMatch.file.path,
79
+ similarity: bestMatch.similarity,
80
+ confidence,
81
+ });
82
+
83
+ // Only consume the deletion/creation pair when we would auto-apply the move.
84
+ // If we only want to prompt, leave the original changes in place so
85
+ // sync can still proceed as delete+create (avoids infinite warning loop).
86
+ if (bestMatch.similarity >= MoveDetector.AUTO_THRESHOLD) {
87
+ usedCreations.add(bestMatch.file.path);
88
+ usedDeletions.add(deletedFile.path);
89
+ }
90
+ }
91
+ }
92
+
93
+ // Filter out changes that are part of moves
94
+ const remainingChanges = changes.filter(
95
+ (change) =>
96
+ !usedCreations.has(change.path) && !usedDeletions.has(change.path)
97
+ );
98
+
99
+ return { moves, remainingChanges };
100
+ }
101
+
102
+ /**
103
+ * Get content of a deleted file from snapshot
104
+ */
105
+ private async getDeletedFileContent(
106
+ deletedFile: DetectedChange,
107
+ snapshot: SyncSnapshot
108
+ ): Promise<string | Uint8Array | null> {
109
+ const snapshotEntry = snapshot.files.get(deletedFile.path);
110
+ if (!snapshotEntry) return null;
111
+
112
+ // Return remote content if available, otherwise null
113
+ return deletedFile.remoteContent || null;
114
+ }
115
+
116
+ /**
117
+ * Group moves by confidence level
118
+ */
119
+ groupMovesByConfidence(moves: MoveCandidate[]): {
120
+ autoMoves: MoveCandidate[];
121
+ promptMoves: MoveCandidate[];
122
+ lowConfidenceMoves: MoveCandidate[];
123
+ } {
124
+ const autoMoves = moves.filter((m) => m.confidence === "auto");
125
+ const promptMoves = moves.filter((m) => m.confidence === "prompt");
126
+ const lowConfidenceMoves = moves.filter((m) => m.confidence === "low");
127
+
128
+ return { autoMoves, promptMoves, lowConfidenceMoves };
129
+ }
130
+
131
+ /**
132
+ * Validate move candidates to avoid conflicts
133
+ */
134
+ validateMoves(moves: MoveCandidate[]): {
135
+ validMoves: MoveCandidate[];
136
+ conflicts: Array<{ moves: MoveCandidate[]; reason: string }>;
137
+ } {
138
+ const validMoves: MoveCandidate[] = [];
139
+ const conflicts: Array<{ moves: MoveCandidate[]; reason: string }> = [];
140
+
141
+ // Check for multiple sources mapping to same destination
142
+ const destinationMap = new Map<string, MoveCandidate[]>();
143
+ for (const move of moves) {
144
+ if (!destinationMap.has(move.toPath)) {
145
+ destinationMap.set(move.toPath, []);
146
+ }
147
+ destinationMap.get(move.toPath)!.push(move);
148
+ }
149
+
150
+ // Check for multiple destinations from same source
151
+ const sourceMap = new Map<string, MoveCandidate[]>();
152
+ for (const move of moves) {
153
+ if (!sourceMap.has(move.fromPath)) {
154
+ sourceMap.set(move.fromPath, []);
155
+ }
156
+ sourceMap.get(move.fromPath)!.push(move);
157
+ }
158
+
159
+ for (const move of moves) {
160
+ const destinationConflicts = destinationMap.get(move.toPath)!;
161
+ const sourceConflicts = sourceMap.get(move.fromPath)!;
162
+
163
+ if (destinationConflicts.length > 1) {
164
+ conflicts.push({
165
+ moves: destinationConflicts,
166
+ reason: `Multiple files moving to ${move.toPath}`,
167
+ });
168
+ } else if (sourceConflicts.length > 1) {
169
+ conflicts.push({
170
+ moves: sourceConflicts,
171
+ reason: `File ${move.fromPath} has multiple potential destinations`,
172
+ });
173
+ } else {
174
+ validMoves.push(move);
175
+ }
176
+ }
177
+
178
+ return { validMoves, conflicts };
179
+ }
180
+
181
+ /**
182
+ * Apply move detection heuristics
183
+ */
184
+ applyHeuristics(moves: MoveCandidate[]): MoveCandidate[] {
185
+ return moves
186
+ .filter((move) => {
187
+ // Filter out moves within the same directory unless similarity is very high
188
+ const fromDir = this.getDirectoryPath(move.fromPath);
189
+ const toDir = this.getDirectoryPath(move.toPath);
190
+
191
+ if (fromDir === toDir && move.similarity < 0.9) {
192
+ return false;
193
+ }
194
+
195
+ // Filter out moves with very different file extensions unless similarity is perfect
196
+ const fromExt = this.getFileExtension(move.fromPath);
197
+ const toExt = this.getFileExtension(move.toPath);
198
+
199
+ if (fromExt !== toExt && move.similarity < 1.0) {
200
+ return false;
201
+ }
202
+
203
+ return true;
204
+ })
205
+ .sort((a, b) => b.similarity - a.similarity); // Sort by similarity descending
206
+ }
207
+
208
+ /**
209
+ * Get directory path from file path
210
+ */
211
+ private getDirectoryPath(filePath: string): string {
212
+ const lastSlash = filePath.lastIndexOf("/");
213
+ return lastSlash >= 0 ? filePath.substring(0, lastSlash) : "";
214
+ }
215
+
216
+ /**
217
+ * Get file extension from file path
218
+ */
219
+ private getFileExtension(filePath: string): string {
220
+ const lastDot = filePath.lastIndexOf(".");
221
+ const lastSlash = filePath.lastIndexOf("/");
222
+
223
+ if (lastDot > lastSlash && lastDot >= 0) {
224
+ return filePath.substring(lastDot + 1).toLowerCase();
225
+ }
226
+
227
+ return "";
228
+ }
229
+
230
+ /**
231
+ * Check if a move should be auto-applied
232
+ */
233
+ shouldAutoApply(move: MoveCandidate): boolean {
234
+ return move.confidence === "auto";
235
+ }
236
+
237
+ /**
238
+ * Check if a move should prompt the user
239
+ */
240
+ shouldPromptUser(move: MoveCandidate): boolean {
241
+ return move.confidence === "prompt";
242
+ }
243
+
244
+ /**
245
+ * Format move for display
246
+ */
247
+ formatMove(move: MoveCandidate): string {
248
+ const percentage = Math.round(move.similarity * 100);
249
+ return `${move.fromPath} → ${move.toPath} (${percentage}% similar)`;
250
+ }
251
+
252
+ /**
253
+ * Calculate move statistics
254
+ */
255
+ calculateStats(moves: MoveCandidate[]): {
256
+ total: number;
257
+ auto: number;
258
+ prompt: number;
259
+ averageSimilarity: number;
260
+ } {
261
+ const total = moves.length;
262
+ const auto = moves.filter((m) => m.confidence === "auto").length;
263
+ const prompt = moves.filter((m) => m.confidence === "prompt").length;
264
+ const averageSimilarity =
265
+ total > 0 ? moves.reduce((sum, m) => sum + m.similarity, 0) / total : 0;
266
+
267
+ return { total, auto, prompt, averageSimilarity };
268
+ }
269
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * pvh TODO: the files & directories could be unified into a single map of entries with a type field
3
+ */
4
+
5
+ import * as fs from "fs/promises";
6
+ import * as path from "path";
7
+ import {
8
+ SyncSnapshot,
9
+ SerializableSyncSnapshot,
10
+ SnapshotFileEntry,
11
+ SnapshotDirectoryEntry,
12
+ } from "../types";
13
+ import { pathExists, ensureDirectoryExists } from "../utils";
14
+
15
+ /**
16
+ * Manages sync snapshots for local state tracking
17
+ */
18
+ export class SnapshotManager {
19
+ private static readonly SNAPSHOT_FILENAME = "snapshot.json";
20
+ private static readonly SYNC_TOOL_DIR = ".pushwork";
21
+
22
+ constructor(private rootPath: string) {}
23
+
24
+ /**
25
+ * Get path to sync tool directory
26
+ */
27
+ private getSyncToolDir(): string {
28
+ return path.join(this.rootPath, SnapshotManager.SYNC_TOOL_DIR);
29
+ }
30
+
31
+ /**
32
+ * Get path to snapshot file
33
+ */
34
+ private getSnapshotPath(): string {
35
+ return path.join(this.getSyncToolDir(), SnapshotManager.SNAPSHOT_FILENAME);
36
+ }
37
+
38
+ /**
39
+ * Check if snapshot exists
40
+ */
41
+ async exists(): Promise<boolean> {
42
+ return await pathExists(this.getSnapshotPath());
43
+ }
44
+
45
+ /**
46
+ * Load snapshot from disk
47
+ */
48
+ async load(): Promise<SyncSnapshot | null> {
49
+ try {
50
+ const snapshotPath = this.getSnapshotPath();
51
+ if (!(await pathExists(snapshotPath))) {
52
+ return null;
53
+ }
54
+
55
+ const content = await fs.readFile(snapshotPath, "utf8");
56
+ const serializable: SerializableSyncSnapshot = JSON.parse(content);
57
+
58
+ return this.deserializeSnapshot(serializable);
59
+ } catch (error) {
60
+ console.warn(`Failed to load snapshot: ${error}`);
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Save snapshot to disk
67
+ */
68
+ async save(snapshot: SyncSnapshot): Promise<void> {
69
+ try {
70
+ await ensureDirectoryExists(this.getSyncToolDir());
71
+
72
+ const serializable = this.serializeSnapshot(snapshot);
73
+ const content = JSON.stringify(serializable, null, 2);
74
+
75
+ await fs.writeFile(this.getSnapshotPath(), content, "utf8");
76
+ } catch (error) {
77
+ throw new Error(`Failed to save snapshot: ${error}`);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Create empty snapshot
83
+ */
84
+ createEmpty(): SyncSnapshot {
85
+ return {
86
+ timestamp: Date.now(),
87
+ rootPath: this.rootPath,
88
+ rootDirectoryUrl: undefined,
89
+ files: new Map(),
90
+ directories: new Map(),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Update file entry in snapshot
96
+ */
97
+ updateFileEntry(
98
+ snapshot: SyncSnapshot,
99
+ relativePath: string,
100
+ entry: SnapshotFileEntry
101
+ ): void {
102
+ snapshot.files.set(relativePath, entry);
103
+ snapshot.timestamp = Date.now();
104
+ }
105
+
106
+ /**
107
+ * Update directory entry in snapshot
108
+ */
109
+ updateDirectoryEntry(
110
+ snapshot: SyncSnapshot,
111
+ relativePath: string,
112
+ entry: SnapshotDirectoryEntry
113
+ ): void {
114
+ snapshot.directories.set(relativePath, entry);
115
+ snapshot.timestamp = Date.now();
116
+ }
117
+
118
+ /**
119
+ * Remove file entry from snapshot
120
+ */
121
+ removeFileEntry(snapshot: SyncSnapshot, relativePath: string): void {
122
+ snapshot.files.delete(relativePath);
123
+ snapshot.timestamp = Date.now();
124
+ }
125
+
126
+ /**
127
+ * Remove directory entry from snapshot
128
+ */
129
+ removeDirectoryEntry(snapshot: SyncSnapshot, relativePath: string): void {
130
+ snapshot.directories.delete(relativePath);
131
+ snapshot.timestamp = Date.now();
132
+ }
133
+
134
+ /**
135
+ * Get all file paths in snapshot
136
+ */
137
+ getFilePaths(snapshot: SyncSnapshot): string[] {
138
+ return Array.from(snapshot.files.keys());
139
+ }
140
+
141
+ /**
142
+ * Get all directory paths in snapshot
143
+ */
144
+ getDirectoryPaths(snapshot: SyncSnapshot): string[] {
145
+ return Array.from(snapshot.directories.keys());
146
+ }
147
+
148
+ /**
149
+ * Get file entry by path
150
+ */
151
+ getFileEntry(
152
+ snapshot: SyncSnapshot,
153
+ relativePath: string
154
+ ): SnapshotFileEntry | undefined {
155
+ return snapshot.files.get(relativePath);
156
+ }
157
+
158
+ /**
159
+ * Get directory entry by path
160
+ */
161
+ getDirectoryEntry(
162
+ snapshot: SyncSnapshot,
163
+ relativePath: string
164
+ ): SnapshotDirectoryEntry | undefined {
165
+ return snapshot.directories.get(relativePath);
166
+ }
167
+
168
+ /**
169
+ * Check if path is tracked in snapshot
170
+ */
171
+ isTracked(snapshot: SyncSnapshot, relativePath: string): boolean {
172
+ return (
173
+ snapshot.files.has(relativePath) || snapshot.directories.has(relativePath)
174
+ );
175
+ }
176
+
177
+ /**
178
+ * Get snapshot statistics
179
+ */
180
+ getStats(snapshot: SyncSnapshot): {
181
+ files: number;
182
+ directories: number;
183
+ timestamp: Date;
184
+ } {
185
+ return {
186
+ files: snapshot.files.size,
187
+ directories: snapshot.directories.size,
188
+ timestamp: new Date(snapshot.timestamp),
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Backup current snapshot
194
+ */
195
+ async backup(): Promise<void> {
196
+ const snapshotPath = this.getSnapshotPath();
197
+ if (await pathExists(snapshotPath)) {
198
+ const backupPath = `${snapshotPath}.backup.${Date.now()}`;
199
+ await fs.copyFile(snapshotPath, backupPath);
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Validate snapshot integrity
205
+ */
206
+ validate(snapshot: SyncSnapshot): { valid: boolean; errors: string[] } {
207
+ const errors: string[] = [];
208
+
209
+ if (!snapshot.timestamp || snapshot.timestamp <= 0) {
210
+ errors.push("Invalid timestamp");
211
+ }
212
+
213
+ if (!snapshot.rootPath) {
214
+ errors.push("Missing root path");
215
+ }
216
+
217
+ if (!snapshot.files || !snapshot.directories) {
218
+ errors.push("Missing files or directories map");
219
+ }
220
+
221
+ // Check for path conflicts (file and directory with same path)
222
+ for (const filePath of snapshot.files.keys()) {
223
+ if (snapshot.directories.has(filePath)) {
224
+ errors.push(
225
+ `Path conflict: ${filePath} exists as both file and directory`
226
+ );
227
+ }
228
+ }
229
+
230
+ return {
231
+ valid: errors.length === 0,
232
+ errors,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Convert snapshot to serializable format
238
+ */
239
+ private serializeSnapshot(snapshot: SyncSnapshot): SerializableSyncSnapshot {
240
+ return {
241
+ timestamp: snapshot.timestamp,
242
+ rootPath: snapshot.rootPath,
243
+ rootDirectoryUrl: snapshot.rootDirectoryUrl,
244
+ files: Array.from(snapshot.files.entries()),
245
+ directories: Array.from(snapshot.directories.entries()),
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Convert serializable format back to snapshot
251
+ */
252
+ private deserializeSnapshot(
253
+ serializable: SerializableSyncSnapshot
254
+ ): SyncSnapshot {
255
+ return {
256
+ timestamp: serializable.timestamp,
257
+ rootPath: serializable.rootPath,
258
+ rootDirectoryUrl: serializable.rootDirectoryUrl,
259
+ files: new Map(serializable.files),
260
+ directories: new Map(serializable.directories),
261
+ };
262
+ }
263
+
264
+ /**
265
+ * Clear all snapshot data
266
+ */
267
+ clear(snapshot: SyncSnapshot): void {
268
+ snapshot.files.clear();
269
+ snapshot.directories.clear();
270
+ snapshot.timestamp = Date.now();
271
+ }
272
+
273
+ /**
274
+ * Clone snapshot for safe manipulation
275
+ */
276
+ clone(snapshot: SyncSnapshot): SyncSnapshot {
277
+ return {
278
+ timestamp: snapshot.timestamp,
279
+ rootPath: snapshot.rootPath,
280
+ rootDirectoryUrl: snapshot.rootDirectoryUrl,
281
+ files: new Map(snapshot.files),
282
+ directories: new Map(snapshot.directories),
283
+ };
284
+ }
285
+ }