pushwork 1.0.4 → 1.0.7
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 +87 -328
- package/dist/.pushwork/automerge/3P/Dm3ekE2pmjGnWvDaG3vSR7ww98/snapshot/aa2349c94955ea561f698720142f9d884a6872d9f82dc332d578c216beb0df0e +0 -0
- package/dist/.pushwork/automerge/st/orage-adapter-id +1 -0
- package/dist/.pushwork/config.json +15 -0
- package/dist/.pushwork/snapshot.json +7 -0
- package/dist/cli.js +231 -170
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts +51 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +799 -0
- package/dist/commands.js.map +1 -0
- package/dist/core/change-detection.d.ts +6 -19
- package/dist/core/change-detection.d.ts.map +1 -1
- package/dist/core/change-detection.js +101 -80
- package/dist/core/change-detection.js.map +1 -1
- package/dist/{config/index.d.ts → core/config.d.ts} +13 -3
- package/dist/core/config.d.ts.map +1 -0
- package/dist/{config/index.js → core/config.js} +55 -73
- package/dist/core/config.js.map +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/move-detection.d.ts +12 -50
- package/dist/core/move-detection.d.ts.map +1 -1
- package/dist/core/move-detection.js +58 -139
- package/dist/core/move-detection.js.map +1 -1
- package/dist/core/snapshot.d.ts +0 -4
- package/dist/core/snapshot.d.ts.map +1 -1
- package/dist/core/snapshot.js +2 -11
- package/dist/core/snapshot.js.map +1 -1
- package/dist/core/sync-engine.d.ts +5 -11
- package/dist/core/sync-engine.d.ts.map +1 -1
- package/dist/core/sync-engine.js +220 -362
- package/dist/core/sync-engine.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -6
- package/dist/index.js.map +1 -1
- package/dist/types/config.d.ts +43 -67
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +6 -0
- package/dist/types/config.js.map +1 -1
- package/dist/types/documents.d.ts +15 -3
- package/dist/types/documents.d.ts.map +1 -1
- package/dist/types/documents.js.map +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +0 -3
- package/dist/types/index.js.map +1 -1
- package/dist/types/snapshot.d.ts +3 -21
- package/dist/types/snapshot.d.ts.map +1 -1
- package/dist/types/snapshot.js +0 -14
- package/dist/types/snapshot.js.map +1 -1
- package/dist/utils/content.d.ts.map +1 -1
- package/dist/utils/content.js +2 -6
- package/dist/utils/content.js.map +1 -1
- package/dist/utils/directory.d.ts +10 -0
- package/dist/utils/directory.d.ts.map +1 -0
- package/dist/utils/directory.js +37 -0
- package/dist/utils/directory.js.map +1 -0
- package/dist/utils/fs.d.ts +15 -2
- package/dist/utils/fs.d.ts.map +1 -1
- package/dist/utils/fs.js +63 -53
- package/dist/utils/fs.js.map +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -4
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/mime-types.d.ts.map +1 -1
- package/dist/utils/mime-types.js +11 -4
- package/dist/utils/mime-types.js.map +1 -1
- package/dist/utils/network-sync.d.ts +0 -6
- package/dist/utils/network-sync.d.ts.map +1 -1
- package/dist/utils/network-sync.js +55 -99
- package/dist/utils/network-sync.js.map +1 -1
- package/dist/utils/output.d.ts +129 -0
- package/dist/utils/output.d.ts.map +1 -0
- package/dist/utils/output.js +375 -0
- package/dist/utils/output.js.map +1 -0
- package/dist/utils/repo-factory.d.ts +2 -6
- package/dist/utils/repo-factory.d.ts.map +1 -1
- package/dist/utils/repo-factory.js +8 -22
- 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/dist/utils/trace.d.ts +19 -0
- package/dist/utils/trace.d.ts.map +1 -0
- package/dist/utils/trace.js +68 -0
- package/dist/utils/trace.js.map +1 -0
- package/package.json +17 -12
- package/src/cli.ts +326 -252
- package/src/commands.ts +988 -0
- package/src/core/change-detection.ts +199 -162
- package/src/{config/index.ts → core/config.ts} +65 -82
- package/src/core/index.ts +1 -1
- package/src/core/move-detection.ts +74 -180
- package/src/core/snapshot.ts +2 -12
- package/src/core/sync-engine.ts +248 -499
- package/src/index.ts +0 -10
- package/src/types/config.ts +50 -72
- package/src/types/documents.ts +16 -3
- package/src/types/index.ts +0 -5
- package/src/types/snapshot.ts +1 -23
- package/src/utils/content.ts +2 -6
- package/src/utils/directory.ts +50 -0
- package/src/utils/fs.ts +67 -56
- package/src/utils/index.ts +1 -6
- package/src/utils/mime-types.ts +12 -4
- package/src/utils/network-sync.ts +79 -137
- package/src/utils/output.ts +450 -0
- package/src/utils/repo-factory.ts +13 -31
- package/src/utils/string-similarity.ts +54 -0
- package/src/utils/trace.ts +70 -0
- package/test/integration/exclude-patterns.test.ts +6 -15
- package/test/integration/fuzzer.test.ts +308 -391
- package/test/integration/init-sync.test.ts +89 -0
- package/test/integration/sync-deletion.test.ts +2 -61
- package/test/integration/sync-flow.test.ts +4 -24
- package/test/jest.setup.ts +34 -0
- package/test/unit/deletion-behavior.test.ts +3 -14
- package/test/unit/enhanced-mime-detection.test.ts +0 -22
- package/test/unit/snapshot.test.ts +2 -29
- package/test/unit/sync-convergence.test.ts +3 -198
- package/test/unit/sync-timing.test.ts +0 -44
- package/test/unit/utils.test.ts +0 -2
- package/tsconfig.json +3 -3
- package/dist/browser/browser-sync-engine.d.ts +0 -64
- package/dist/browser/browser-sync-engine.d.ts.map +0 -1
- package/dist/browser/browser-sync-engine.js +0 -303
- package/dist/browser/browser-sync-engine.js.map +0 -1
- package/dist/browser/filesystem-adapter.d.ts +0 -84
- package/dist/browser/filesystem-adapter.d.ts.map +0 -1
- package/dist/browser/filesystem-adapter.js +0 -413
- package/dist/browser/filesystem-adapter.js.map +0 -1
- package/dist/browser/index.d.ts +0 -36
- package/dist/browser/index.d.ts.map +0 -1
- package/dist/browser/index.js +0 -90
- package/dist/browser/index.js.map +0 -1
- package/dist/browser/types.d.ts +0 -70
- package/dist/browser/types.d.ts.map +0 -1
- package/dist/browser/types.js +0 -6
- package/dist/browser/types.js.map +0 -1
- package/dist/cli/commands.d.ts +0 -77
- package/dist/cli/commands.d.ts.map +0 -1
- package/dist/cli/commands.js +0 -904
- package/dist/cli/commands.js.map +0 -1
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/index.js +0 -19
- package/dist/cli/index.js.map +0 -1
- package/dist/config/index.d.ts.map +0 -1
- package/dist/config/index.js.map +0 -1
- package/dist/core/isomorphic-snapshot.d.ts +0 -58
- package/dist/core/isomorphic-snapshot.d.ts.map +0 -1
- package/dist/core/isomorphic-snapshot.js +0 -204
- package/dist/core/isomorphic-snapshot.js.map +0 -1
- package/dist/platform/browser-filesystem.d.ts +0 -26
- package/dist/platform/browser-filesystem.d.ts.map +0 -1
- package/dist/platform/browser-filesystem.js +0 -91
- package/dist/platform/browser-filesystem.js.map +0 -1
- package/dist/platform/filesystem.d.ts +0 -29
- package/dist/platform/filesystem.d.ts.map +0 -1
- package/dist/platform/filesystem.js +0 -65
- package/dist/platform/filesystem.js.map +0 -1
- package/dist/platform/node-filesystem.d.ts +0 -21
- package/dist/platform/node-filesystem.d.ts.map +0 -1
- package/dist/platform/node-filesystem.js +0 -93
- package/dist/platform/node-filesystem.js.map +0 -1
- package/dist/utils/content-similarity.d.ts +0 -53
- package/dist/utils/content-similarity.d.ts.map +0 -1
- package/dist/utils/content-similarity.js +0 -155
- package/dist/utils/content-similarity.js.map +0 -1
- package/dist/utils/fs-browser.d.ts +0 -57
- package/dist/utils/fs-browser.d.ts.map +0 -1
- package/dist/utils/fs-browser.js +0 -311
- package/dist/utils/fs-browser.js.map +0 -1
- package/dist/utils/fs-node.d.ts +0 -53
- package/dist/utils/fs-node.d.ts.map +0 -1
- package/dist/utils/fs-node.js +0 -220
- package/dist/utils/fs-node.js.map +0 -1
- package/dist/utils/isomorphic.d.ts +0 -29
- package/dist/utils/isomorphic.d.ts.map +0 -1
- package/dist/utils/isomorphic.js +0 -139
- package/dist/utils/isomorphic.js.map +0 -1
- package/dist/utils/pure.d.ts +0 -25
- package/dist/utils/pure.d.ts.map +0 -1
- package/dist/utils/pure.js +0 -112
- package/dist/utils/pure.js.map +0 -1
- package/src/cli/commands.ts +0 -1207
- package/src/cli/index.ts +0 -2
- package/src/utils/content-similarity.ts +0 -194
- package/test/README-TESTING-GAPS.md +0 -174
- package/test/unit/content-similarity.test.ts +0 -236
package/src/core/sync-engine.ts
CHANGED
|
@@ -1,40 +1,39 @@
|
|
|
1
|
-
import {
|
|
2
|
-
AutomergeUrl,
|
|
3
|
-
Repo,
|
|
4
|
-
updateText,
|
|
5
|
-
DocHandle,
|
|
6
|
-
UrlHeads,
|
|
7
|
-
} from "@automerge/automerge-repo";
|
|
1
|
+
import { AutomergeUrl, Repo, DocHandle } from "@automerge/automerge-repo";
|
|
8
2
|
import * as A from "@automerge/automerge";
|
|
9
3
|
import {
|
|
10
4
|
SyncSnapshot,
|
|
11
5
|
SyncResult,
|
|
12
|
-
SyncError,
|
|
13
|
-
SyncOperation,
|
|
14
|
-
PendingSyncOperation,
|
|
15
6
|
FileDocument,
|
|
16
7
|
DirectoryDocument,
|
|
17
|
-
FileType,
|
|
18
8
|
ChangeType,
|
|
19
9
|
MoveCandidate,
|
|
10
|
+
DirectoryConfig,
|
|
11
|
+
DetectedChange,
|
|
20
12
|
} from "../types";
|
|
21
13
|
import {
|
|
22
|
-
readFileContent,
|
|
23
14
|
writeFileContent,
|
|
24
15
|
removePath,
|
|
25
|
-
movePath,
|
|
26
|
-
ensureDirectoryExists,
|
|
27
16
|
getFileExtension,
|
|
28
|
-
normalizePath,
|
|
29
|
-
getRelativePath,
|
|
30
17
|
getEnhancedMimeType,
|
|
31
|
-
|
|
18
|
+
formatRelativePath,
|
|
19
|
+
findFileInDirectoryHierarchy,
|
|
20
|
+
joinAndNormalizePath,
|
|
32
21
|
} from "../utils";
|
|
33
22
|
import { isContentEqual } from "../utils/content";
|
|
34
|
-
import { waitForSync
|
|
23
|
+
import { waitForSync } from "../utils/network-sync";
|
|
35
24
|
import { SnapshotManager } from "./snapshot";
|
|
36
|
-
import { ChangeDetector
|
|
25
|
+
import { ChangeDetector } from "./change-detection";
|
|
37
26
|
import { MoveDetector } from "./move-detection";
|
|
27
|
+
import { out } from "../utils/output";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Post-sync delay constants for network propagation
|
|
31
|
+
* These delays allow the WebSocket protocol to propagate peer changes after
|
|
32
|
+
* our changes reach the server. waitForSync only ensures OUR changes reached
|
|
33
|
+
* the server, not that we've RECEIVED changes from other peers.
|
|
34
|
+
* TODO: remove need for this to exist.
|
|
35
|
+
*/
|
|
36
|
+
const POST_SYNC_DELAY_MS = 200; // After we pushed changes
|
|
38
37
|
|
|
39
38
|
/**
|
|
40
39
|
* Bidirectional sync engine implementing two-phase sync
|
|
@@ -43,22 +42,22 @@ export class SyncEngine {
|
|
|
43
42
|
private snapshotManager: SnapshotManager;
|
|
44
43
|
private changeDetector: ChangeDetector;
|
|
45
44
|
private moveDetector: MoveDetector;
|
|
46
|
-
private networkSyncEnabled: boolean = true;
|
|
47
45
|
private handlesToWaitOn: DocHandle<unknown>[] = [];
|
|
48
|
-
private
|
|
46
|
+
private config: DirectoryConfig;
|
|
49
47
|
|
|
50
48
|
constructor(
|
|
51
49
|
private repo: Repo,
|
|
52
50
|
private rootPath: string,
|
|
53
|
-
|
|
54
|
-
networkSyncEnabled: boolean = true,
|
|
55
|
-
syncServerStorageId?: string
|
|
51
|
+
config: DirectoryConfig
|
|
56
52
|
) {
|
|
53
|
+
this.config = config;
|
|
57
54
|
this.snapshotManager = new SnapshotManager(rootPath);
|
|
58
|
-
this.changeDetector = new ChangeDetector(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
55
|
+
this.changeDetector = new ChangeDetector(
|
|
56
|
+
repo,
|
|
57
|
+
rootPath,
|
|
58
|
+
config.exclude_patterns
|
|
59
|
+
);
|
|
60
|
+
this.moveDetector = new MoveDetector(config.sync.move_detection_threshold);
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
/**
|
|
@@ -86,9 +85,7 @@ export class SyncEngine {
|
|
|
86
85
|
/**
|
|
87
86
|
* Commit local changes only (no network sync)
|
|
88
87
|
*/
|
|
89
|
-
async commitLocal(
|
|
90
|
-
console.log(`🚀 Starting local commit process (dryRun: ${dryRun})`);
|
|
91
|
-
|
|
88
|
+
async commitLocal(): Promise<SyncResult> {
|
|
92
89
|
const result: SyncResult = {
|
|
93
90
|
success: false,
|
|
94
91
|
filesChanged: 0,
|
|
@@ -99,50 +96,25 @@ export class SyncEngine {
|
|
|
99
96
|
|
|
100
97
|
try {
|
|
101
98
|
// Load current snapshot
|
|
102
|
-
console.log(`📸 Loading current snapshot...`);
|
|
103
99
|
let snapshot = await this.snapshotManager.load();
|
|
104
100
|
if (!snapshot) {
|
|
105
|
-
console.log(`📸 No snapshot found, creating empty one`);
|
|
106
101
|
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
|
-
}
|
|
113
|
-
|
|
114
|
-
// Backup snapshot before starting
|
|
115
|
-
if (!dryRun) {
|
|
116
|
-
console.log(`💾 Backing up snapshot...`);
|
|
117
|
-
await this.snapshotManager.backup();
|
|
118
102
|
}
|
|
119
103
|
|
|
120
104
|
// Detect all changes
|
|
121
|
-
console.log(`🔍 Detecting changes...`);
|
|
122
105
|
const changes = await this.changeDetector.detectChanges(snapshot);
|
|
123
|
-
console.log(`🔍 Found ${changes.length} changes`);
|
|
124
106
|
|
|
125
107
|
// Detect moves
|
|
126
|
-
console.log(`📦 Detecting moves...`);
|
|
127
108
|
const { moves, remainingChanges } = await this.moveDetector.detectMoves(
|
|
128
109
|
changes,
|
|
129
|
-
snapshot
|
|
130
|
-
this.rootPath
|
|
131
|
-
);
|
|
132
|
-
console.log(
|
|
133
|
-
`📦 Found ${moves.length} moves, ${remainingChanges.length} remaining changes`
|
|
110
|
+
snapshot
|
|
134
111
|
);
|
|
135
112
|
|
|
136
113
|
// Apply local changes only (no network sync)
|
|
137
|
-
console.log(`💾 Committing local changes...`);
|
|
138
114
|
const commitResult = await this.pushLocalChanges(
|
|
139
115
|
remainingChanges,
|
|
140
116
|
moves,
|
|
141
|
-
snapshot
|
|
142
|
-
dryRun
|
|
143
|
-
);
|
|
144
|
-
console.log(
|
|
145
|
-
`💾 Commit complete: ${commitResult.filesChanged} files changed`
|
|
117
|
+
snapshot
|
|
146
118
|
);
|
|
147
119
|
|
|
148
120
|
result.filesChanged += commitResult.filesChanged;
|
|
@@ -154,20 +126,16 @@ export class SyncEngine {
|
|
|
154
126
|
const hasChanges =
|
|
155
127
|
result.filesChanged > 0 || result.directoriesChanged > 0;
|
|
156
128
|
if (hasChanges) {
|
|
157
|
-
await this.touchRootDirectory(snapshot
|
|
129
|
+
await this.touchRootDirectory(snapshot);
|
|
158
130
|
}
|
|
159
131
|
|
|
160
|
-
// Save updated snapshot
|
|
161
|
-
|
|
162
|
-
await this.snapshotManager.save(snapshot);
|
|
163
|
-
}
|
|
132
|
+
// Save updated snapshot
|
|
133
|
+
await this.snapshotManager.save(snapshot);
|
|
164
134
|
|
|
165
135
|
result.success = result.errors.length === 0;
|
|
166
|
-
console.log(`💾 Local commit ${result.success ? "completed" : "failed"}`);
|
|
167
136
|
|
|
168
137
|
return result;
|
|
169
138
|
} catch (error) {
|
|
170
|
-
console.error(`❌ Local commit failed: ${error}`);
|
|
171
139
|
result.errors.push({
|
|
172
140
|
path: this.rootPath,
|
|
173
141
|
operation: "commitLocal",
|
|
@@ -182,16 +150,14 @@ export class SyncEngine {
|
|
|
182
150
|
/**
|
|
183
151
|
* Run full bidirectional sync
|
|
184
152
|
*/
|
|
185
|
-
async sync(
|
|
186
|
-
const syncStartTime = Date.now();
|
|
187
|
-
const timings: { [key: string]: number } = {};
|
|
188
|
-
|
|
153
|
+
async sync(): Promise<SyncResult> {
|
|
189
154
|
const result: SyncResult = {
|
|
190
155
|
success: false,
|
|
191
156
|
filesChanged: 0,
|
|
192
157
|
directoriesChanged: 0,
|
|
193
158
|
errors: [],
|
|
194
159
|
warnings: [],
|
|
160
|
+
timings: {},
|
|
195
161
|
};
|
|
196
162
|
|
|
197
163
|
// Reset handles to wait on
|
|
@@ -199,47 +165,25 @@ export class SyncEngine {
|
|
|
199
165
|
|
|
200
166
|
try {
|
|
201
167
|
// Load current snapshot
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (!snapshot) {
|
|
206
|
-
snapshot = this.snapshotManager.createEmpty();
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Backup snapshot before starting
|
|
210
|
-
const t1 = Date.now();
|
|
211
|
-
if (!dryRun) {
|
|
212
|
-
await this.snapshotManager.backup();
|
|
213
|
-
}
|
|
214
|
-
timings["backup_snapshot"] = Date.now() - t1;
|
|
168
|
+
const snapshot =
|
|
169
|
+
(await this.snapshotManager.load()) ||
|
|
170
|
+
this.snapshotManager.createEmpty();
|
|
215
171
|
|
|
216
172
|
// Detect all changes
|
|
217
|
-
const t2 = Date.now();
|
|
218
173
|
const changes = await this.changeDetector.detectChanges(snapshot);
|
|
219
|
-
timings["detect_changes"] = Date.now() - t2;
|
|
220
174
|
|
|
221
175
|
// Detect moves
|
|
222
|
-
const t3 = Date.now();
|
|
223
176
|
const { moves, remainingChanges } = await this.moveDetector.detectMoves(
|
|
224
177
|
changes,
|
|
225
|
-
snapshot
|
|
226
|
-
this.rootPath
|
|
178
|
+
snapshot
|
|
227
179
|
);
|
|
228
|
-
timings["detect_moves"] = Date.now() - t3;
|
|
229
|
-
|
|
230
|
-
if (changes.length > 0) {
|
|
231
|
-
console.log(`🔄 Syncing ${changes.length} changes...`);
|
|
232
|
-
}
|
|
233
180
|
|
|
234
181
|
// Phase 1: Push local changes to remote
|
|
235
|
-
const t4 = Date.now();
|
|
236
182
|
const phase1Result = await this.pushLocalChanges(
|
|
237
183
|
remainingChanges,
|
|
238
184
|
moves,
|
|
239
|
-
snapshot
|
|
240
|
-
dryRun
|
|
185
|
+
snapshot
|
|
241
186
|
);
|
|
242
|
-
timings["phase1_push"] = Date.now() - t4;
|
|
243
187
|
|
|
244
188
|
result.filesChanged += phase1Result.filesChanged;
|
|
245
189
|
result.directoriesChanged += phase1Result.directoriesChanged;
|
|
@@ -248,24 +192,22 @@ export class SyncEngine {
|
|
|
248
192
|
|
|
249
193
|
// Always wait for network sync when enabled (not just when local changes exist)
|
|
250
194
|
// This is critical for clone scenarios where we need to pull remote changes
|
|
251
|
-
|
|
252
|
-
if (!dryRun && this.networkSyncEnabled) {
|
|
195
|
+
if (this.config.sync_enabled) {
|
|
253
196
|
try {
|
|
254
197
|
// If we have a root directory URL, wait for it to sync
|
|
255
198
|
if (snapshot.rootDirectoryUrl) {
|
|
199
|
+
const rootDirUrl = snapshot.rootDirectoryUrl;
|
|
256
200
|
const rootHandle = await this.repo.find<DirectoryDocument>(
|
|
257
|
-
|
|
201
|
+
rootDirUrl
|
|
258
202
|
);
|
|
259
203
|
this.handlesToWaitOn.push(rootHandle);
|
|
260
204
|
}
|
|
261
205
|
|
|
262
206
|
if (this.handlesToWaitOn.length > 0) {
|
|
263
|
-
const tWaitStart = Date.now();
|
|
264
207
|
await waitForSync(
|
|
265
208
|
this.handlesToWaitOn,
|
|
266
|
-
|
|
209
|
+
this.config.sync_server_storage_id
|
|
267
210
|
);
|
|
268
|
-
timings["network_sync"] = Date.now() - tWaitStart;
|
|
269
211
|
|
|
270
212
|
// CRITICAL: Wait a bit after our changes reach the server to allow
|
|
271
213
|
// time for WebSocket to deliver OTHER peers' changes to us.
|
|
@@ -276,38 +218,32 @@ export class SyncEngine {
|
|
|
276
218
|
// each other due to timing races.
|
|
277
219
|
//
|
|
278
220
|
// Optimization: Only wait if we pushed changes (shorter delay if no changes)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
221
|
+
|
|
222
|
+
await new Promise((resolve) =>
|
|
223
|
+
setTimeout(resolve, POST_SYNC_DELAY_MS)
|
|
224
|
+
);
|
|
283
225
|
}
|
|
284
226
|
} catch (error) {
|
|
285
|
-
|
|
227
|
+
out.taskLine(`Network sync failed: ${error}`, true);
|
|
286
228
|
result.warnings.push(`Network sync failed: ${error}`);
|
|
287
229
|
}
|
|
288
230
|
}
|
|
289
|
-
timings["total_network"] = Date.now() - t5;
|
|
290
231
|
|
|
291
232
|
// Re-detect remote changes after network sync to ensure fresh state
|
|
292
233
|
// This fixes race conditions where we detect changes before server propagation
|
|
293
234
|
// NOTE: We DON'T update snapshot heads yet - that would prevent detecting remote changes!
|
|
294
|
-
const t6 = Date.now();
|
|
295
235
|
const freshChanges = await this.changeDetector.detectChanges(snapshot);
|
|
296
236
|
const freshRemoteChanges = freshChanges.filter(
|
|
297
237
|
(c) =>
|
|
298
238
|
c.changeType === ChangeType.REMOTE_ONLY ||
|
|
299
239
|
c.changeType === ChangeType.BOTH_CHANGED
|
|
300
240
|
);
|
|
301
|
-
timings["redetect_changes"] = Date.now() - t6;
|
|
302
241
|
|
|
303
242
|
// Phase 2: Pull remote changes to local using fresh detection
|
|
304
|
-
const t7 = Date.now();
|
|
305
243
|
const phase2Result = await this.pullRemoteChanges(
|
|
306
244
|
freshRemoteChanges,
|
|
307
|
-
snapshot
|
|
308
|
-
dryRun
|
|
245
|
+
snapshot
|
|
309
246
|
);
|
|
310
|
-
timings["phase2_pull"] = Date.now() - t7;
|
|
311
247
|
result.filesChanged += phase2Result.filesChanged;
|
|
312
248
|
result.directoriesChanged += phase2Result.directoriesChanged;
|
|
313
249
|
result.errors.push(...phase2Result.errors);
|
|
@@ -316,80 +252,49 @@ export class SyncEngine {
|
|
|
316
252
|
// CRITICAL FIX: Update snapshot heads AFTER pulling remote changes
|
|
317
253
|
// This ensures that change detection can find remote changes, and we only
|
|
318
254
|
// update the snapshot after the filesystem is in sync with the documents
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
head: currentHeads,
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
} catch (error) {
|
|
334
|
-
// Handle might not exist if file was deleted, skip
|
|
335
|
-
console.warn(`Could not update heads for ${filePath}: ${error}`);
|
|
255
|
+
// Update file document heads
|
|
256
|
+
for (const [filePath, snapshotEntry] of snapshot.files.entries()) {
|
|
257
|
+
try {
|
|
258
|
+
const handle = await this.repo.find(snapshotEntry.url);
|
|
259
|
+
const currentHeads = handle.heads();
|
|
260
|
+
if (!A.equals(currentHeads, snapshotEntry.head)) {
|
|
261
|
+
// Update snapshot with current heads after pulling changes
|
|
262
|
+
snapshot.files.set(filePath, {
|
|
263
|
+
...snapshotEntry,
|
|
264
|
+
head: currentHeads,
|
|
265
|
+
});
|
|
336
266
|
}
|
|
267
|
+
} catch (error) {
|
|
268
|
+
// Handle might not exist if file was deleted
|
|
337
269
|
}
|
|
270
|
+
}
|
|
338
271
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}
|
|
351
|
-
} catch (error) {
|
|
352
|
-
// Handle might not exist if directory was deleted, skip
|
|
353
|
-
console.warn(
|
|
354
|
-
`Could not update heads for directory ${dirPath}: ${error}`
|
|
355
|
-
);
|
|
272
|
+
// Update directory document heads
|
|
273
|
+
for (const [dirPath, snapshotEntry] of snapshot.directories.entries()) {
|
|
274
|
+
try {
|
|
275
|
+
const handle = await this.repo.find(snapshotEntry.url);
|
|
276
|
+
const currentHeads = handle.heads();
|
|
277
|
+
if (!A.equals(currentHeads, snapshotEntry.head)) {
|
|
278
|
+
// Update snapshot with current heads after pulling changes
|
|
279
|
+
snapshot.directories.set(dirPath, {
|
|
280
|
+
...snapshotEntry,
|
|
281
|
+
head: currentHeads,
|
|
282
|
+
});
|
|
356
283
|
}
|
|
284
|
+
} catch (error) {
|
|
285
|
+
// Handle might not exist if directory was deleted
|
|
357
286
|
}
|
|
358
287
|
}
|
|
359
|
-
timings["update_snapshot_heads"] = Date.now() - t8;
|
|
360
288
|
|
|
361
289
|
// Touch root directory if any changes were made during sync
|
|
362
|
-
const t9 = Date.now();
|
|
363
290
|
const hasChanges =
|
|
364
291
|
result.filesChanged > 0 || result.directoriesChanged > 0;
|
|
365
292
|
if (hasChanges) {
|
|
366
|
-
await this.touchRootDirectory(snapshot
|
|
293
|
+
await this.touchRootDirectory(snapshot);
|
|
367
294
|
}
|
|
368
|
-
timings["touch_root"] = Date.now() - t9;
|
|
369
295
|
|
|
370
296
|
// Save updated snapshot if not dry run
|
|
371
|
-
|
|
372
|
-
if (!dryRun) {
|
|
373
|
-
await this.snapshotManager.save(snapshot);
|
|
374
|
-
}
|
|
375
|
-
timings["save_snapshot"] = Date.now() - t10;
|
|
376
|
-
|
|
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
|
-
}
|
|
297
|
+
await this.snapshotManager.save(snapshot);
|
|
393
298
|
|
|
394
299
|
result.success = result.errors.length === 0;
|
|
395
300
|
return result;
|
|
@@ -410,8 +315,7 @@ export class SyncEngine {
|
|
|
410
315
|
private async pushLocalChanges(
|
|
411
316
|
changes: DetectedChange[],
|
|
412
317
|
moves: MoveCandidate[],
|
|
413
|
-
snapshot: SyncSnapshot
|
|
414
|
-
dryRun: boolean
|
|
318
|
+
snapshot: SyncSnapshot
|
|
415
319
|
): Promise<SyncResult> {
|
|
416
320
|
const result: SyncResult = {
|
|
417
321
|
success: true,
|
|
@@ -421,28 +325,18 @@ export class SyncEngine {
|
|
|
421
325
|
warnings: [],
|
|
422
326
|
};
|
|
423
327
|
|
|
424
|
-
// Process moves first
|
|
328
|
+
// Process moves first - all detected moves are applied
|
|
425
329
|
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
|
-
);
|
|
330
|
+
try {
|
|
331
|
+
await this.applyMoveToRemote(move, snapshot);
|
|
332
|
+
result.filesChanged++;
|
|
333
|
+
} catch (error) {
|
|
334
|
+
result.errors.push({
|
|
335
|
+
path: move.fromPath,
|
|
336
|
+
operation: "move",
|
|
337
|
+
error: error as Error,
|
|
338
|
+
recoverable: true,
|
|
339
|
+
});
|
|
446
340
|
}
|
|
447
341
|
}
|
|
448
342
|
|
|
@@ -455,7 +349,7 @@ export class SyncEngine {
|
|
|
455
349
|
|
|
456
350
|
for (const change of localChanges) {
|
|
457
351
|
try {
|
|
458
|
-
await this.applyLocalChangeToRemote(change, snapshot
|
|
352
|
+
await this.applyLocalChangeToRemote(change, snapshot);
|
|
459
353
|
result.filesChanged++;
|
|
460
354
|
} catch (error) {
|
|
461
355
|
result.errors.push({
|
|
@@ -475,8 +369,7 @@ export class SyncEngine {
|
|
|
475
369
|
*/
|
|
476
370
|
private async pullRemoteChanges(
|
|
477
371
|
changes: DetectedChange[],
|
|
478
|
-
snapshot: SyncSnapshot
|
|
479
|
-
dryRun: boolean
|
|
372
|
+
snapshot: SyncSnapshot
|
|
480
373
|
): Promise<SyncResult> {
|
|
481
374
|
const result: SyncResult = {
|
|
482
375
|
success: true,
|
|
@@ -498,7 +391,7 @@ export class SyncEngine {
|
|
|
498
391
|
|
|
499
392
|
for (const change of sortedChanges) {
|
|
500
393
|
try {
|
|
501
|
-
await this.applyRemoteChangeToLocal(change, snapshot
|
|
394
|
+
await this.applyRemoteChangeToLocal(change, snapshot);
|
|
502
395
|
result.filesChanged++;
|
|
503
396
|
} catch (error) {
|
|
504
397
|
result.errors.push({
|
|
@@ -518,8 +411,7 @@ export class SyncEngine {
|
|
|
518
411
|
*/
|
|
519
412
|
private async applyLocalChangeToRemote(
|
|
520
413
|
change: DetectedChange,
|
|
521
|
-
snapshot: SyncSnapshot
|
|
522
|
-
dryRun: boolean
|
|
414
|
+
snapshot: SyncSnapshot
|
|
523
415
|
): Promise<void> {
|
|
524
416
|
const snapshotEntry = snapshot.files.get(change.path);
|
|
525
417
|
|
|
@@ -528,38 +420,24 @@ export class SyncEngine {
|
|
|
528
420
|
if (change.localContent === null) {
|
|
529
421
|
// File was deleted locally
|
|
530
422
|
if (snapshotEntry) {
|
|
531
|
-
|
|
532
|
-
await this.deleteRemoteFile(
|
|
533
|
-
snapshotEntry.url,
|
|
534
|
-
dryRun,
|
|
535
|
-
snapshot,
|
|
536
|
-
change.path
|
|
537
|
-
);
|
|
423
|
+
await this.deleteRemoteFile(snapshotEntry.url, snapshot, change.path);
|
|
538
424
|
// Remove from directory document
|
|
539
|
-
await this.removeFileFromDirectory(snapshot, change.path
|
|
540
|
-
|
|
541
|
-
this.snapshotManager.removeFileEntry(snapshot, change.path);
|
|
542
|
-
}
|
|
425
|
+
await this.removeFileFromDirectory(snapshot, change.path);
|
|
426
|
+
this.snapshotManager.removeFileEntry(snapshot, change.path);
|
|
543
427
|
}
|
|
544
428
|
return;
|
|
545
429
|
}
|
|
546
430
|
|
|
547
431
|
if (!snapshotEntry) {
|
|
548
432
|
// New file
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
await this.addFileToDirectory(
|
|
553
|
-
snapshot,
|
|
554
|
-
change.path,
|
|
555
|
-
handle.url,
|
|
556
|
-
dryRun
|
|
557
|
-
);
|
|
433
|
+
const handle = await this.createRemoteFile(change);
|
|
434
|
+
if (handle) {
|
|
435
|
+
await this.addFileToDirectory(snapshot, change.path, handle.url);
|
|
558
436
|
|
|
559
437
|
// CRITICAL FIX: Update snapshot with heads AFTER adding to directory
|
|
560
438
|
// The addFileToDirectory call above may have changed the document heads
|
|
561
439
|
this.snapshotManager.updateFileEntry(snapshot, change.path, {
|
|
562
|
-
path:
|
|
440
|
+
path: joinAndNormalizePath(this.rootPath, change.path),
|
|
563
441
|
url: handle.url,
|
|
564
442
|
head: handle.heads(),
|
|
565
443
|
extension: getFileExtension(change.path),
|
|
@@ -568,12 +446,9 @@ export class SyncEngine {
|
|
|
568
446
|
}
|
|
569
447
|
} else {
|
|
570
448
|
// Update existing file
|
|
571
|
-
console.log(`📝 ${change.path}`);
|
|
572
|
-
|
|
573
449
|
await this.updateRemoteFile(
|
|
574
450
|
snapshotEntry.url,
|
|
575
451
|
change.localContent,
|
|
576
|
-
dryRun,
|
|
577
452
|
snapshot,
|
|
578
453
|
change.path
|
|
579
454
|
);
|
|
@@ -585,10 +460,9 @@ export class SyncEngine {
|
|
|
585
460
|
*/
|
|
586
461
|
private async applyRemoteChangeToLocal(
|
|
587
462
|
change: DetectedChange,
|
|
588
|
-
snapshot: SyncSnapshot
|
|
589
|
-
dryRun: boolean
|
|
463
|
+
snapshot: SyncSnapshot
|
|
590
464
|
): Promise<void> {
|
|
591
|
-
const localPath =
|
|
465
|
+
const localPath = joinAndNormalizePath(this.rootPath, change.path);
|
|
592
466
|
|
|
593
467
|
if (!change.remoteHead) {
|
|
594
468
|
throw new Error(
|
|
@@ -600,53 +474,45 @@ export class SyncEngine {
|
|
|
600
474
|
// Empty strings "" and empty Uint8Array are valid file content!
|
|
601
475
|
if (change.remoteContent === null) {
|
|
602
476
|
// File was deleted remotely
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
await removePath(localPath);
|
|
606
|
-
this.snapshotManager.removeFileEntry(snapshot, change.path);
|
|
607
|
-
}
|
|
477
|
+
await removePath(localPath);
|
|
478
|
+
this.snapshotManager.removeFileEntry(snapshot, change.path);
|
|
608
479
|
return;
|
|
609
480
|
}
|
|
610
481
|
|
|
611
482
|
// Create or update local file
|
|
612
|
-
|
|
613
|
-
console.log(`⬇️ ${change.path}`);
|
|
614
|
-
} else {
|
|
615
|
-
console.log(`🔀 ${change.path}`);
|
|
616
|
-
}
|
|
483
|
+
await writeFileContent(localPath, change.remoteContent);
|
|
617
484
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
// Update
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
change.path
|
|
634
|
-
);
|
|
485
|
+
// Update or create snapshot entry for this file
|
|
486
|
+
const snapshotEntry = snapshot.files.get(change.path);
|
|
487
|
+
if (snapshotEntry) {
|
|
488
|
+
// Update existing entry
|
|
489
|
+
snapshotEntry.head = change.remoteHead;
|
|
490
|
+
} else {
|
|
491
|
+
// Create new snapshot entry for newly discovered remote file
|
|
492
|
+
// We need to find the remote file's URL from the directory hierarchy
|
|
493
|
+
if (snapshot.rootDirectoryUrl) {
|
|
494
|
+
try {
|
|
495
|
+
const fileEntry = await findFileInDirectoryHierarchy(
|
|
496
|
+
this.repo,
|
|
497
|
+
snapshot.rootDirectoryUrl,
|
|
498
|
+
change.path
|
|
499
|
+
);
|
|
635
500
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
}
|
|
645
|
-
} catch (error) {
|
|
646
|
-
console.warn(
|
|
647
|
-
`Failed to update snapshot for remote file ${change.path}: ${error}`
|
|
648
|
-
);
|
|
501
|
+
if (fileEntry) {
|
|
502
|
+
this.snapshotManager.updateFileEntry(snapshot, change.path, {
|
|
503
|
+
path: localPath,
|
|
504
|
+
url: fileEntry.url,
|
|
505
|
+
head: change.remoteHead,
|
|
506
|
+
extension: getFileExtension(change.path),
|
|
507
|
+
mimeType: getEnhancedMimeType(change.path),
|
|
508
|
+
});
|
|
649
509
|
}
|
|
510
|
+
} catch (error) {
|
|
511
|
+
// Failed to update snapshot - file may have been deleted
|
|
512
|
+
out.taskLine(
|
|
513
|
+
`Warning: Failed to update snapshot for remote file ${change.path}`,
|
|
514
|
+
true
|
|
515
|
+
);
|
|
650
516
|
}
|
|
651
517
|
}
|
|
652
518
|
}
|
|
@@ -657,103 +523,86 @@ export class SyncEngine {
|
|
|
657
523
|
*/
|
|
658
524
|
private async applyMoveToRemote(
|
|
659
525
|
move: MoveCandidate,
|
|
660
|
-
snapshot: SyncSnapshot
|
|
661
|
-
dryRun: boolean
|
|
526
|
+
snapshot: SyncSnapshot
|
|
662
527
|
): Promise<void> {
|
|
663
528
|
const fromEntry = snapshot.files.get(move.fromPath);
|
|
664
529
|
if (!fromEntry) return;
|
|
665
530
|
|
|
666
531
|
// Parse paths
|
|
667
|
-
const fromParts = move.fromPath.split("/");
|
|
668
|
-
const fromFileName = fromParts.pop() || "";
|
|
669
|
-
const fromDirPath = fromParts.join("/");
|
|
670
|
-
|
|
671
532
|
const toParts = move.toPath.split("/");
|
|
672
533
|
const toFileName = toParts.pop() || "";
|
|
673
534
|
const toDirPath = toParts.join("/");
|
|
674
535
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
}
|
|
536
|
+
// 1) Remove file entry from old directory document
|
|
537
|
+
if (move.fromPath !== move.toPath) {
|
|
538
|
+
await this.removeFileFromDirectory(snapshot, move.fromPath);
|
|
539
|
+
}
|
|
680
540
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
toDirPath,
|
|
685
|
-
dryRun
|
|
686
|
-
);
|
|
687
|
-
await this.addFileToDirectory(
|
|
688
|
-
snapshot,
|
|
689
|
-
move.toPath,
|
|
690
|
-
fromEntry.url,
|
|
691
|
-
dryRun
|
|
692
|
-
);
|
|
541
|
+
// 2) Ensure destination directory document exists and add file entry there
|
|
542
|
+
await this.ensureDirectoryDocument(snapshot, toDirPath);
|
|
543
|
+
await this.addFileToDirectory(snapshot, move.toPath, fromEntry.url);
|
|
693
544
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
doc.content = move.newContent;
|
|
711
|
-
}
|
|
545
|
+
// 3) Update the FileDocument name and content to match new location/state
|
|
546
|
+
try {
|
|
547
|
+
const handle = await this.repo.find<FileDocument>(fromEntry.url);
|
|
548
|
+
const heads = fromEntry.head;
|
|
549
|
+
|
|
550
|
+
// Update both name and content (if content changed during move)
|
|
551
|
+
if (heads && heads.length > 0) {
|
|
552
|
+
handle.changeAt(heads, (doc: FileDocument) => {
|
|
553
|
+
doc.name = toFileName;
|
|
554
|
+
|
|
555
|
+
// If new content is provided, update it (handles move + modification case)
|
|
556
|
+
if (move.newContent !== undefined) {
|
|
557
|
+
if (typeof move.newContent === "string") {
|
|
558
|
+
doc.content = new A.ImmutableString(move.newContent);
|
|
559
|
+
} else {
|
|
560
|
+
doc.content = move.newContent;
|
|
712
561
|
}
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
}
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
} else {
|
|
565
|
+
handle.change((doc: FileDocument) => {
|
|
566
|
+
doc.name = toFileName;
|
|
567
|
+
|
|
568
|
+
// If new content is provided, update it (handles move + modification case)
|
|
569
|
+
if (move.newContent !== undefined) {
|
|
570
|
+
if (typeof move.newContent === "string") {
|
|
571
|
+
doc.content = new A.ImmutableString(move.newContent);
|
|
572
|
+
} else {
|
|
573
|
+
doc.content = move.newContent;
|
|
726
574
|
}
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
// Track file handle for network sync
|
|
730
|
-
this.handlesToWaitOn.push(handle);
|
|
731
|
-
} catch (e) {
|
|
732
|
-
console.warn(
|
|
733
|
-
`Failed to update file name for move ${move.fromPath} -> ${move.toPath}: ${e}`
|
|
734
|
-
);
|
|
575
|
+
}
|
|
576
|
+
});
|
|
735
577
|
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
578
|
+
// Track file handle for network sync
|
|
579
|
+
this.handlesToWaitOn.push(handle);
|
|
580
|
+
} catch (e) {
|
|
581
|
+
// Failed to update file name - file may have been deleted
|
|
582
|
+
out.taskLine(
|
|
583
|
+
`Warning: Failed to rename ${move.fromPath} to ${move.toPath}`,
|
|
584
|
+
true
|
|
585
|
+
);
|
|
744
586
|
}
|
|
587
|
+
|
|
588
|
+
// 4) Update snapshot entries
|
|
589
|
+
this.snapshotManager.removeFileEntry(snapshot, move.fromPath);
|
|
590
|
+
this.snapshotManager.updateFileEntry(snapshot, move.toPath, {
|
|
591
|
+
...fromEntry,
|
|
592
|
+
path: joinAndNormalizePath(this.rootPath, move.toPath),
|
|
593
|
+
head: fromEntry.head, // will be updated later when heads advance
|
|
594
|
+
});
|
|
745
595
|
}
|
|
746
596
|
|
|
747
597
|
/**
|
|
748
598
|
* Create new remote file document
|
|
749
599
|
*/
|
|
750
600
|
private async createRemoteFile(
|
|
751
|
-
change: DetectedChange
|
|
752
|
-
dryRun: boolean
|
|
601
|
+
change: DetectedChange
|
|
753
602
|
): Promise<DocHandle<FileDocument> | null> {
|
|
754
603
|
// CRITICAL: Check for null explicitly, not falsy values
|
|
755
604
|
// Empty strings "" and empty Uint8Array are valid file content!
|
|
756
|
-
if (
|
|
605
|
+
if (change.localContent === null) return null;
|
|
757
606
|
|
|
758
607
|
const isText = this.isTextContent(change.localContent);
|
|
759
608
|
|
|
@@ -763,7 +612,11 @@ export class SyncEngine {
|
|
|
763
612
|
name: change.path.split("/").pop() || "",
|
|
764
613
|
extension: getFileExtension(change.path),
|
|
765
614
|
mimeType: getEnhancedMimeType(change.path),
|
|
766
|
-
content: isText
|
|
615
|
+
content: isText
|
|
616
|
+
? new A.ImmutableString("")
|
|
617
|
+
: typeof change.localContent === "string"
|
|
618
|
+
? new A.ImmutableString(change.localContent)
|
|
619
|
+
: change.localContent, // Empty ImmutableString for text, wrap strings for safety, actual content for binary
|
|
767
620
|
metadata: {
|
|
768
621
|
permissions: 0o644,
|
|
769
622
|
},
|
|
@@ -771,10 +624,10 @@ export class SyncEngine {
|
|
|
771
624
|
|
|
772
625
|
const handle = this.repo.create(fileDoc);
|
|
773
626
|
|
|
774
|
-
// For text files, use
|
|
627
|
+
// For text files, use ImmutableString for better performance
|
|
775
628
|
if (isText && typeof change.localContent === "string") {
|
|
776
629
|
handle.change((doc: FileDocument) => {
|
|
777
|
-
|
|
630
|
+
doc.content = new A.ImmutableString(change.localContent as string);
|
|
778
631
|
});
|
|
779
632
|
}
|
|
780
633
|
|
|
@@ -791,12 +644,9 @@ export class SyncEngine {
|
|
|
791
644
|
private async updateRemoteFile(
|
|
792
645
|
url: AutomergeUrl,
|
|
793
646
|
content: string | Uint8Array,
|
|
794
|
-
dryRun: boolean,
|
|
795
647
|
snapshot: SyncSnapshot,
|
|
796
648
|
filePath: string
|
|
797
649
|
): Promise<void> {
|
|
798
|
-
if (dryRun) return;
|
|
799
|
-
|
|
800
650
|
const handle = await this.repo.find<FileDocument>(url);
|
|
801
651
|
|
|
802
652
|
// Check if content actually changed before tracking for sync
|
|
@@ -818,9 +668,6 @@ export class SyncEngine {
|
|
|
818
668
|
if (!contentChanged) {
|
|
819
669
|
// Content is identical, but we've updated the snapshot heads above
|
|
820
670
|
// This prevents fresh change detection from seeing stale heads
|
|
821
|
-
console.log(
|
|
822
|
-
`🔍 Content is identical, but we've updated the snapshot heads above`
|
|
823
|
-
);
|
|
824
671
|
return;
|
|
825
672
|
}
|
|
826
673
|
|
|
@@ -831,16 +678,15 @@ export class SyncEngine {
|
|
|
831
678
|
}
|
|
832
679
|
|
|
833
680
|
handle.changeAt(heads, (doc: FileDocument) => {
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
updateText(doc, ["content"], content);
|
|
681
|
+
if (typeof content === "string") {
|
|
682
|
+
doc.content = new A.ImmutableString(content);
|
|
837
683
|
} else {
|
|
838
684
|
doc.content = content;
|
|
839
685
|
}
|
|
840
686
|
});
|
|
841
687
|
|
|
842
688
|
// Update snapshot with new heads after content change
|
|
843
|
-
if (
|
|
689
|
+
if (snapshotEntry) {
|
|
844
690
|
snapshot.files.set(filePath, {
|
|
845
691
|
...snapshotEntry,
|
|
846
692
|
head: handle.heads(),
|
|
@@ -856,12 +702,9 @@ export class SyncEngine {
|
|
|
856
702
|
*/
|
|
857
703
|
private async deleteRemoteFile(
|
|
858
704
|
url: AutomergeUrl,
|
|
859
|
-
dryRun: boolean,
|
|
860
705
|
snapshot?: SyncSnapshot,
|
|
861
706
|
filePath?: string
|
|
862
707
|
): Promise<void> {
|
|
863
|
-
if (dryRun) return;
|
|
864
|
-
|
|
865
708
|
// In Automerge, we don't actually delete documents
|
|
866
709
|
// They become orphaned and will be garbage collected
|
|
867
710
|
// For now, we just mark them as deleted by clearing content
|
|
@@ -873,11 +716,11 @@ export class SyncEngine {
|
|
|
873
716
|
}
|
|
874
717
|
if (heads) {
|
|
875
718
|
handle.changeAt(heads, (doc: FileDocument) => {
|
|
876
|
-
doc.content = "";
|
|
719
|
+
doc.content = new A.ImmutableString("");
|
|
877
720
|
});
|
|
878
721
|
} else {
|
|
879
722
|
handle.change((doc: FileDocument) => {
|
|
880
|
-
doc.content = "";
|
|
723
|
+
doc.content = new A.ImmutableString("");
|
|
881
724
|
});
|
|
882
725
|
}
|
|
883
726
|
}
|
|
@@ -888,10 +731,9 @@ export class SyncEngine {
|
|
|
888
731
|
private async addFileToDirectory(
|
|
889
732
|
snapshot: SyncSnapshot,
|
|
890
733
|
filePath: string,
|
|
891
|
-
fileUrl: AutomergeUrl
|
|
892
|
-
dryRun: boolean
|
|
734
|
+
fileUrl: AutomergeUrl
|
|
893
735
|
): Promise<void> {
|
|
894
|
-
if (
|
|
736
|
+
if (!snapshot.rootDirectoryUrl) return;
|
|
895
737
|
|
|
896
738
|
const pathParts = filePath.split("/");
|
|
897
739
|
const fileName = pathParts.pop() || "";
|
|
@@ -900,12 +742,7 @@ export class SyncEngine {
|
|
|
900
742
|
// Get or create the parent directory document
|
|
901
743
|
const parentDirUrl = await this.ensureDirectoryDocument(
|
|
902
744
|
snapshot,
|
|
903
|
-
directoryPath
|
|
904
|
-
dryRun
|
|
905
|
-
);
|
|
906
|
-
|
|
907
|
-
console.log(
|
|
908
|
-
`🔗 Adding ${fileName} (${fileUrl}) to directory ${parentDirUrl} (path: ${directoryPath})`
|
|
745
|
+
directoryPath
|
|
909
746
|
);
|
|
910
747
|
|
|
911
748
|
const dirHandle = await this.repo.find<DirectoryDocument>(parentDirUrl);
|
|
@@ -959,8 +796,7 @@ export class SyncEngine {
|
|
|
959
796
|
*/
|
|
960
797
|
private async ensureDirectoryDocument(
|
|
961
798
|
snapshot: SyncSnapshot,
|
|
962
|
-
directoryPath: string
|
|
963
|
-
dryRun: boolean
|
|
799
|
+
directoryPath: string
|
|
964
800
|
): Promise<AutomergeUrl> {
|
|
965
801
|
// Root directory case
|
|
966
802
|
if (!directoryPath || directoryPath === "") {
|
|
@@ -981,8 +817,7 @@ export class SyncEngine {
|
|
|
981
817
|
// Ensure parent directory exists first (recursive)
|
|
982
818
|
const parentDirUrl = await this.ensureDirectoryDocument(
|
|
983
819
|
snapshot,
|
|
984
|
-
parentPath
|
|
985
|
-
dryRun
|
|
820
|
+
parentPath
|
|
986
821
|
);
|
|
987
822
|
|
|
988
823
|
// DISCOVERY: Check if directory already exists in parent on server
|
|
@@ -1008,32 +843,21 @@ export class SyncEngine {
|
|
|
1008
843
|
const childHeads = childDirHandle.heads();
|
|
1009
844
|
|
|
1010
845
|
// Update snapshot with discovered directory using validated heads
|
|
1011
|
-
|
|
1012
|
-
this.
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
url: existingDirEntry.url,
|
|
1018
|
-
head: childHeads,
|
|
1019
|
-
entries: [],
|
|
1020
|
-
}
|
|
1021
|
-
);
|
|
1022
|
-
}
|
|
846
|
+
this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
|
|
847
|
+
path: joinAndNormalizePath(this.rootPath, directoryPath),
|
|
848
|
+
url: existingDirEntry.url,
|
|
849
|
+
head: childHeads,
|
|
850
|
+
entries: [],
|
|
851
|
+
});
|
|
1023
852
|
|
|
1024
853
|
return existingDirEntry.url;
|
|
1025
854
|
} catch (resolveErr) {
|
|
1026
|
-
|
|
1027
|
-
`Failed to resolve child directory ${currentDirName} at ${directoryPath}: ${resolveErr}`
|
|
1028
|
-
);
|
|
1029
|
-
// Fall through to create a fresh directory document
|
|
855
|
+
// Failed to resolve directory - fall through to create a fresh directory document
|
|
1030
856
|
}
|
|
1031
857
|
}
|
|
1032
858
|
}
|
|
1033
859
|
} catch (error) {
|
|
1034
|
-
|
|
1035
|
-
`Failed to check for existing directory ${currentDirName}: ${error}`
|
|
1036
|
-
);
|
|
860
|
+
// Failed to check for existing directory - will create new one
|
|
1037
861
|
}
|
|
1038
862
|
|
|
1039
863
|
// CREATE: Directory doesn't exist, create new one
|
|
@@ -1065,28 +889,26 @@ export class SyncEngine {
|
|
|
1065
889
|
});
|
|
1066
890
|
|
|
1067
891
|
// Track directory handles for sync
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
this.handlesToWaitOn.push(parentHandle);
|
|
1072
|
-
|
|
1073
|
-
// CRITICAL FIX: Update parent directory heads in snapshot immediately
|
|
1074
|
-
// This prevents stale head issues when parent directory is modified
|
|
1075
|
-
const parentSnapshotEntry = snapshot.directories.get(parentPath);
|
|
1076
|
-
if (parentSnapshotEntry) {
|
|
1077
|
-
parentSnapshotEntry.head = parentHandle.heads();
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
892
|
+
this.handlesToWaitOn.push(dirHandle);
|
|
893
|
+
if (didChange) {
|
|
894
|
+
this.handlesToWaitOn.push(parentHandle);
|
|
1080
895
|
|
|
1081
|
-
// Update
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
head
|
|
1086
|
-
|
|
1087
|
-
});
|
|
896
|
+
// CRITICAL FIX: Update parent directory heads in snapshot immediately
|
|
897
|
+
// This prevents stale head issues when parent directory is modified
|
|
898
|
+
const parentSnapshotEntry = snapshot.directories.get(parentPath);
|
|
899
|
+
if (parentSnapshotEntry) {
|
|
900
|
+
parentSnapshotEntry.head = parentHandle.heads();
|
|
901
|
+
}
|
|
1088
902
|
}
|
|
1089
903
|
|
|
904
|
+
// Update snapshot with new directory
|
|
905
|
+
this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
|
|
906
|
+
path: joinAndNormalizePath(this.rootPath, directoryPath),
|
|
907
|
+
url: dirHandle.url,
|
|
908
|
+
head: dirHandle.heads(),
|
|
909
|
+
entries: [],
|
|
910
|
+
});
|
|
911
|
+
|
|
1090
912
|
return dirHandle.url;
|
|
1091
913
|
}
|
|
1092
914
|
|
|
@@ -1095,10 +917,9 @@ export class SyncEngine {
|
|
|
1095
917
|
*/
|
|
1096
918
|
private async removeFileFromDirectory(
|
|
1097
919
|
snapshot: SyncSnapshot,
|
|
1098
|
-
filePath: string
|
|
1099
|
-
dryRun: boolean
|
|
920
|
+
filePath: string
|
|
1100
921
|
): Promise<void> {
|
|
1101
|
-
if (
|
|
922
|
+
if (!snapshot.rootDirectoryUrl) return;
|
|
1102
923
|
|
|
1103
924
|
const pathParts = filePath.split("/");
|
|
1104
925
|
const fileName = pathParts.pop() || "";
|
|
@@ -1111,9 +932,7 @@ export class SyncEngine {
|
|
|
1111
932
|
} else {
|
|
1112
933
|
const existingDir = snapshot.directories.get(directoryPath);
|
|
1113
934
|
if (!existingDir) {
|
|
1114
|
-
|
|
1115
|
-
`Directory ${directoryPath} not found in snapshot for file removal`
|
|
1116
|
-
);
|
|
935
|
+
// Directory not found - file may already be removed
|
|
1117
936
|
return;
|
|
1118
937
|
}
|
|
1119
938
|
parentDirUrl = existingDir.url;
|
|
@@ -1136,9 +955,9 @@ export class SyncEngine {
|
|
|
1136
955
|
if (indexToRemove !== -1) {
|
|
1137
956
|
doc.docs.splice(indexToRemove, 1);
|
|
1138
957
|
didChange = true;
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
directoryPath || "root"
|
|
958
|
+
out.taskLine(
|
|
959
|
+
`Removed ${fileName} from ${
|
|
960
|
+
formatRelativePath(directoryPath) || "root"
|
|
1142
961
|
}`
|
|
1143
962
|
);
|
|
1144
963
|
}
|
|
@@ -1151,9 +970,9 @@ export class SyncEngine {
|
|
|
1151
970
|
if (indexToRemove !== -1) {
|
|
1152
971
|
doc.docs.splice(indexToRemove, 1);
|
|
1153
972
|
didChange = true;
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
directoryPath || "root"
|
|
973
|
+
out.taskLine(
|
|
974
|
+
`Removed ${fileName} from ${
|
|
975
|
+
formatRelativePath(directoryPath) || "root"
|
|
1157
976
|
}`
|
|
1158
977
|
);
|
|
1159
978
|
}
|
|
@@ -1166,68 +985,11 @@ export class SyncEngine {
|
|
|
1166
985
|
snapshotEntry.head = dirHandle.heads();
|
|
1167
986
|
}
|
|
1168
987
|
} catch (error) {
|
|
1169
|
-
|
|
1170
|
-
`Failed to remove ${fileName} from directory ${
|
|
1171
|
-
directoryPath || "root"
|
|
1172
|
-
}: ${error}`
|
|
1173
|
-
);
|
|
988
|
+
// Failed to remove from directory - re-throw for caller to handle
|
|
1174
989
|
throw error;
|
|
1175
990
|
}
|
|
1176
991
|
}
|
|
1177
992
|
|
|
1178
|
-
/**
|
|
1179
|
-
* Find a file in the directory hierarchy by path
|
|
1180
|
-
*/
|
|
1181
|
-
private async findFileInDirectoryHierarchy(
|
|
1182
|
-
directoryUrl: AutomergeUrl,
|
|
1183
|
-
filePath: string
|
|
1184
|
-
): Promise<{ name: string; type: string; url: AutomergeUrl } | null> {
|
|
1185
|
-
try {
|
|
1186
|
-
const pathParts = filePath.split("/");
|
|
1187
|
-
let currentDirUrl = directoryUrl;
|
|
1188
|
-
|
|
1189
|
-
// Navigate through directories to find the parent directory
|
|
1190
|
-
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
1191
|
-
const dirName = pathParts[i];
|
|
1192
|
-
const dirHandle = await this.repo.find<DirectoryDocument>(
|
|
1193
|
-
currentDirUrl
|
|
1194
|
-
);
|
|
1195
|
-
const dirDoc = await dirHandle.doc();
|
|
1196
|
-
|
|
1197
|
-
if (!dirDoc) return null;
|
|
1198
|
-
|
|
1199
|
-
const subDirEntry = dirDoc.docs.find(
|
|
1200
|
-
(entry: { name: string; type: string; url: AutomergeUrl }) =>
|
|
1201
|
-
entry.name === dirName && entry.type === "folder"
|
|
1202
|
-
);
|
|
1203
|
-
|
|
1204
|
-
if (!subDirEntry) return null;
|
|
1205
|
-
currentDirUrl = subDirEntry.url;
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
// Now look for the file in the final directory
|
|
1209
|
-
const fileName = pathParts[pathParts.length - 1];
|
|
1210
|
-
const finalDirHandle = await this.repo.find<DirectoryDocument>(
|
|
1211
|
-
currentDirUrl
|
|
1212
|
-
);
|
|
1213
|
-
const finalDirDoc = await finalDirHandle.doc();
|
|
1214
|
-
|
|
1215
|
-
if (!finalDirDoc) return null;
|
|
1216
|
-
|
|
1217
|
-
const fileEntry = finalDirDoc.docs.find(
|
|
1218
|
-
(entry: { name: string; type: string; url: AutomergeUrl }) =>
|
|
1219
|
-
entry.name === fileName && entry.type === "file"
|
|
1220
|
-
);
|
|
1221
|
-
|
|
1222
|
-
return fileEntry || null;
|
|
1223
|
-
} catch (error) {
|
|
1224
|
-
console.warn(
|
|
1225
|
-
`Failed to find file ${filePath} in directory hierarchy: ${error}`
|
|
1226
|
-
);
|
|
1227
|
-
return null;
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
993
|
/**
|
|
1232
994
|
* Sort changes by dependency order
|
|
1233
995
|
*/
|
|
@@ -1288,11 +1050,7 @@ export class SyncEngine {
|
|
|
1288
1050
|
}
|
|
1289
1051
|
|
|
1290
1052
|
const changes = await this.changeDetector.detectChanges(snapshot);
|
|
1291
|
-
const { moves } = await this.moveDetector.detectMoves(
|
|
1292
|
-
changes,
|
|
1293
|
-
snapshot,
|
|
1294
|
-
this.rootPath
|
|
1295
|
-
);
|
|
1053
|
+
const { moves } = await this.moveDetector.detectMoves(changes, snapshot);
|
|
1296
1054
|
|
|
1297
1055
|
const summary = this.generateChangeSummary(changes, moves);
|
|
1298
1056
|
|
|
@@ -1354,11 +1112,8 @@ export class SyncEngine {
|
|
|
1354
1112
|
/**
|
|
1355
1113
|
* Update the lastSyncAt timestamp on the root directory document
|
|
1356
1114
|
*/
|
|
1357
|
-
private async touchRootDirectory(
|
|
1358
|
-
snapshot
|
|
1359
|
-
dryRun: boolean
|
|
1360
|
-
): Promise<void> {
|
|
1361
|
-
if (dryRun || !snapshot.rootDirectoryUrl) {
|
|
1115
|
+
private async touchRootDirectory(snapshot: SyncSnapshot): Promise<void> {
|
|
1116
|
+
if (!snapshot.rootDirectoryUrl) {
|
|
1362
1117
|
return;
|
|
1363
1118
|
}
|
|
1364
1119
|
|
|
@@ -1390,14 +1145,8 @@ export class SyncEngine {
|
|
|
1390
1145
|
if (snapshotEntry) {
|
|
1391
1146
|
snapshotEntry.head = rootHandle.heads();
|
|
1392
1147
|
}
|
|
1393
|
-
|
|
1394
|
-
console.log(
|
|
1395
|
-
`🕒 Updated root directory lastSyncAt to ${new Date(
|
|
1396
|
-
timestamp
|
|
1397
|
-
).toISOString()}`
|
|
1398
|
-
);
|
|
1399
1148
|
} catch (error) {
|
|
1400
|
-
|
|
1149
|
+
// Failed to update root directory timestamp
|
|
1401
1150
|
}
|
|
1402
1151
|
}
|
|
1403
1152
|
}
|