pushwork 1.0.3 → 1.0.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 +8 -1
- package/bench/filesystem.bench.ts +78 -0
- package/bench/hashing.bench.ts +60 -0
- package/bench/move-detection.bench.ts +130 -0
- package/bench/runner.ts +49 -0
- package/dist/cli/commands.d.ts +15 -25
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +409 -519
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli/output.d.ts +75 -0
- package/dist/cli/output.d.ts.map +1 -0
- package/dist/cli/output.js +182 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/cli.js +119 -51
- package/dist/cli.js.map +1 -1
- package/dist/config/remote-manager.d.ts +65 -0
- package/dist/config/remote-manager.d.ts.map +1 -0
- package/dist/config/remote-manager.js +243 -0
- package/dist/config/remote-manager.js.map +1 -0
- package/dist/core/change-detection.d.ts +8 -0
- package/dist/core/change-detection.d.ts.map +1 -1
- package/dist/core/change-detection.js +63 -0
- package/dist/core/change-detection.js.map +1 -1
- package/dist/core/move-detection.d.ts +9 -48
- package/dist/core/move-detection.d.ts.map +1 -1
- package/dist/core/move-detection.js +53 -135
- package/dist/core/move-detection.js.map +1 -1
- package/dist/core/sync-engine.d.ts.map +1 -1
- package/dist/core/sync-engine.js +17 -85
- package/dist/core/sync-engine.js.map +1 -1
- package/dist/types/config.d.ts +45 -5
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/documents.d.ts +0 -1
- package/dist/types/documents.d.ts.map +1 -1
- package/dist/types/snapshot.d.ts +3 -0
- package/dist/types/snapshot.d.ts.map +1 -1
- package/dist/types/snapshot.js.map +1 -1
- package/dist/utils/fs.d.ts.map +1 -1
- package/dist/utils/fs.js +9 -33
- package/dist/utils/fs.js.map +1 -1
- package/dist/utils/index.d.ts +0 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +0 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/repo-factory.d.ts.map +1 -1
- package/dist/utils/repo-factory.js +18 -9
- package/dist/utils/repo-factory.js.map +1 -1
- package/dist/utils/string-similarity.d.ts +14 -0
- package/dist/utils/string-similarity.d.ts.map +1 -0
- package/dist/utils/string-similarity.js +43 -0
- package/dist/utils/string-similarity.js.map +1 -0
- package/package.json +10 -5
- package/src/cli/commands.ts +520 -697
- package/src/cli/output.ts +244 -0
- package/src/cli.ts +182 -73
- package/src/core/change-detection.ts +95 -0
- package/src/core/move-detection.ts +69 -177
- package/src/core/sync-engine.ts +17 -105
- package/src/types/config.ts +50 -7
- package/src/types/documents.ts +0 -1
- package/src/types/snapshot.ts +1 -0
- package/src/utils/fs.ts +9 -33
- package/src/utils/index.ts +0 -1
- package/src/utils/repo-factory.ts +21 -8
- package/src/utils/string-similarity.ts +54 -0
- package/src/utils/content-similarity.ts +0 -194
- package/test/unit/content-similarity.test.ts +0 -236
|
@@ -171,6 +171,33 @@ export class ChangeDetector {
|
|
|
171
171
|
const changes: DetectedChange[] = [];
|
|
172
172
|
|
|
173
173
|
for (const [relativePath, snapshotEntry] of snapshot.files.entries()) {
|
|
174
|
+
// CRITICAL FIX: Check if file still exists in remote directory listing
|
|
175
|
+
// Files can be removed from the directory without their document heads changing
|
|
176
|
+
const stillExistsInDirectory = await this.fileExistsInRemoteDirectory(
|
|
177
|
+
snapshot.rootDirectoryUrl,
|
|
178
|
+
relativePath
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
if (!stillExistsInDirectory) {
|
|
182
|
+
// File was removed from remote directory listing
|
|
183
|
+
const localContent = await this.getLocalContent(relativePath);
|
|
184
|
+
|
|
185
|
+
// Only report as deleted if local file still exists
|
|
186
|
+
// (if local file is also deleted, detectLocalChanges handles it)
|
|
187
|
+
if (localContent !== null) {
|
|
188
|
+
changes.push({
|
|
189
|
+
path: relativePath,
|
|
190
|
+
changeType: ChangeType.REMOTE_ONLY,
|
|
191
|
+
fileType: FileType.TEXT,
|
|
192
|
+
localContent,
|
|
193
|
+
remoteContent: null, // File deleted remotely
|
|
194
|
+
localHead: snapshotEntry.head,
|
|
195
|
+
remoteHead: snapshotEntry.head,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
174
201
|
const currentRemoteHead = await this.getCurrentRemoteHead(
|
|
175
202
|
snapshotEntry.url
|
|
176
203
|
);
|
|
@@ -494,4 +521,72 @@ export class ChangeDetector {
|
|
|
494
521
|
return ChangeType.BOTH_CHANGED;
|
|
495
522
|
}
|
|
496
523
|
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Check if a file exists in the remote directory hierarchy
|
|
527
|
+
*/
|
|
528
|
+
private async fileExistsInRemoteDirectory(
|
|
529
|
+
rootDirectoryUrl: AutomergeUrl | undefined,
|
|
530
|
+
filePath: string
|
|
531
|
+
): Promise<boolean> {
|
|
532
|
+
if (!rootDirectoryUrl) return false;
|
|
533
|
+
const entry = await this.findFileInDirectoryHierarchy(
|
|
534
|
+
rootDirectoryUrl,
|
|
535
|
+
filePath
|
|
536
|
+
);
|
|
537
|
+
return entry !== null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Find a file in the directory hierarchy by path
|
|
542
|
+
*/
|
|
543
|
+
private async findFileInDirectoryHierarchy(
|
|
544
|
+
directoryUrl: AutomergeUrl,
|
|
545
|
+
filePath: string
|
|
546
|
+
): Promise<{ name: string; type: string; url: AutomergeUrl } | null> {
|
|
547
|
+
try {
|
|
548
|
+
const pathParts = filePath.split("/");
|
|
549
|
+
let currentDirUrl = directoryUrl;
|
|
550
|
+
|
|
551
|
+
// Navigate through directories to find the parent directory
|
|
552
|
+
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
553
|
+
const dirName = pathParts[i];
|
|
554
|
+
const dirHandle = await this.repo.find<DirectoryDocument>(
|
|
555
|
+
currentDirUrl
|
|
556
|
+
);
|
|
557
|
+
const dirDoc = await dirHandle.doc();
|
|
558
|
+
|
|
559
|
+
if (!dirDoc) return null;
|
|
560
|
+
|
|
561
|
+
const subDirEntry = dirDoc.docs.find(
|
|
562
|
+
(entry: { name: string; type: string; url: AutomergeUrl }) =>
|
|
563
|
+
entry.name === dirName && entry.type === "folder"
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
if (!subDirEntry) return null;
|
|
567
|
+
currentDirUrl = subDirEntry.url;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Now look for the file in the final directory
|
|
571
|
+
const fileName = pathParts[pathParts.length - 1];
|
|
572
|
+
const finalDirHandle = await this.repo.find<DirectoryDocument>(
|
|
573
|
+
currentDirUrl
|
|
574
|
+
);
|
|
575
|
+
const finalDirDoc = await finalDirHandle.doc();
|
|
576
|
+
|
|
577
|
+
if (!finalDirDoc) return null;
|
|
578
|
+
|
|
579
|
+
const fileEntry = finalDirDoc.docs.find(
|
|
580
|
+
(entry: { name: string; type: string; url: AutomergeUrl }) =>
|
|
581
|
+
entry.name === fileName && entry.type === "file"
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
return fileEntry || null;
|
|
585
|
+
} catch (error) {
|
|
586
|
+
console.warn(
|
|
587
|
+
`Failed to find file ${filePath} in directory hierarchy: ${error}`
|
|
588
|
+
);
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
497
592
|
}
|
|
@@ -1,18 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
FileType,
|
|
5
|
-
SnapshotFileEntry,
|
|
6
|
-
} from "../types";
|
|
7
|
-
import { ContentSimilarity, readFileContent, getRelativePath } from "../utils";
|
|
1
|
+
import { SyncSnapshot, MoveCandidate, SnapshotFileEntry } from "../types";
|
|
2
|
+
import { calculateContentHash, isTextFile } from "../utils";
|
|
3
|
+
import { stringSimilarity } from "../utils/string-similarity";
|
|
8
4
|
import { DetectedChange, ChangeType } from "./change-detection";
|
|
9
5
|
|
|
10
6
|
/**
|
|
11
|
-
*
|
|
7
|
+
* Simplified move detection engine
|
|
12
8
|
*/
|
|
13
9
|
export class MoveDetector {
|
|
14
|
-
|
|
15
|
-
private static readonly
|
|
10
|
+
// Single threshold: either it's a move or it's not
|
|
11
|
+
private static readonly MOVE_THRESHOLD = 0.7;
|
|
16
12
|
|
|
17
13
|
/**
|
|
18
14
|
* Detect file moves by analyzing deleted and created files
|
|
@@ -22,7 +18,6 @@ export class MoveDetector {
|
|
|
22
18
|
snapshot: SyncSnapshot,
|
|
23
19
|
rootPath: string
|
|
24
20
|
): Promise<{ moves: MoveCandidate[]; remainingChanges: DetectedChange[] }> {
|
|
25
|
-
// Separate deletions and creations
|
|
26
21
|
const deletedFiles = changes.filter(
|
|
27
22
|
(c) => !c.localContent && c.changeType === ChangeType.LOCAL_ONLY
|
|
28
23
|
);
|
|
@@ -43,28 +38,22 @@ export class MoveDetector {
|
|
|
43
38
|
|
|
44
39
|
// Find potential moves by comparing content
|
|
45
40
|
for (const deletedFile of deletedFiles) {
|
|
46
|
-
const deletedContent =
|
|
47
|
-
deletedFile,
|
|
48
|
-
snapshot
|
|
49
|
-
);
|
|
50
|
-
// CRITICAL: Check for null explicitly, not falsy values
|
|
51
|
-
// Empty strings "" are valid file content!
|
|
41
|
+
const deletedContent = deletedFile.remoteContent;
|
|
52
42
|
if (deletedContent === null) continue;
|
|
53
43
|
|
|
54
44
|
let bestMatch: { file: DetectedChange; similarity: number } | null = null;
|
|
55
45
|
|
|
56
46
|
for (const createdFile of createdFiles) {
|
|
57
47
|
if (usedCreations.has(createdFile.path)) continue;
|
|
58
|
-
// CRITICAL: Check for null explicitly, not falsy values
|
|
59
|
-
// Empty strings "" are valid file content!
|
|
60
48
|
if (createdFile.localContent === null) continue;
|
|
61
49
|
|
|
62
|
-
const similarity = await
|
|
50
|
+
const similarity = await this.calculateSimilarity(
|
|
63
51
|
deletedContent,
|
|
64
|
-
createdFile.localContent
|
|
52
|
+
createdFile.localContent,
|
|
53
|
+
deletedFile.path
|
|
65
54
|
);
|
|
66
55
|
|
|
67
|
-
if (similarity >= MoveDetector.
|
|
56
|
+
if (similarity >= MoveDetector.MOVE_THRESHOLD) {
|
|
68
57
|
if (!bestMatch || similarity > bestMatch.similarity) {
|
|
69
58
|
bestMatch = { file: createdFile, similarity };
|
|
70
59
|
}
|
|
@@ -72,31 +61,20 @@ export class MoveDetector {
|
|
|
72
61
|
}
|
|
73
62
|
|
|
74
63
|
if (bestMatch) {
|
|
75
|
-
|
|
76
|
-
bestMatch.similarity
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
// Always report the potential move (for logging/prompting)
|
|
64
|
+
// If we detected a move above threshold, we apply it
|
|
80
65
|
moves.push({
|
|
81
66
|
fromPath: deletedFile.path,
|
|
82
67
|
toPath: bestMatch.file.path,
|
|
83
68
|
similarity: bestMatch.similarity,
|
|
84
|
-
confidence,
|
|
85
|
-
// Capture new content (may include modifications)
|
|
86
69
|
newContent: bestMatch.file.localContent || undefined,
|
|
87
70
|
});
|
|
88
71
|
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (bestMatch.similarity >= MoveDetector.AUTO_THRESHOLD) {
|
|
93
|
-
usedCreations.add(bestMatch.file.path);
|
|
94
|
-
usedDeletions.add(deletedFile.path);
|
|
95
|
-
}
|
|
72
|
+
// Consume the deletion and creation (move replaces both)
|
|
73
|
+
usedCreations.add(bestMatch.file.path);
|
|
74
|
+
usedDeletions.add(deletedFile.path);
|
|
96
75
|
}
|
|
97
76
|
}
|
|
98
77
|
|
|
99
|
-
// Filter out changes that are part of moves
|
|
100
78
|
const remainingChanges = changes.filter(
|
|
101
79
|
(change) =>
|
|
102
80
|
!usedCreations.has(change.path) && !usedDeletions.has(change.path)
|
|
@@ -106,145 +84,77 @@ export class MoveDetector {
|
|
|
106
84
|
}
|
|
107
85
|
|
|
108
86
|
/**
|
|
109
|
-
*
|
|
110
|
-
|
|
111
|
-
private async getDeletedFileContent(
|
|
112
|
-
deletedFile: DetectedChange,
|
|
113
|
-
snapshot: SyncSnapshot
|
|
114
|
-
): Promise<string | Uint8Array | null> {
|
|
115
|
-
const snapshotEntry = snapshot.files.get(deletedFile.path);
|
|
116
|
-
if (!snapshotEntry) return null;
|
|
117
|
-
|
|
118
|
-
// Return remote content if available, otherwise null
|
|
119
|
-
return deletedFile.remoteContent || null;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Group moves by confidence level
|
|
124
|
-
*/
|
|
125
|
-
groupMovesByConfidence(moves: MoveCandidate[]): {
|
|
126
|
-
autoMoves: MoveCandidate[];
|
|
127
|
-
promptMoves: MoveCandidate[];
|
|
128
|
-
lowConfidenceMoves: MoveCandidate[];
|
|
129
|
-
} {
|
|
130
|
-
const autoMoves = moves.filter((m) => m.confidence === "auto");
|
|
131
|
-
const promptMoves = moves.filter((m) => m.confidence === "prompt");
|
|
132
|
-
const lowConfidenceMoves = moves.filter((m) => m.confidence === "low");
|
|
133
|
-
|
|
134
|
-
return { autoMoves, promptMoves, lowConfidenceMoves };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Validate move candidates to avoid conflicts
|
|
87
|
+
* Calculate similarity between two content pieces
|
|
88
|
+
* Optimized for speed while maintaining accuracy
|
|
139
89
|
*/
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
//
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
90
|
+
private async calculateSimilarity(
|
|
91
|
+
content1: string | Uint8Array,
|
|
92
|
+
content2: string | Uint8Array,
|
|
93
|
+
path: string
|
|
94
|
+
): Promise<number> {
|
|
95
|
+
if (content1 === content2) return 1.0;
|
|
96
|
+
|
|
97
|
+
// Early exit: size difference too large
|
|
98
|
+
const size1 =
|
|
99
|
+
typeof content1 === "string" ? content1.length : content1.length;
|
|
100
|
+
const size2 =
|
|
101
|
+
typeof content2 === "string" ? content2.length : content2.length;
|
|
102
|
+
const sizeDiff = Math.abs(size1 - size2) / Math.max(size1, size2);
|
|
103
|
+
if (sizeDiff > 0.5) return 0.0;
|
|
104
|
+
|
|
105
|
+
// Binary files: hash mismatch = not a move
|
|
106
|
+
const isText = await isTextFile(path);
|
|
107
|
+
if (!isText) return 0.0;
|
|
108
|
+
|
|
109
|
+
// Text files: use string similarity
|
|
110
|
+
const str1 =
|
|
111
|
+
typeof content1 === "string" ? content1 : this.bufferToString(content1);
|
|
112
|
+
const str2 =
|
|
113
|
+
typeof content2 === "string" ? content2 : this.bufferToString(content2);
|
|
114
|
+
|
|
115
|
+
// For small files (<4KB), compare full content
|
|
116
|
+
if (size1 < 4096 && size2 < 4096) {
|
|
117
|
+
return stringSimilarity(str1, str2);
|
|
154
118
|
}
|
|
155
119
|
|
|
156
|
-
//
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
if (!sourceMap.has(move.fromPath)) {
|
|
160
|
-
sourceMap.set(move.fromPath, []);
|
|
161
|
-
}
|
|
162
|
-
sourceMap.get(move.fromPath)!.push(move);
|
|
163
|
-
}
|
|
120
|
+
// For large files, sample 3 locations
|
|
121
|
+
const samples1 = this.getSamples(str1);
|
|
122
|
+
const samples2 = this.getSamples(str2);
|
|
164
123
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (destinationConflicts.length > 1) {
|
|
170
|
-
conflicts.push({
|
|
171
|
-
moves: destinationConflicts,
|
|
172
|
-
reason: `Multiple files moving to ${move.toPath}`,
|
|
173
|
-
});
|
|
174
|
-
} else if (sourceConflicts.length > 1) {
|
|
175
|
-
conflicts.push({
|
|
176
|
-
moves: sourceConflicts,
|
|
177
|
-
reason: `File ${move.fromPath} has multiple potential destinations`,
|
|
178
|
-
});
|
|
179
|
-
} else {
|
|
180
|
-
validMoves.push(move);
|
|
181
|
-
}
|
|
124
|
+
let totalSimilarity = 0;
|
|
125
|
+
for (let i = 0; i < Math.min(samples1.length, samples2.length); i++) {
|
|
126
|
+
totalSimilarity += stringSimilarity(samples1[i], samples2[i]);
|
|
182
127
|
}
|
|
183
128
|
|
|
184
|
-
return
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Apply move detection heuristics
|
|
189
|
-
*/
|
|
190
|
-
applyHeuristics(moves: MoveCandidate[]): MoveCandidate[] {
|
|
191
|
-
return moves
|
|
192
|
-
.filter((move) => {
|
|
193
|
-
// Filter out moves within the same directory unless similarity is very high
|
|
194
|
-
const fromDir = this.getDirectoryPath(move.fromPath);
|
|
195
|
-
const toDir = this.getDirectoryPath(move.toPath);
|
|
196
|
-
|
|
197
|
-
if (fromDir === toDir && move.similarity < 0.9) {
|
|
198
|
-
return false;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Filter out moves with very different file extensions unless similarity is perfect
|
|
202
|
-
const fromExt = this.getFileExtension(move.fromPath);
|
|
203
|
-
const toExt = this.getFileExtension(move.toPath);
|
|
204
|
-
|
|
205
|
-
if (fromExt !== toExt && move.similarity < 1.0) {
|
|
206
|
-
return false;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
return true;
|
|
210
|
-
})
|
|
211
|
-
.sort((a, b) => b.similarity - a.similarity); // Sort by similarity descending
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Get directory path from file path
|
|
216
|
-
*/
|
|
217
|
-
private getDirectoryPath(filePath: string): string {
|
|
218
|
-
const lastSlash = filePath.lastIndexOf("/");
|
|
219
|
-
return lastSlash >= 0 ? filePath.substring(0, lastSlash) : "";
|
|
129
|
+
return totalSimilarity / Math.min(samples1.length, samples2.length);
|
|
220
130
|
}
|
|
221
131
|
|
|
222
132
|
/**
|
|
223
|
-
* Get
|
|
133
|
+
* Get representative samples from content (beginning, middle, end)
|
|
224
134
|
*/
|
|
225
|
-
private
|
|
226
|
-
const
|
|
227
|
-
const
|
|
135
|
+
private getSamples(str: string): string[] {
|
|
136
|
+
const CHUNK_SIZE = 1024;
|
|
137
|
+
const length = str.length;
|
|
228
138
|
|
|
229
|
-
if (
|
|
230
|
-
return
|
|
139
|
+
if (length <= CHUNK_SIZE) {
|
|
140
|
+
return [str];
|
|
231
141
|
}
|
|
232
142
|
|
|
233
|
-
return
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
143
|
+
return [
|
|
144
|
+
str.slice(0, CHUNK_SIZE), // Beginning
|
|
145
|
+
str.slice(
|
|
146
|
+
Math.floor(length / 2) - Math.floor(CHUNK_SIZE / 2),
|
|
147
|
+
Math.floor(length / 2) + Math.floor(CHUNK_SIZE / 2)
|
|
148
|
+
), // Middle
|
|
149
|
+
str.slice(-CHUNK_SIZE), // End
|
|
150
|
+
];
|
|
241
151
|
}
|
|
242
152
|
|
|
243
153
|
/**
|
|
244
|
-
*
|
|
154
|
+
* Convert buffer to string (for text comparison)
|
|
245
155
|
*/
|
|
246
|
-
|
|
247
|
-
return
|
|
156
|
+
private bufferToString(buffer: Uint8Array): string {
|
|
157
|
+
return new TextDecoder().decode(buffer);
|
|
248
158
|
}
|
|
249
159
|
|
|
250
160
|
/**
|
|
@@ -254,22 +164,4 @@ export class MoveDetector {
|
|
|
254
164
|
const percentage = Math.round(move.similarity * 100);
|
|
255
165
|
return `${move.fromPath} → ${move.toPath} (${percentage}% similar)`;
|
|
256
166
|
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Calculate move statistics
|
|
260
|
-
*/
|
|
261
|
-
calculateStats(moves: MoveCandidate[]): {
|
|
262
|
-
total: number;
|
|
263
|
-
auto: number;
|
|
264
|
-
prompt: number;
|
|
265
|
-
averageSimilarity: number;
|
|
266
|
-
} {
|
|
267
|
-
const total = moves.length;
|
|
268
|
-
const auto = moves.filter((m) => m.confidence === "auto").length;
|
|
269
|
-
const prompt = moves.filter((m) => m.confidence === "prompt").length;
|
|
270
|
-
const averageSimilarity =
|
|
271
|
-
total > 0 ? moves.reduce((sum, m) => sum + m.similarity, 0) / total : 0;
|
|
272
|
-
|
|
273
|
-
return { total, auto, prompt, averageSimilarity };
|
|
274
|
-
}
|
|
275
167
|
}
|
package/src/core/sync-engine.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
const myers = require("myers-diff");
|
|
2
|
-
|
|
3
1
|
import {
|
|
4
2
|
AutomergeUrl,
|
|
5
3
|
Repo,
|
|
@@ -89,8 +87,6 @@ export class SyncEngine {
|
|
|
89
87
|
* Commit local changes only (no network sync)
|
|
90
88
|
*/
|
|
91
89
|
async commitLocal(dryRun = false): Promise<SyncResult> {
|
|
92
|
-
console.log(`🚀 Starting local commit process (dryRun: ${dryRun})`);
|
|
93
|
-
|
|
94
90
|
const result: SyncResult = {
|
|
95
91
|
success: false,
|
|
96
92
|
filesChanged: 0,
|
|
@@ -101,51 +97,33 @@ export class SyncEngine {
|
|
|
101
97
|
|
|
102
98
|
try {
|
|
103
99
|
// Load current snapshot
|
|
104
|
-
console.log(`📸 Loading current snapshot...`);
|
|
105
100
|
let snapshot = await this.snapshotManager.load();
|
|
106
101
|
if (!snapshot) {
|
|
107
|
-
console.log(`📸 No snapshot found, creating empty one`);
|
|
108
102
|
snapshot = this.snapshotManager.createEmpty();
|
|
109
|
-
} else {
|
|
110
|
-
console.log(`📸 Snapshot loaded with ${snapshot.files.size} files`);
|
|
111
|
-
if (snapshot.rootDirectoryUrl) {
|
|
112
|
-
console.log(`🔗 Root directory URL: ${snapshot.rootDirectoryUrl}`);
|
|
113
|
-
}
|
|
114
103
|
}
|
|
115
104
|
|
|
116
105
|
// Backup snapshot before starting
|
|
117
106
|
if (!dryRun) {
|
|
118
|
-
console.log(`💾 Backing up snapshot...`);
|
|
119
107
|
await this.snapshotManager.backup();
|
|
120
108
|
}
|
|
121
109
|
|
|
122
110
|
// Detect all changes
|
|
123
|
-
console.log(`🔍 Detecting changes...`);
|
|
124
111
|
const changes = await this.changeDetector.detectChanges(snapshot);
|
|
125
|
-
console.log(`🔍 Found ${changes.length} changes`);
|
|
126
112
|
|
|
127
113
|
// Detect moves
|
|
128
|
-
console.log(`📦 Detecting moves...`);
|
|
129
114
|
const { moves, remainingChanges } = await this.moveDetector.detectMoves(
|
|
130
115
|
changes,
|
|
131
116
|
snapshot,
|
|
132
117
|
this.rootPath
|
|
133
118
|
);
|
|
134
|
-
console.log(
|
|
135
|
-
`📦 Found ${moves.length} moves, ${remainingChanges.length} remaining changes`
|
|
136
|
-
);
|
|
137
119
|
|
|
138
120
|
// Apply local changes only (no network sync)
|
|
139
|
-
console.log(`💾 Committing local changes...`);
|
|
140
121
|
const commitResult = await this.pushLocalChanges(
|
|
141
122
|
remainingChanges,
|
|
142
123
|
moves,
|
|
143
124
|
snapshot,
|
|
144
125
|
dryRun
|
|
145
126
|
);
|
|
146
|
-
console.log(
|
|
147
|
-
`💾 Commit complete: ${commitResult.filesChanged} files changed`
|
|
148
|
-
);
|
|
149
127
|
|
|
150
128
|
result.filesChanged += commitResult.filesChanged;
|
|
151
129
|
result.directoriesChanged += commitResult.directoriesChanged;
|
|
@@ -165,11 +143,9 @@ export class SyncEngine {
|
|
|
165
143
|
}
|
|
166
144
|
|
|
167
145
|
result.success = result.errors.length === 0;
|
|
168
|
-
console.log(`💾 Local commit ${result.success ? "completed" : "failed"}`);
|
|
169
146
|
|
|
170
147
|
return result;
|
|
171
148
|
} catch (error) {
|
|
172
|
-
console.error(`❌ Local commit failed: ${error}`);
|
|
173
149
|
result.errors.push({
|
|
174
150
|
path: this.rootPath,
|
|
175
151
|
operation: "commitLocal",
|
|
@@ -194,6 +170,7 @@ export class SyncEngine {
|
|
|
194
170
|
directoriesChanged: 0,
|
|
195
171
|
errors: [],
|
|
196
172
|
warnings: [],
|
|
173
|
+
timings: {},
|
|
197
174
|
};
|
|
198
175
|
|
|
199
176
|
// Reset handles to wait on
|
|
@@ -229,10 +206,6 @@ export class SyncEngine {
|
|
|
229
206
|
);
|
|
230
207
|
timings["detect_moves"] = Date.now() - t3;
|
|
231
208
|
|
|
232
|
-
if (changes.length > 0) {
|
|
233
|
-
console.log(`🔄 Syncing ${changes.length} changes...`);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
209
|
// Phase 1: Push local changes to remote
|
|
237
210
|
const t4 = Date.now();
|
|
238
211
|
const phase1Result = await this.pushLocalChanges(
|
|
@@ -251,6 +224,7 @@ export class SyncEngine {
|
|
|
251
224
|
// Always wait for network sync when enabled (not just when local changes exist)
|
|
252
225
|
// This is critical for clone scenarios where we need to pull remote changes
|
|
253
226
|
const t5 = Date.now();
|
|
227
|
+
timings["documents_to_sync"] = this.handlesToWaitOn.length;
|
|
254
228
|
if (!dryRun && this.networkSyncEnabled) {
|
|
255
229
|
try {
|
|
256
230
|
// If we have a root directory URL, wait for it to sync
|
|
@@ -376,24 +350,12 @@ export class SyncEngine {
|
|
|
376
350
|
}
|
|
377
351
|
timings["save_snapshot"] = Date.now() - t10;
|
|
378
352
|
|
|
379
|
-
//
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
console.error("\n⏱️ Sync Timing Breakdown:");
|
|
383
|
-
for (const [key, ms] of Object.entries(timings)) {
|
|
384
|
-
const pct = ((ms / totalTime) * 100).toFixed(1);
|
|
385
|
-
console.error(
|
|
386
|
-
` ${key.padEnd(25)} ${ms.toString().padStart(5)}ms (${pct}%)`
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
console.error(
|
|
390
|
-
` ${"TOTAL".padEnd(25)} ${totalTime
|
|
391
|
-
.toString()
|
|
392
|
-
.padStart(5)}ms (100.0%)\n`
|
|
393
|
-
);
|
|
394
|
-
}
|
|
353
|
+
// Calculate total time
|
|
354
|
+
const totalTime = Date.now() - syncStartTime;
|
|
355
|
+
timings["total"] = totalTime;
|
|
395
356
|
|
|
396
357
|
result.success = result.errors.length === 0;
|
|
358
|
+
result.timings = timings;
|
|
397
359
|
return result;
|
|
398
360
|
} catch (error) {
|
|
399
361
|
result.errors.push({
|
|
@@ -423,28 +385,18 @@ export class SyncEngine {
|
|
|
423
385
|
warnings: [],
|
|
424
386
|
};
|
|
425
387
|
|
|
426
|
-
// Process moves first
|
|
388
|
+
// Process moves first - all detected moves are applied
|
|
427
389
|
for (const move of moves) {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
});
|
|
439
|
-
}
|
|
440
|
-
} else if (this.moveDetector.shouldPromptUser(move)) {
|
|
441
|
-
// Instead of creating a persistent loop, perform delete+create semantics
|
|
442
|
-
// so the working tree converges even without auto-apply.
|
|
443
|
-
result.warnings.push(
|
|
444
|
-
`Potential move detected: ${this.moveDetector.formatMove(
|
|
445
|
-
move
|
|
446
|
-
)} (${Math.round(move.similarity * 100)}% similar)`
|
|
447
|
-
);
|
|
390
|
+
try {
|
|
391
|
+
await this.applyMoveToRemote(move, snapshot, dryRun);
|
|
392
|
+
result.filesChanged++;
|
|
393
|
+
} catch (error) {
|
|
394
|
+
result.errors.push({
|
|
395
|
+
path: move.fromPath,
|
|
396
|
+
operation: "move",
|
|
397
|
+
error: error as Error,
|
|
398
|
+
recoverable: true,
|
|
399
|
+
});
|
|
448
400
|
}
|
|
449
401
|
}
|
|
450
402
|
|
|
@@ -530,7 +482,6 @@ export class SyncEngine {
|
|
|
530
482
|
if (change.localContent === null) {
|
|
531
483
|
// File was deleted locally
|
|
532
484
|
if (snapshotEntry) {
|
|
533
|
-
console.log(`🗑️ ${change.path}`);
|
|
534
485
|
await this.deleteRemoteFile(
|
|
535
486
|
snapshotEntry.url,
|
|
536
487
|
dryRun,
|
|
@@ -548,7 +499,6 @@ export class SyncEngine {
|
|
|
548
499
|
|
|
549
500
|
if (!snapshotEntry) {
|
|
550
501
|
// New file
|
|
551
|
-
console.log(`➕ ${change.path}`);
|
|
552
502
|
const handle = await this.createRemoteFile(change, dryRun);
|
|
553
503
|
if (!dryRun && handle) {
|
|
554
504
|
await this.addFileToDirectory(
|
|
@@ -570,33 +520,6 @@ export class SyncEngine {
|
|
|
570
520
|
}
|
|
571
521
|
} else {
|
|
572
522
|
// Update existing file
|
|
573
|
-
console.log(`📝 ${change.path}`);
|
|
574
|
-
|
|
575
|
-
// log the change in detail for debugging
|
|
576
|
-
// split out remotea nd local content so we don't overwhelm the logs
|
|
577
|
-
const { remoteContent, localContent, ...rest } = change;
|
|
578
|
-
console.log(`🔍 Change in detail:`, rest);
|
|
579
|
-
|
|
580
|
-
// compare the local and remote content and make a diff so we can
|
|
581
|
-
// see what happened between the two
|
|
582
|
-
const { diff, changed } = require("myers-diff");
|
|
583
|
-
const lhs = change.remoteContent ? change.remoteContent.toString() : "";
|
|
584
|
-
const rhs = change.localContent ? change.localContent.toString() : "";
|
|
585
|
-
const changes = diff(lhs, rhs, { compare: "chars" });
|
|
586
|
-
|
|
587
|
-
for (const change of changes) {
|
|
588
|
-
if (changed(change.lhs)) {
|
|
589
|
-
// deleted
|
|
590
|
-
const { pos, text, del, length } = change.lhs;
|
|
591
|
-
console.log(`🔍 Deleted:`, { pos, text, del, length });
|
|
592
|
-
}
|
|
593
|
-
if (changed(change.rhs)) {
|
|
594
|
-
// added
|
|
595
|
-
const { pos, text, add, length } = change.rhs;
|
|
596
|
-
console.log(`🔍 Added:`, { pos, text, add, length });
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
523
|
await this.updateRemoteFile(
|
|
601
524
|
snapshotEntry.url,
|
|
602
525
|
change.localContent,
|
|
@@ -627,7 +550,6 @@ export class SyncEngine {
|
|
|
627
550
|
// Empty strings "" and empty Uint8Array are valid file content!
|
|
628
551
|
if (change.remoteContent === null) {
|
|
629
552
|
// File was deleted remotely
|
|
630
|
-
console.log(`🗑️ ${change.path}`);
|
|
631
553
|
if (!dryRun) {
|
|
632
554
|
await removePath(localPath);
|
|
633
555
|
this.snapshotManager.removeFileEntry(snapshot, change.path);
|
|
@@ -636,12 +558,6 @@ export class SyncEngine {
|
|
|
636
558
|
}
|
|
637
559
|
|
|
638
560
|
// Create or update local file
|
|
639
|
-
if (change.changeType === ChangeType.REMOTE_ONLY) {
|
|
640
|
-
console.log(`⬇️ ${change.path}`);
|
|
641
|
-
} else {
|
|
642
|
-
console.log(`🔀 ${change.path}`);
|
|
643
|
-
}
|
|
644
|
-
|
|
645
561
|
if (!dryRun) {
|
|
646
562
|
await writeFileContent(localPath, change.remoteContent);
|
|
647
563
|
|
|
@@ -931,10 +847,6 @@ export class SyncEngine {
|
|
|
931
847
|
dryRun
|
|
932
848
|
);
|
|
933
849
|
|
|
934
|
-
console.log(
|
|
935
|
-
`🔗 Adding ${fileName} (${fileUrl}) to directory ${parentDirUrl} (path: ${directoryPath})`
|
|
936
|
-
);
|
|
937
|
-
|
|
938
850
|
const dirHandle = await this.repo.find<DirectoryDocument>(parentDirUrl);
|
|
939
851
|
|
|
940
852
|
let didChange = false;
|