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.
Files changed (67) hide show
  1. package/README.md +8 -1
  2. package/bench/filesystem.bench.ts +78 -0
  3. package/bench/hashing.bench.ts +60 -0
  4. package/bench/move-detection.bench.ts +130 -0
  5. package/bench/runner.ts +49 -0
  6. package/dist/cli/commands.d.ts +15 -25
  7. package/dist/cli/commands.d.ts.map +1 -1
  8. package/dist/cli/commands.js +409 -519
  9. package/dist/cli/commands.js.map +1 -1
  10. package/dist/cli/output.d.ts +75 -0
  11. package/dist/cli/output.d.ts.map +1 -0
  12. package/dist/cli/output.js +182 -0
  13. package/dist/cli/output.js.map +1 -0
  14. package/dist/cli.js +116 -50
  15. package/dist/cli.js.map +1 -1
  16. package/dist/config/remote-manager.d.ts +65 -0
  17. package/dist/config/remote-manager.d.ts.map +1 -0
  18. package/dist/config/remote-manager.js +243 -0
  19. package/dist/config/remote-manager.js.map +1 -0
  20. package/dist/core/change-detection.d.ts +8 -0
  21. package/dist/core/change-detection.d.ts.map +1 -1
  22. package/dist/core/change-detection.js +63 -0
  23. package/dist/core/change-detection.js.map +1 -1
  24. package/dist/core/move-detection.d.ts +9 -48
  25. package/dist/core/move-detection.d.ts.map +1 -1
  26. package/dist/core/move-detection.js +53 -135
  27. package/dist/core/move-detection.js.map +1 -1
  28. package/dist/core/sync-engine.d.ts.map +1 -1
  29. package/dist/core/sync-engine.js +17 -62
  30. package/dist/core/sync-engine.js.map +1 -1
  31. package/dist/types/config.d.ts +45 -5
  32. package/dist/types/config.d.ts.map +1 -1
  33. package/dist/types/documents.d.ts +0 -1
  34. package/dist/types/documents.d.ts.map +1 -1
  35. package/dist/types/snapshot.d.ts +3 -0
  36. package/dist/types/snapshot.d.ts.map +1 -1
  37. package/dist/types/snapshot.js.map +1 -1
  38. package/dist/utils/fs.d.ts.map +1 -1
  39. package/dist/utils/fs.js +9 -33
  40. package/dist/utils/fs.js.map +1 -1
  41. package/dist/utils/index.d.ts +0 -1
  42. package/dist/utils/index.d.ts.map +1 -1
  43. package/dist/utils/index.js +0 -1
  44. package/dist/utils/index.js.map +1 -1
  45. package/dist/utils/repo-factory.d.ts.map +1 -1
  46. package/dist/utils/repo-factory.js +18 -9
  47. package/dist/utils/repo-factory.js.map +1 -1
  48. package/dist/utils/string-similarity.d.ts +14 -0
  49. package/dist/utils/string-similarity.d.ts.map +1 -0
  50. package/dist/utils/string-similarity.js +43 -0
  51. package/dist/utils/string-similarity.js.map +1 -0
  52. package/package.json +10 -5
  53. package/src/cli/commands.ts +520 -697
  54. package/src/cli/output.ts +244 -0
  55. package/src/cli.ts +178 -72
  56. package/src/core/change-detection.ts +95 -0
  57. package/src/core/move-detection.ts +69 -177
  58. package/src/core/sync-engine.ts +17 -78
  59. package/src/types/config.ts +50 -7
  60. package/src/types/documents.ts +0 -1
  61. package/src/types/snapshot.ts +1 -0
  62. package/src/utils/fs.ts +9 -33
  63. package/src/utils/index.ts +0 -1
  64. package/src/utils/repo-factory.ts +21 -8
  65. package/src/utils/string-similarity.ts +54 -0
  66. package/src/utils/content-similarity.ts +0 -194
  67. 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
- SyncSnapshot,
3
- MoveCandidate,
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
- * Move detection engine
7
+ * Simplified move detection engine
12
8
  */
13
9
  export class MoveDetector {
14
- private static readonly AUTO_THRESHOLD = 0.8;
15
- private static readonly PROMPT_THRESHOLD = 0.5;
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 = await this.getDeletedFileContent(
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 ContentSimilarity.calculateSimilarity(
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.PROMPT_THRESHOLD) {
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
- const confidence = ContentSimilarity.getConfidenceLevel(
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
- // Only consume the deletion/creation pair when we would auto-apply the move.
90
- // If we only want to prompt, leave the original changes in place so
91
- // sync can still proceed as delete+create (avoids infinite warning loop).
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
- * Get content of a deleted file from snapshot
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
- validateMoves(moves: MoveCandidate[]): {
141
- validMoves: MoveCandidate[];
142
- conflicts: Array<{ moves: MoveCandidate[]; reason: string }>;
143
- } {
144
- const validMoves: MoveCandidate[] = [];
145
- const conflicts: Array<{ moves: MoveCandidate[]; reason: string }> = [];
146
-
147
- // Check for multiple sources mapping to same destination
148
- const destinationMap = new Map<string, MoveCandidate[]>();
149
- for (const move of moves) {
150
- if (!destinationMap.has(move.toPath)) {
151
- destinationMap.set(move.toPath, []);
152
- }
153
- destinationMap.get(move.toPath)!.push(move);
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
- // Check for multiple destinations from same source
157
- const sourceMap = new Map<string, MoveCandidate[]>();
158
- for (const move of moves) {
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
- for (const move of moves) {
166
- const destinationConflicts = destinationMap.get(move.toPath)!;
167
- const sourceConflicts = sourceMap.get(move.fromPath)!;
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 { validMoves, conflicts };
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 file extension from file path
133
+ * Get representative samples from content (beginning, middle, end)
224
134
  */
225
- private getFileExtension(filePath: string): string {
226
- const lastDot = filePath.lastIndexOf(".");
227
- const lastSlash = filePath.lastIndexOf("/");
135
+ private getSamples(str: string): string[] {
136
+ const CHUNK_SIZE = 1024;
137
+ const length = str.length;
228
138
 
229
- if (lastDot > lastSlash && lastDot >= 0) {
230
- return filePath.substring(lastDot + 1).toLowerCase();
139
+ if (length <= CHUNK_SIZE) {
140
+ return [str];
231
141
  }
232
142
 
233
- return "";
234
- }
235
-
236
- /**
237
- * Check if a move should be auto-applied
238
- */
239
- shouldAutoApply(move: MoveCandidate): boolean {
240
- return move.confidence === "auto";
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
- * Check if a move should prompt the user
154
+ * Convert buffer to string (for text comparison)
245
155
  */
246
- shouldPromptUser(move: MoveCandidate): boolean {
247
- return move.confidence === "prompt";
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
  }
@@ -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
- // Output timing breakdown if enabled via environment variable
378
- if (process.env.PUSHWORK_TIMING === "1") {
379
- const totalTime = Date.now() - syncStartTime;
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
- if (this.moveDetector.shouldAutoApply(move)) {
427
- try {
428
- await this.applyMoveToRemote(move, snapshot, dryRun);
429
- result.filesChanged++;
430
- } catch (error) {
431
- result.errors.push({
432
- path: move.fromPath,
433
- operation: "move",
434
- error: error as Error,
435
- recoverable: true,
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;