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.
- package/README.md +460 -0
- package/dist/browser/browser-sync-engine.d.ts +64 -0
- package/dist/browser/browser-sync-engine.d.ts.map +1 -0
- package/dist/browser/browser-sync-engine.js +303 -0
- package/dist/browser/browser-sync-engine.js.map +1 -0
- package/dist/browser/filesystem-adapter.d.ts +84 -0
- package/dist/browser/filesystem-adapter.d.ts.map +1 -0
- package/dist/browser/filesystem-adapter.js +413 -0
- package/dist/browser/filesystem-adapter.js.map +1 -0
- package/dist/browser/index.d.ts +36 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +90 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/types.d.ts +70 -0
- package/dist/browser/types.d.ts.map +1 -0
- package/dist/browser/types.js +6 -0
- package/dist/browser/types.js.map +1 -0
- package/dist/cli/commands.d.ts +71 -0
- package/dist/cli/commands.d.ts.map +1 -0
- package/dist/cli/commands.js +794 -0
- package/dist/cli/commands.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +19 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +199 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/index.d.ts +71 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +314 -0
- package/dist/config/index.js.map +1 -0
- package/dist/core/change-detection.d.ts +78 -0
- package/dist/core/change-detection.d.ts.map +1 -0
- package/dist/core/change-detection.js +370 -0
- package/dist/core/change-detection.js.map +1 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +22 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/isomorphic-snapshot.d.ts +58 -0
- package/dist/core/isomorphic-snapshot.d.ts.map +1 -0
- package/dist/core/isomorphic-snapshot.js +204 -0
- package/dist/core/isomorphic-snapshot.js.map +1 -0
- package/dist/core/move-detection.d.ts +72 -0
- package/dist/core/move-detection.d.ts.map +1 -0
- package/dist/core/move-detection.js +200 -0
- package/dist/core/move-detection.js.map +1 -0
- package/dist/core/snapshot.d.ts +109 -0
- package/dist/core/snapshot.d.ts.map +1 -0
- package/dist/core/snapshot.js +263 -0
- package/dist/core/snapshot.js.map +1 -0
- package/dist/core/sync-engine.d.ts +110 -0
- package/dist/core/sync-engine.d.ts.map +1 -0
- package/dist/core/sync-engine.js +817 -0
- package/dist/core/sync-engine.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/platform/browser-filesystem.d.ts +26 -0
- package/dist/platform/browser-filesystem.d.ts.map +1 -0
- package/dist/platform/browser-filesystem.js +91 -0
- package/dist/platform/browser-filesystem.js.map +1 -0
- package/dist/platform/filesystem.d.ts +29 -0
- package/dist/platform/filesystem.d.ts.map +1 -0
- package/dist/platform/filesystem.js +65 -0
- package/dist/platform/filesystem.js.map +1 -0
- package/dist/platform/node-filesystem.d.ts +21 -0
- package/dist/platform/node-filesystem.d.ts.map +1 -0
- package/dist/platform/node-filesystem.js +93 -0
- package/dist/platform/node-filesystem.js.map +1 -0
- package/dist/types/config.d.ts +119 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/config.js +3 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/documents.d.ts +70 -0
- package/dist/types/documents.d.ts.map +1 -0
- package/dist/types/documents.js +23 -0
- package/dist/types/documents.js.map +1 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +23 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/snapshot.d.ts +81 -0
- package/dist/types/snapshot.d.ts.map +1 -0
- package/dist/types/snapshot.js +17 -0
- package/dist/types/snapshot.js.map +1 -0
- package/dist/utils/content-similarity.d.ts +53 -0
- package/dist/utils/content-similarity.d.ts.map +1 -0
- package/dist/utils/content-similarity.js +155 -0
- package/dist/utils/content-similarity.js.map +1 -0
- package/dist/utils/content.d.ts +5 -0
- package/dist/utils/content.d.ts.map +1 -0
- package/dist/utils/content.js +30 -0
- package/dist/utils/content.js.map +1 -0
- package/dist/utils/fs-browser.d.ts +57 -0
- package/dist/utils/fs-browser.d.ts.map +1 -0
- package/dist/utils/fs-browser.js +311 -0
- package/dist/utils/fs-browser.js.map +1 -0
- package/dist/utils/fs-node.d.ts +53 -0
- package/dist/utils/fs-node.d.ts.map +1 -0
- package/dist/utils/fs-node.js +220 -0
- package/dist/utils/fs-node.js.map +1 -0
- package/dist/utils/fs.d.ts +62 -0
- package/dist/utils/fs.d.ts.map +1 -0
- package/dist/utils/fs.js +293 -0
- package/dist/utils/fs.js.map +1 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +23 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/isomorphic.d.ts +29 -0
- package/dist/utils/isomorphic.d.ts.map +1 -0
- package/dist/utils/isomorphic.js +139 -0
- package/dist/utils/isomorphic.js.map +1 -0
- package/dist/utils/mime-types.d.ts +13 -0
- package/dist/utils/mime-types.d.ts.map +1 -0
- package/dist/utils/mime-types.js +240 -0
- package/dist/utils/mime-types.js.map +1 -0
- package/dist/utils/network-sync.d.ts +12 -0
- package/dist/utils/network-sync.d.ts.map +1 -0
- package/dist/utils/network-sync.js +149 -0
- package/dist/utils/network-sync.js.map +1 -0
- package/dist/utils/pure.d.ts +25 -0
- package/dist/utils/pure.d.ts.map +1 -0
- package/dist/utils/pure.js +112 -0
- package/dist/utils/pure.js.map +1 -0
- package/dist/utils/repo-factory.d.ts +11 -0
- package/dist/utils/repo-factory.d.ts.map +1 -0
- package/dist/utils/repo-factory.js +77 -0
- package/dist/utils/repo-factory.js.map +1 -0
- package/package.json +83 -0
- package/src/cli/commands.ts +1053 -0
- package/src/cli/index.ts +2 -0
- package/src/cli.ts +287 -0
- package/src/config/index.ts +334 -0
- package/src/core/change-detection.ts +484 -0
- package/src/core/index.ts +5 -0
- package/src/core/move-detection.ts +269 -0
- package/src/core/snapshot.ts +285 -0
- package/src/core/sync-engine.ts +1167 -0
- package/src/index.ts +14 -0
- package/src/types/config.ts +130 -0
- package/src/types/documents.ts +72 -0
- package/src/types/index.ts +8 -0
- package/src/types/snapshot.ts +88 -0
- package/src/utils/content-similarity.ts +194 -0
- package/src/utils/content.ts +28 -0
- package/src/utils/fs.ts +289 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/mime-types.ts +236 -0
- package/src/utils/network-sync.ts +153 -0
- package/src/utils/repo-factory.ts +58 -0
- package/test/README-TESTING-GAPS.md +174 -0
- package/test/integration/README.md +328 -0
- package/test/integration/clone-test.sh +310 -0
- package/test/integration/conflict-resolution-test.sh +309 -0
- package/test/integration/deletion-behavior-test.sh +487 -0
- package/test/integration/deletion-sync-test-simple.sh +193 -0
- package/test/integration/deletion-sync-test.sh +297 -0
- package/test/integration/exclude-patterns.test.ts +152 -0
- package/test/integration/full-integration-test.sh +363 -0
- package/test/integration/sync-deletion.test.ts +339 -0
- package/test/integration/sync-flow.test.ts +309 -0
- package/test/run-tests.sh +225 -0
- package/test/unit/content-similarity.test.ts +236 -0
- package/test/unit/deletion-behavior.test.ts +260 -0
- package/test/unit/enhanced-mime-detection.test.ts +266 -0
- package/test/unit/snapshot.test.ts +431 -0
- package/test/unit/sync-timing.test.ts +178 -0
- package/test/unit/utils.test.ts +368 -0
- package/tools/browser-sync/README.md +116 -0
- package/tools/browser-sync/package.json +44 -0
- package/tools/browser-sync/patchwork.json +1 -0
- package/tools/browser-sync/pnpm-lock.yaml +4202 -0
- package/tools/browser-sync/src/components/BrowserSyncTool.tsx +599 -0
- package/tools/browser-sync/src/index.ts +20 -0
- package/tools/browser-sync/src/polyfills.ts +31 -0
- package/tools/browser-sync/src/styles.css +290 -0
- package/tools/browser-sync/src/types.ts +27 -0
- package/tools/browser-sync/vite.config.ts +25 -0
- 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
|
+
}
|