pushwork 1.0.4 ā 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 +116 -50
- 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 -62
- 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 +178 -72
- package/src/core/change-detection.ts +95 -0
- package/src/core/move-detection.ts +69 -177
- package/src/core/sync-engine.ts +17 -78
- 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
|
@@ -87,8 +87,6 @@ export class SyncEngine {
|
|
|
87
87
|
* Commit local changes only (no network sync)
|
|
88
88
|
*/
|
|
89
89
|
async commitLocal(dryRun = false): Promise<SyncResult> {
|
|
90
|
-
console.log(`š Starting local commit process (dryRun: ${dryRun})`);
|
|
91
|
-
|
|
92
90
|
const result: SyncResult = {
|
|
93
91
|
success: false,
|
|
94
92
|
filesChanged: 0,
|
|
@@ -99,51 +97,33 @@ export class SyncEngine {
|
|
|
99
97
|
|
|
100
98
|
try {
|
|
101
99
|
// Load current snapshot
|
|
102
|
-
console.log(`šø Loading current snapshot...`);
|
|
103
100
|
let snapshot = await this.snapshotManager.load();
|
|
104
101
|
if (!snapshot) {
|
|
105
|
-
console.log(`šø No snapshot found, creating empty one`);
|
|
106
102
|
snapshot = this.snapshotManager.createEmpty();
|
|
107
|
-
} else {
|
|
108
|
-
console.log(`šø Snapshot loaded with ${snapshot.files.size} files`);
|
|
109
|
-
if (snapshot.rootDirectoryUrl) {
|
|
110
|
-
console.log(`š Root directory URL: ${snapshot.rootDirectoryUrl}`);
|
|
111
|
-
}
|
|
112
103
|
}
|
|
113
104
|
|
|
114
105
|
// Backup snapshot before starting
|
|
115
106
|
if (!dryRun) {
|
|
116
|
-
console.log(`š¾ Backing up snapshot...`);
|
|
117
107
|
await this.snapshotManager.backup();
|
|
118
108
|
}
|
|
119
109
|
|
|
120
110
|
// Detect all changes
|
|
121
|
-
console.log(`š Detecting changes...`);
|
|
122
111
|
const changes = await this.changeDetector.detectChanges(snapshot);
|
|
123
|
-
console.log(`š Found ${changes.length} changes`);
|
|
124
112
|
|
|
125
113
|
// Detect moves
|
|
126
|
-
console.log(`š¦ Detecting moves...`);
|
|
127
114
|
const { moves, remainingChanges } = await this.moveDetector.detectMoves(
|
|
128
115
|
changes,
|
|
129
116
|
snapshot,
|
|
130
117
|
this.rootPath
|
|
131
118
|
);
|
|
132
|
-
console.log(
|
|
133
|
-
`š¦ Found ${moves.length} moves, ${remainingChanges.length} remaining changes`
|
|
134
|
-
);
|
|
135
119
|
|
|
136
120
|
// Apply local changes only (no network sync)
|
|
137
|
-
console.log(`š¾ Committing local changes...`);
|
|
138
121
|
const commitResult = await this.pushLocalChanges(
|
|
139
122
|
remainingChanges,
|
|
140
123
|
moves,
|
|
141
124
|
snapshot,
|
|
142
125
|
dryRun
|
|
143
126
|
);
|
|
144
|
-
console.log(
|
|
145
|
-
`š¾ Commit complete: ${commitResult.filesChanged} files changed`
|
|
146
|
-
);
|
|
147
127
|
|
|
148
128
|
result.filesChanged += commitResult.filesChanged;
|
|
149
129
|
result.directoriesChanged += commitResult.directoriesChanged;
|
|
@@ -163,11 +143,9 @@ export class SyncEngine {
|
|
|
163
143
|
}
|
|
164
144
|
|
|
165
145
|
result.success = result.errors.length === 0;
|
|
166
|
-
console.log(`š¾ Local commit ${result.success ? "completed" : "failed"}`);
|
|
167
146
|
|
|
168
147
|
return result;
|
|
169
148
|
} catch (error) {
|
|
170
|
-
console.error(`ā Local commit failed: ${error}`);
|
|
171
149
|
result.errors.push({
|
|
172
150
|
path: this.rootPath,
|
|
173
151
|
operation: "commitLocal",
|
|
@@ -192,6 +170,7 @@ export class SyncEngine {
|
|
|
192
170
|
directoriesChanged: 0,
|
|
193
171
|
errors: [],
|
|
194
172
|
warnings: [],
|
|
173
|
+
timings: {},
|
|
195
174
|
};
|
|
196
175
|
|
|
197
176
|
// Reset handles to wait on
|
|
@@ -227,10 +206,6 @@ export class SyncEngine {
|
|
|
227
206
|
);
|
|
228
207
|
timings["detect_moves"] = Date.now() - t3;
|
|
229
208
|
|
|
230
|
-
if (changes.length > 0) {
|
|
231
|
-
console.log(`š Syncing ${changes.length} changes...`);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
209
|
// Phase 1: Push local changes to remote
|
|
235
210
|
const t4 = Date.now();
|
|
236
211
|
const phase1Result = await this.pushLocalChanges(
|
|
@@ -249,6 +224,7 @@ export class SyncEngine {
|
|
|
249
224
|
// Always wait for network sync when enabled (not just when local changes exist)
|
|
250
225
|
// This is critical for clone scenarios where we need to pull remote changes
|
|
251
226
|
const t5 = Date.now();
|
|
227
|
+
timings["documents_to_sync"] = this.handlesToWaitOn.length;
|
|
252
228
|
if (!dryRun && this.networkSyncEnabled) {
|
|
253
229
|
try {
|
|
254
230
|
// If we have a root directory URL, wait for it to sync
|
|
@@ -374,24 +350,12 @@ export class SyncEngine {
|
|
|
374
350
|
}
|
|
375
351
|
timings["save_snapshot"] = Date.now() - t10;
|
|
376
352
|
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
console.error("\nā±ļø Sync Timing Breakdown:");
|
|
381
|
-
for (const [key, ms] of Object.entries(timings)) {
|
|
382
|
-
const pct = ((ms / totalTime) * 100).toFixed(1);
|
|
383
|
-
console.error(
|
|
384
|
-
` ${key.padEnd(25)} ${ms.toString().padStart(5)}ms (${pct}%)`
|
|
385
|
-
);
|
|
386
|
-
}
|
|
387
|
-
console.error(
|
|
388
|
-
` ${"TOTAL".padEnd(25)} ${totalTime
|
|
389
|
-
.toString()
|
|
390
|
-
.padStart(5)}ms (100.0%)\n`
|
|
391
|
-
);
|
|
392
|
-
}
|
|
353
|
+
// Calculate total time
|
|
354
|
+
const totalTime = Date.now() - syncStartTime;
|
|
355
|
+
timings["total"] = totalTime;
|
|
393
356
|
|
|
394
357
|
result.success = result.errors.length === 0;
|
|
358
|
+
result.timings = timings;
|
|
395
359
|
return result;
|
|
396
360
|
} catch (error) {
|
|
397
361
|
result.errors.push({
|
|
@@ -421,28 +385,18 @@ export class SyncEngine {
|
|
|
421
385
|
warnings: [],
|
|
422
386
|
};
|
|
423
387
|
|
|
424
|
-
// Process moves first
|
|
388
|
+
// Process moves first - all detected moves are applied
|
|
425
389
|
for (const move of moves) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
} else if (this.moveDetector.shouldPromptUser(move)) {
|
|
439
|
-
// Instead of creating a persistent loop, perform delete+create semantics
|
|
440
|
-
// so the working tree converges even without auto-apply.
|
|
441
|
-
result.warnings.push(
|
|
442
|
-
`Potential move detected: ${this.moveDetector.formatMove(
|
|
443
|
-
move
|
|
444
|
-
)} (${Math.round(move.similarity * 100)}% similar)`
|
|
445
|
-
);
|
|
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
|
+
});
|
|
446
400
|
}
|
|
447
401
|
}
|
|
448
402
|
|
|
@@ -528,7 +482,6 @@ export class SyncEngine {
|
|
|
528
482
|
if (change.localContent === null) {
|
|
529
483
|
// File was deleted locally
|
|
530
484
|
if (snapshotEntry) {
|
|
531
|
-
console.log(`šļø ${change.path}`);
|
|
532
485
|
await this.deleteRemoteFile(
|
|
533
486
|
snapshotEntry.url,
|
|
534
487
|
dryRun,
|
|
@@ -546,7 +499,6 @@ export class SyncEngine {
|
|
|
546
499
|
|
|
547
500
|
if (!snapshotEntry) {
|
|
548
501
|
// New file
|
|
549
|
-
console.log(`ā ${change.path}`);
|
|
550
502
|
const handle = await this.createRemoteFile(change, dryRun);
|
|
551
503
|
if (!dryRun && handle) {
|
|
552
504
|
await this.addFileToDirectory(
|
|
@@ -568,8 +520,6 @@ export class SyncEngine {
|
|
|
568
520
|
}
|
|
569
521
|
} else {
|
|
570
522
|
// Update existing file
|
|
571
|
-
console.log(`š ${change.path}`);
|
|
572
|
-
|
|
573
523
|
await this.updateRemoteFile(
|
|
574
524
|
snapshotEntry.url,
|
|
575
525
|
change.localContent,
|
|
@@ -600,7 +550,6 @@ export class SyncEngine {
|
|
|
600
550
|
// Empty strings "" and empty Uint8Array are valid file content!
|
|
601
551
|
if (change.remoteContent === null) {
|
|
602
552
|
// File was deleted remotely
|
|
603
|
-
console.log(`šļø ${change.path}`);
|
|
604
553
|
if (!dryRun) {
|
|
605
554
|
await removePath(localPath);
|
|
606
555
|
this.snapshotManager.removeFileEntry(snapshot, change.path);
|
|
@@ -609,12 +558,6 @@ export class SyncEngine {
|
|
|
609
558
|
}
|
|
610
559
|
|
|
611
560
|
// Create or update local file
|
|
612
|
-
if (change.changeType === ChangeType.REMOTE_ONLY) {
|
|
613
|
-
console.log(`ā¬ļø ${change.path}`);
|
|
614
|
-
} else {
|
|
615
|
-
console.log(`š ${change.path}`);
|
|
616
|
-
}
|
|
617
|
-
|
|
618
561
|
if (!dryRun) {
|
|
619
562
|
await writeFileContent(localPath, change.remoteContent);
|
|
620
563
|
|
|
@@ -904,10 +847,6 @@ export class SyncEngine {
|
|
|
904
847
|
dryRun
|
|
905
848
|
);
|
|
906
849
|
|
|
907
|
-
console.log(
|
|
908
|
-
`š Adding ${fileName} (${fileUrl}) to directory ${parentDirUrl} (path: ${directoryPath})`
|
|
909
|
-
);
|
|
910
|
-
|
|
911
850
|
const dirHandle = await this.repo.find<DirectoryDocument>(parentDirUrl);
|
|
912
851
|
|
|
913
852
|
let didChange = false;
|