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.
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 +119 -51
  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 -85
  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 +182 -73
  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 -105
  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
  }
@@ -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
- // Output timing breakdown if enabled via environment variable
380
- if (process.env.PUSHWORK_TIMING === "1") {
381
- const totalTime = Date.now() - syncStartTime;
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
- if (this.moveDetector.shouldAutoApply(move)) {
429
- try {
430
- await this.applyMoveToRemote(move, snapshot, dryRun);
431
- result.filesChanged++;
432
- } catch (error) {
433
- result.errors.push({
434
- path: move.fromPath,
435
- operation: "move",
436
- error: error as Error,
437
- recoverable: true,
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;