pushwork 1.0.0
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 +460 -0
- package/dist/browser/browser-sync-engine.d.ts +64 -0
- package/dist/browser/browser-sync-engine.d.ts.map +1 -0
- package/dist/browser/browser-sync-engine.js +303 -0
- package/dist/browser/browser-sync-engine.js.map +1 -0
- package/dist/browser/filesystem-adapter.d.ts +84 -0
- package/dist/browser/filesystem-adapter.d.ts.map +1 -0
- package/dist/browser/filesystem-adapter.js +413 -0
- package/dist/browser/filesystem-adapter.js.map +1 -0
- package/dist/browser/index.d.ts +36 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +90 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/types.d.ts +70 -0
- package/dist/browser/types.d.ts.map +1 -0
- package/dist/browser/types.js +6 -0
- package/dist/browser/types.js.map +1 -0
- package/dist/cli/commands.d.ts +71 -0
- package/dist/cli/commands.d.ts.map +1 -0
- package/dist/cli/commands.js +794 -0
- package/dist/cli/commands.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +19 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +199 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/index.d.ts +71 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +314 -0
- package/dist/config/index.js.map +1 -0
- package/dist/core/change-detection.d.ts +78 -0
- package/dist/core/change-detection.d.ts.map +1 -0
- package/dist/core/change-detection.js +370 -0
- package/dist/core/change-detection.js.map +1 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +22 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/isomorphic-snapshot.d.ts +58 -0
- package/dist/core/isomorphic-snapshot.d.ts.map +1 -0
- package/dist/core/isomorphic-snapshot.js +204 -0
- package/dist/core/isomorphic-snapshot.js.map +1 -0
- package/dist/core/move-detection.d.ts +72 -0
- package/dist/core/move-detection.d.ts.map +1 -0
- package/dist/core/move-detection.js +200 -0
- package/dist/core/move-detection.js.map +1 -0
- package/dist/core/snapshot.d.ts +109 -0
- package/dist/core/snapshot.d.ts.map +1 -0
- package/dist/core/snapshot.js +263 -0
- package/dist/core/snapshot.js.map +1 -0
- package/dist/core/sync-engine.d.ts +110 -0
- package/dist/core/sync-engine.d.ts.map +1 -0
- package/dist/core/sync-engine.js +817 -0
- package/dist/core/sync-engine.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/platform/browser-filesystem.d.ts +26 -0
- package/dist/platform/browser-filesystem.d.ts.map +1 -0
- package/dist/platform/browser-filesystem.js +91 -0
- package/dist/platform/browser-filesystem.js.map +1 -0
- package/dist/platform/filesystem.d.ts +29 -0
- package/dist/platform/filesystem.d.ts.map +1 -0
- package/dist/platform/filesystem.js +65 -0
- package/dist/platform/filesystem.js.map +1 -0
- package/dist/platform/node-filesystem.d.ts +21 -0
- package/dist/platform/node-filesystem.d.ts.map +1 -0
- package/dist/platform/node-filesystem.js +93 -0
- package/dist/platform/node-filesystem.js.map +1 -0
- package/dist/types/config.d.ts +119 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/config.js +3 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/documents.d.ts +70 -0
- package/dist/types/documents.d.ts.map +1 -0
- package/dist/types/documents.js +23 -0
- package/dist/types/documents.js.map +1 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +23 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/snapshot.d.ts +81 -0
- package/dist/types/snapshot.d.ts.map +1 -0
- package/dist/types/snapshot.js +17 -0
- package/dist/types/snapshot.js.map +1 -0
- package/dist/utils/content-similarity.d.ts +53 -0
- package/dist/utils/content-similarity.d.ts.map +1 -0
- package/dist/utils/content-similarity.js +155 -0
- package/dist/utils/content-similarity.js.map +1 -0
- package/dist/utils/content.d.ts +5 -0
- package/dist/utils/content.d.ts.map +1 -0
- package/dist/utils/content.js +30 -0
- package/dist/utils/content.js.map +1 -0
- package/dist/utils/fs-browser.d.ts +57 -0
- package/dist/utils/fs-browser.d.ts.map +1 -0
- package/dist/utils/fs-browser.js +311 -0
- package/dist/utils/fs-browser.js.map +1 -0
- package/dist/utils/fs-node.d.ts +53 -0
- package/dist/utils/fs-node.d.ts.map +1 -0
- package/dist/utils/fs-node.js +220 -0
- package/dist/utils/fs-node.js.map +1 -0
- package/dist/utils/fs.d.ts +62 -0
- package/dist/utils/fs.d.ts.map +1 -0
- package/dist/utils/fs.js +293 -0
- package/dist/utils/fs.js.map +1 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +23 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/isomorphic.d.ts +29 -0
- package/dist/utils/isomorphic.d.ts.map +1 -0
- package/dist/utils/isomorphic.js +139 -0
- package/dist/utils/isomorphic.js.map +1 -0
- package/dist/utils/mime-types.d.ts +13 -0
- package/dist/utils/mime-types.d.ts.map +1 -0
- package/dist/utils/mime-types.js +240 -0
- package/dist/utils/mime-types.js.map +1 -0
- package/dist/utils/network-sync.d.ts +12 -0
- package/dist/utils/network-sync.d.ts.map +1 -0
- package/dist/utils/network-sync.js +149 -0
- package/dist/utils/network-sync.js.map +1 -0
- package/dist/utils/pure.d.ts +25 -0
- package/dist/utils/pure.d.ts.map +1 -0
- package/dist/utils/pure.js +112 -0
- package/dist/utils/pure.js.map +1 -0
- package/dist/utils/repo-factory.d.ts +11 -0
- package/dist/utils/repo-factory.d.ts.map +1 -0
- package/dist/utils/repo-factory.js +77 -0
- package/dist/utils/repo-factory.js.map +1 -0
- package/package.json +83 -0
- package/src/cli/commands.ts +1053 -0
- package/src/cli/index.ts +2 -0
- package/src/cli.ts +287 -0
- package/src/config/index.ts +334 -0
- package/src/core/change-detection.ts +484 -0
- package/src/core/index.ts +5 -0
- package/src/core/move-detection.ts +269 -0
- package/src/core/snapshot.ts +285 -0
- package/src/core/sync-engine.ts +1167 -0
- package/src/index.ts +14 -0
- package/src/types/config.ts +130 -0
- package/src/types/documents.ts +72 -0
- package/src/types/index.ts +8 -0
- package/src/types/snapshot.ts +88 -0
- package/src/utils/content-similarity.ts +194 -0
- package/src/utils/content.ts +28 -0
- package/src/utils/fs.ts +289 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/mime-types.ts +236 -0
- package/src/utils/network-sync.ts +153 -0
- package/src/utils/repo-factory.ts +58 -0
- package/test/README-TESTING-GAPS.md +174 -0
- package/test/integration/README.md +328 -0
- package/test/integration/clone-test.sh +310 -0
- package/test/integration/conflict-resolution-test.sh +309 -0
- package/test/integration/deletion-behavior-test.sh +487 -0
- package/test/integration/deletion-sync-test-simple.sh +193 -0
- package/test/integration/deletion-sync-test.sh +297 -0
- package/test/integration/exclude-patterns.test.ts +152 -0
- package/test/integration/full-integration-test.sh +363 -0
- package/test/integration/sync-deletion.test.ts +339 -0
- package/test/integration/sync-flow.test.ts +309 -0
- package/test/run-tests.sh +225 -0
- package/test/unit/content-similarity.test.ts +236 -0
- package/test/unit/deletion-behavior.test.ts +260 -0
- package/test/unit/enhanced-mime-detection.test.ts +266 -0
- package/test/unit/snapshot.test.ts +431 -0
- package/test/unit/sync-timing.test.ts +178 -0
- package/test/unit/utils.test.ts +368 -0
- package/tools/browser-sync/README.md +116 -0
- package/tools/browser-sync/package.json +44 -0
- package/tools/browser-sync/patchwork.json +1 -0
- package/tools/browser-sync/pnpm-lock.yaml +4202 -0
- package/tools/browser-sync/src/components/BrowserSyncTool.tsx +599 -0
- package/tools/browser-sync/src/index.ts +20 -0
- package/tools/browser-sync/src/polyfills.ts +31 -0
- package/tools/browser-sync/src/styles.css +290 -0
- package/tools/browser-sync/src/types.ts +27 -0
- package/tools/browser-sync/vite.config.ts +25 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AutomergeUrl,
|
|
3
|
+
Repo,
|
|
4
|
+
updateText,
|
|
5
|
+
DocHandle,
|
|
6
|
+
UrlHeads,
|
|
7
|
+
} from "@automerge/automerge-repo";
|
|
8
|
+
import * as A from "@automerge/automerge";
|
|
9
|
+
import {
|
|
10
|
+
SyncSnapshot,
|
|
11
|
+
SyncResult,
|
|
12
|
+
SyncError,
|
|
13
|
+
SyncOperation,
|
|
14
|
+
PendingSyncOperation,
|
|
15
|
+
FileDocument,
|
|
16
|
+
DirectoryDocument,
|
|
17
|
+
FileType,
|
|
18
|
+
ChangeType,
|
|
19
|
+
MoveCandidate,
|
|
20
|
+
} from "../types";
|
|
21
|
+
import {
|
|
22
|
+
readFileContent,
|
|
23
|
+
writeFileContent,
|
|
24
|
+
removePath,
|
|
25
|
+
movePath,
|
|
26
|
+
ensureDirectoryExists,
|
|
27
|
+
getFileExtension,
|
|
28
|
+
normalizePath,
|
|
29
|
+
getRelativePath,
|
|
30
|
+
getEnhancedMimeType,
|
|
31
|
+
isEnhancedTextFile,
|
|
32
|
+
} from "../utils";
|
|
33
|
+
import { isContentEqual } from "../utils/content";
|
|
34
|
+
import { waitForSync, getSyncServerStorageId } from "../utils/network-sync";
|
|
35
|
+
import { SnapshotManager } from "./snapshot";
|
|
36
|
+
import { ChangeDetector, DetectedChange } from "./change-detection";
|
|
37
|
+
import { MoveDetector } from "./move-detection";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Bidirectional sync engine implementing two-phase sync
|
|
41
|
+
*/
|
|
42
|
+
export class SyncEngine {
|
|
43
|
+
private snapshotManager: SnapshotManager;
|
|
44
|
+
private changeDetector: ChangeDetector;
|
|
45
|
+
private moveDetector: MoveDetector;
|
|
46
|
+
private networkSyncEnabled: boolean = true;
|
|
47
|
+
private handlesToWaitOn: DocHandle<unknown>[] = [];
|
|
48
|
+
private syncServerStorageId?: string;
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
private repo: Repo,
|
|
52
|
+
private rootPath: string,
|
|
53
|
+
excludePatterns: string[] = [],
|
|
54
|
+
networkSyncEnabled: boolean = true,
|
|
55
|
+
syncServerStorageId?: string
|
|
56
|
+
) {
|
|
57
|
+
this.snapshotManager = new SnapshotManager(rootPath);
|
|
58
|
+
this.changeDetector = new ChangeDetector(repo, rootPath, excludePatterns);
|
|
59
|
+
this.moveDetector = new MoveDetector();
|
|
60
|
+
this.networkSyncEnabled = networkSyncEnabled;
|
|
61
|
+
this.syncServerStorageId = syncServerStorageId;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Determine if content should be treated as text for Automerge text operations
|
|
66
|
+
* Note: This method checks the runtime type. File type detection happens
|
|
67
|
+
* during reading with isEnhancedTextFile() which now has better dev file support.
|
|
68
|
+
*/
|
|
69
|
+
private isTextContent(content: string | Uint8Array): boolean {
|
|
70
|
+
// Simply check the actual type of the content
|
|
71
|
+
return typeof content === "string";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Set the root directory URL in the snapshot
|
|
76
|
+
*/
|
|
77
|
+
async setRootDirectoryUrl(url: AutomergeUrl): Promise<void> {
|
|
78
|
+
let snapshot = await this.snapshotManager.load();
|
|
79
|
+
if (!snapshot) {
|
|
80
|
+
snapshot = this.snapshotManager.createEmpty();
|
|
81
|
+
}
|
|
82
|
+
snapshot.rootDirectoryUrl = url;
|
|
83
|
+
await this.snapshotManager.save(snapshot);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Commit local changes only (no network sync)
|
|
88
|
+
*/
|
|
89
|
+
async commitLocal(dryRun = false): Promise<SyncResult> {
|
|
90
|
+
console.log(`🚀 Starting local commit process (dryRun: ${dryRun})`);
|
|
91
|
+
|
|
92
|
+
const result: SyncResult = {
|
|
93
|
+
success: false,
|
|
94
|
+
filesChanged: 0,
|
|
95
|
+
directoriesChanged: 0,
|
|
96
|
+
errors: [],
|
|
97
|
+
warnings: [],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
// Load current snapshot
|
|
102
|
+
console.log(`📸 Loading current snapshot...`);
|
|
103
|
+
let snapshot = await this.snapshotManager.load();
|
|
104
|
+
if (!snapshot) {
|
|
105
|
+
console.log(`📸 No snapshot found, creating empty one`);
|
|
106
|
+
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
|
+
}
|
|
119
|
+
|
|
120
|
+
// Detect all changes
|
|
121
|
+
console.log(`🔍 Detecting changes...`);
|
|
122
|
+
const changes = await this.changeDetector.detectChanges(snapshot);
|
|
123
|
+
console.log(`🔍 Found ${changes.length} changes`);
|
|
124
|
+
|
|
125
|
+
// Detect moves
|
|
126
|
+
console.log(`📦 Detecting moves...`);
|
|
127
|
+
const { moves, remainingChanges } = await this.moveDetector.detectMoves(
|
|
128
|
+
changes,
|
|
129
|
+
snapshot,
|
|
130
|
+
this.rootPath
|
|
131
|
+
);
|
|
132
|
+
console.log(
|
|
133
|
+
`📦 Found ${moves.length} moves, ${remainingChanges.length} remaining changes`
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Apply local changes only (no network sync)
|
|
137
|
+
console.log(`💾 Committing local changes...`);
|
|
138
|
+
const commitResult = await this.pushLocalChanges(
|
|
139
|
+
remainingChanges,
|
|
140
|
+
moves,
|
|
141
|
+
snapshot,
|
|
142
|
+
dryRun
|
|
143
|
+
);
|
|
144
|
+
console.log(
|
|
145
|
+
`💾 Commit complete: ${commitResult.filesChanged} files changed`
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
result.filesChanged += commitResult.filesChanged;
|
|
149
|
+
result.directoriesChanged += commitResult.directoriesChanged;
|
|
150
|
+
result.errors.push(...commitResult.errors);
|
|
151
|
+
result.warnings.push(...commitResult.warnings);
|
|
152
|
+
|
|
153
|
+
// Save updated snapshot if not dry run
|
|
154
|
+
if (!dryRun) {
|
|
155
|
+
await this.snapshotManager.save(snapshot);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
result.success = result.errors.length === 0;
|
|
159
|
+
console.log(`💾 Local commit ${result.success ? "completed" : "failed"}`);
|
|
160
|
+
|
|
161
|
+
return result;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error(`❌ Local commit failed: ${error}`);
|
|
164
|
+
result.errors.push({
|
|
165
|
+
path: this.rootPath,
|
|
166
|
+
operation: "commitLocal",
|
|
167
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
168
|
+
recoverable: true,
|
|
169
|
+
});
|
|
170
|
+
result.success = false;
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Run full bidirectional sync
|
|
177
|
+
*/
|
|
178
|
+
async sync(dryRun = false): Promise<SyncResult> {
|
|
179
|
+
const result: SyncResult = {
|
|
180
|
+
success: false,
|
|
181
|
+
filesChanged: 0,
|
|
182
|
+
directoriesChanged: 0,
|
|
183
|
+
errors: [],
|
|
184
|
+
warnings: [],
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Reset handles to wait on
|
|
188
|
+
this.handlesToWaitOn = [];
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
// Load current snapshot
|
|
192
|
+
let snapshot = await this.snapshotManager.load();
|
|
193
|
+
if (!snapshot) {
|
|
194
|
+
snapshot = this.snapshotManager.createEmpty();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Backup snapshot before starting
|
|
198
|
+
if (!dryRun) {
|
|
199
|
+
await this.snapshotManager.backup();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Detect all changes
|
|
203
|
+
const changes = await this.changeDetector.detectChanges(snapshot);
|
|
204
|
+
|
|
205
|
+
// Detect moves
|
|
206
|
+
const { moves, remainingChanges } = await this.moveDetector.detectMoves(
|
|
207
|
+
changes,
|
|
208
|
+
snapshot,
|
|
209
|
+
this.rootPath
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
if (changes.length > 0) {
|
|
213
|
+
console.log(`🔄 Syncing ${changes.length} changes...`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Phase 1: Push local changes to remote
|
|
217
|
+
const phase1Result = await this.pushLocalChanges(
|
|
218
|
+
remainingChanges,
|
|
219
|
+
moves,
|
|
220
|
+
snapshot,
|
|
221
|
+
dryRun
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
result.filesChanged += phase1Result.filesChanged;
|
|
225
|
+
result.directoriesChanged += phase1Result.directoriesChanged;
|
|
226
|
+
result.errors.push(...phase1Result.errors);
|
|
227
|
+
result.warnings.push(...phase1Result.warnings);
|
|
228
|
+
|
|
229
|
+
// Always wait for network sync when enabled (not just when local changes exist)
|
|
230
|
+
// This is critical for clone scenarios where we need to pull remote changes
|
|
231
|
+
if (!dryRun && this.networkSyncEnabled) {
|
|
232
|
+
try {
|
|
233
|
+
// If we have a root directory URL, wait for it to sync
|
|
234
|
+
if (snapshot.rootDirectoryUrl) {
|
|
235
|
+
const rootHandle = await this.repo.find<DirectoryDocument>(
|
|
236
|
+
snapshot.rootDirectoryUrl
|
|
237
|
+
);
|
|
238
|
+
this.handlesToWaitOn.push(rootHandle);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (this.handlesToWaitOn.length > 0) {
|
|
242
|
+
await waitForSync(
|
|
243
|
+
this.handlesToWaitOn,
|
|
244
|
+
getSyncServerStorageId(this.syncServerStorageId)
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
} catch (error) {
|
|
248
|
+
console.error(`❌ Network sync failed: ${error}`);
|
|
249
|
+
result.warnings.push(`Network sync failed: ${error}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Re-detect remote changes after network sync to ensure fresh state
|
|
254
|
+
// This fixes race conditions where we detect changes before server propagation
|
|
255
|
+
const freshChanges = await this.changeDetector.detectChanges(snapshot);
|
|
256
|
+
const freshRemoteChanges = freshChanges.filter(
|
|
257
|
+
(c) =>
|
|
258
|
+
c.changeType === ChangeType.REMOTE_ONLY ||
|
|
259
|
+
c.changeType === ChangeType.BOTH_CHANGED
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
// Phase 2: Pull remote changes to local using fresh detection
|
|
263
|
+
const phase2Result = await this.pullRemoteChanges(
|
|
264
|
+
freshRemoteChanges,
|
|
265
|
+
snapshot,
|
|
266
|
+
dryRun
|
|
267
|
+
);
|
|
268
|
+
result.filesChanged += phase2Result.filesChanged;
|
|
269
|
+
result.directoriesChanged += phase2Result.directoriesChanged;
|
|
270
|
+
result.errors.push(...phase2Result.errors);
|
|
271
|
+
result.warnings.push(...phase2Result.warnings);
|
|
272
|
+
|
|
273
|
+
// Save updated snapshot if not dry run
|
|
274
|
+
if (!dryRun) {
|
|
275
|
+
await this.snapshotManager.save(snapshot);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
result.success = result.errors.length === 0;
|
|
279
|
+
return result;
|
|
280
|
+
} catch (error) {
|
|
281
|
+
result.errors.push({
|
|
282
|
+
path: "sync",
|
|
283
|
+
operation: "full-sync",
|
|
284
|
+
error: error as Error,
|
|
285
|
+
recoverable: false,
|
|
286
|
+
});
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Phase 1: Push local changes to Automerge documents
|
|
293
|
+
*/
|
|
294
|
+
private async pushLocalChanges(
|
|
295
|
+
changes: DetectedChange[],
|
|
296
|
+
moves: MoveCandidate[],
|
|
297
|
+
snapshot: SyncSnapshot,
|
|
298
|
+
dryRun: boolean
|
|
299
|
+
): Promise<SyncResult> {
|
|
300
|
+
const result: SyncResult = {
|
|
301
|
+
success: true,
|
|
302
|
+
filesChanged: 0,
|
|
303
|
+
directoriesChanged: 0,
|
|
304
|
+
errors: [],
|
|
305
|
+
warnings: [],
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// Process moves first
|
|
309
|
+
for (const move of moves) {
|
|
310
|
+
if (this.moveDetector.shouldAutoApply(move)) {
|
|
311
|
+
try {
|
|
312
|
+
await this.applyMoveToRemote(move, snapshot, dryRun);
|
|
313
|
+
result.filesChanged++;
|
|
314
|
+
} catch (error) {
|
|
315
|
+
result.errors.push({
|
|
316
|
+
path: move.fromPath,
|
|
317
|
+
operation: "move",
|
|
318
|
+
error: error as Error,
|
|
319
|
+
recoverable: true,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
} else if (this.moveDetector.shouldPromptUser(move)) {
|
|
323
|
+
// Instead of creating a persistent loop, perform delete+create semantics
|
|
324
|
+
// so the working tree converges even without auto-apply.
|
|
325
|
+
result.warnings.push(
|
|
326
|
+
`Potential move detected: ${this.moveDetector.formatMove(
|
|
327
|
+
move
|
|
328
|
+
)} (${Math.round(move.similarity * 100)}% similar)`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Process local changes
|
|
334
|
+
const localChanges = changes.filter(
|
|
335
|
+
(c) =>
|
|
336
|
+
c.changeType === ChangeType.LOCAL_ONLY ||
|
|
337
|
+
c.changeType === ChangeType.BOTH_CHANGED
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
for (const change of localChanges) {
|
|
341
|
+
try {
|
|
342
|
+
await this.applyLocalChangeToRemote(change, snapshot, dryRun);
|
|
343
|
+
result.filesChanged++;
|
|
344
|
+
} catch (error) {
|
|
345
|
+
result.errors.push({
|
|
346
|
+
path: change.path,
|
|
347
|
+
operation: "local-to-remote",
|
|
348
|
+
error: error as Error,
|
|
349
|
+
recoverable: true,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return result;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Phase 2: Pull remote changes to local filesystem
|
|
359
|
+
*/
|
|
360
|
+
private async pullRemoteChanges(
|
|
361
|
+
changes: DetectedChange[],
|
|
362
|
+
snapshot: SyncSnapshot,
|
|
363
|
+
dryRun: boolean
|
|
364
|
+
): Promise<SyncResult> {
|
|
365
|
+
const result: SyncResult = {
|
|
366
|
+
success: true,
|
|
367
|
+
filesChanged: 0,
|
|
368
|
+
directoriesChanged: 0,
|
|
369
|
+
errors: [],
|
|
370
|
+
warnings: [],
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// Process remote changes
|
|
374
|
+
const remoteChanges = changes.filter(
|
|
375
|
+
(c) =>
|
|
376
|
+
c.changeType === ChangeType.REMOTE_ONLY ||
|
|
377
|
+
c.changeType === ChangeType.BOTH_CHANGED
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
// Sort changes by dependency order (parents before children)
|
|
381
|
+
const sortedChanges = this.sortChangesByDependency(remoteChanges);
|
|
382
|
+
|
|
383
|
+
for (const change of sortedChanges) {
|
|
384
|
+
try {
|
|
385
|
+
await this.applyRemoteChangeToLocal(change, snapshot, dryRun);
|
|
386
|
+
result.filesChanged++;
|
|
387
|
+
} catch (error) {
|
|
388
|
+
result.errors.push({
|
|
389
|
+
path: change.path,
|
|
390
|
+
operation: "remote-to-local",
|
|
391
|
+
error: error as Error,
|
|
392
|
+
recoverable: true,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return result;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Apply local file change to remote Automerge document
|
|
402
|
+
*/
|
|
403
|
+
private async applyLocalChangeToRemote(
|
|
404
|
+
change: DetectedChange,
|
|
405
|
+
snapshot: SyncSnapshot,
|
|
406
|
+
dryRun: boolean
|
|
407
|
+
): Promise<void> {
|
|
408
|
+
const snapshotEntry = snapshot.files.get(change.path);
|
|
409
|
+
|
|
410
|
+
if (!change.localContent) {
|
|
411
|
+
// File was deleted locally
|
|
412
|
+
if (snapshotEntry) {
|
|
413
|
+
console.log(`🗑️ ${change.path}`);
|
|
414
|
+
await this.deleteRemoteFile(
|
|
415
|
+
snapshotEntry.url,
|
|
416
|
+
dryRun,
|
|
417
|
+
snapshot,
|
|
418
|
+
change.path
|
|
419
|
+
);
|
|
420
|
+
// Remove from directory document
|
|
421
|
+
await this.removeFileFromDirectory(snapshot, change.path, dryRun);
|
|
422
|
+
if (!dryRun) {
|
|
423
|
+
this.snapshotManager.removeFileEntry(snapshot, change.path);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (!snapshotEntry) {
|
|
430
|
+
// New file
|
|
431
|
+
console.log(`➕ ${change.path}`);
|
|
432
|
+
const handle = await this.createRemoteFile(change, dryRun);
|
|
433
|
+
if (!dryRun && handle) {
|
|
434
|
+
await this.addFileToDirectory(
|
|
435
|
+
snapshot,
|
|
436
|
+
change.path,
|
|
437
|
+
handle.url,
|
|
438
|
+
dryRun
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
this.snapshotManager.updateFileEntry(snapshot, change.path, {
|
|
442
|
+
path: normalizePath(this.rootPath + "/" + change.path),
|
|
443
|
+
url: handle.url,
|
|
444
|
+
head: handle.heads(),
|
|
445
|
+
extension: getFileExtension(change.path),
|
|
446
|
+
mimeType: getEnhancedMimeType(change.path),
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
} else {
|
|
450
|
+
// Update existing file
|
|
451
|
+
console.log(`📝 ${change.path}`);
|
|
452
|
+
await this.updateRemoteFile(
|
|
453
|
+
snapshotEntry.url,
|
|
454
|
+
change.localContent,
|
|
455
|
+
dryRun,
|
|
456
|
+
snapshot,
|
|
457
|
+
change.path
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Apply remote change to local filesystem
|
|
464
|
+
*/
|
|
465
|
+
private async applyRemoteChangeToLocal(
|
|
466
|
+
change: DetectedChange,
|
|
467
|
+
snapshot: SyncSnapshot,
|
|
468
|
+
dryRun: boolean
|
|
469
|
+
): Promise<void> {
|
|
470
|
+
const localPath = normalizePath(this.rootPath + "/" + change.path);
|
|
471
|
+
|
|
472
|
+
if (!change.remoteHead) {
|
|
473
|
+
throw new Error(
|
|
474
|
+
`No remote head found for remote change to${change.path}`
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (!change.remoteContent) {
|
|
479
|
+
// File was deleted remotely
|
|
480
|
+
console.log(`🗑️ ${change.path}`);
|
|
481
|
+
if (!dryRun) {
|
|
482
|
+
await removePath(localPath);
|
|
483
|
+
this.snapshotManager.removeFileEntry(snapshot, change.path);
|
|
484
|
+
}
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Create or update local file
|
|
489
|
+
if (change.changeType === ChangeType.REMOTE_ONLY) {
|
|
490
|
+
console.log(`⬇️ ${change.path}`);
|
|
491
|
+
} else {
|
|
492
|
+
console.log(`🔀 ${change.path}`);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (!dryRun) {
|
|
496
|
+
await writeFileContent(localPath, change.remoteContent);
|
|
497
|
+
|
|
498
|
+
// Update or create snapshot entry for this file
|
|
499
|
+
const snapshotEntry = snapshot.files.get(change.path);
|
|
500
|
+
if (snapshotEntry) {
|
|
501
|
+
// Update existing entry
|
|
502
|
+
snapshotEntry.head = change.remoteHead;
|
|
503
|
+
} else {
|
|
504
|
+
// Create new snapshot entry for newly discovered remote file
|
|
505
|
+
// We need to find the remote file's URL from the directory hierarchy
|
|
506
|
+
if (snapshot.rootDirectoryUrl) {
|
|
507
|
+
try {
|
|
508
|
+
const fileEntry = await this.findFileInDirectoryHierarchy(
|
|
509
|
+
snapshot.rootDirectoryUrl,
|
|
510
|
+
change.path
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
if (fileEntry) {
|
|
514
|
+
this.snapshotManager.updateFileEntry(snapshot, change.path, {
|
|
515
|
+
path: localPath,
|
|
516
|
+
url: fileEntry.url,
|
|
517
|
+
head: change.remoteHead,
|
|
518
|
+
extension: getFileExtension(change.path),
|
|
519
|
+
mimeType: getEnhancedMimeType(change.path),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
} catch (error) {
|
|
523
|
+
console.warn(
|
|
524
|
+
`Failed to update snapshot for remote file ${change.path}: ${error}`
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Apply move to remote documents
|
|
534
|
+
*/
|
|
535
|
+
private async applyMoveToRemote(
|
|
536
|
+
move: MoveCandidate,
|
|
537
|
+
snapshot: SyncSnapshot,
|
|
538
|
+
dryRun: boolean
|
|
539
|
+
): Promise<void> {
|
|
540
|
+
const fromEntry = snapshot.files.get(move.fromPath);
|
|
541
|
+
if (!fromEntry) return;
|
|
542
|
+
|
|
543
|
+
// Parse paths
|
|
544
|
+
const fromParts = move.fromPath.split("/");
|
|
545
|
+
const fromFileName = fromParts.pop() || "";
|
|
546
|
+
const fromDirPath = fromParts.join("/");
|
|
547
|
+
|
|
548
|
+
const toParts = move.toPath.split("/");
|
|
549
|
+
const toFileName = toParts.pop() || "";
|
|
550
|
+
const toDirPath = toParts.join("/");
|
|
551
|
+
|
|
552
|
+
if (!dryRun) {
|
|
553
|
+
// 1) Remove file entry from old directory document
|
|
554
|
+
if (move.fromPath !== move.toPath) {
|
|
555
|
+
await this.removeFileFromDirectory(snapshot, move.fromPath, dryRun);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// 2) Ensure destination directory document exists and add file entry there
|
|
559
|
+
const destDirUrl = await this.ensureDirectoryDocument(
|
|
560
|
+
snapshot,
|
|
561
|
+
toDirPath,
|
|
562
|
+
dryRun
|
|
563
|
+
);
|
|
564
|
+
await this.addFileToDirectory(
|
|
565
|
+
snapshot,
|
|
566
|
+
move.toPath,
|
|
567
|
+
fromEntry.url,
|
|
568
|
+
dryRun
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
// 3) Update the FileDocument name to match new basename
|
|
572
|
+
try {
|
|
573
|
+
const handle = await this.repo.find<FileDocument>(fromEntry.url);
|
|
574
|
+
const heads = fromEntry.head;
|
|
575
|
+
if (heads && heads.length > 0) {
|
|
576
|
+
handle.changeAt(heads, (doc: FileDocument) => {
|
|
577
|
+
doc.name = toFileName;
|
|
578
|
+
});
|
|
579
|
+
} else {
|
|
580
|
+
handle.change((doc: FileDocument) => {
|
|
581
|
+
doc.name = toFileName;
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
// Track file handle for network sync
|
|
585
|
+
this.handlesToWaitOn.push(handle);
|
|
586
|
+
} catch (e) {
|
|
587
|
+
console.warn(
|
|
588
|
+
`Failed to update file name for move ${move.fromPath} -> ${move.toPath}: ${e}`
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// 4) Update snapshot entries
|
|
593
|
+
this.snapshotManager.removeFileEntry(snapshot, move.fromPath);
|
|
594
|
+
this.snapshotManager.updateFileEntry(snapshot, move.toPath, {
|
|
595
|
+
...fromEntry,
|
|
596
|
+
path: normalizePath(this.rootPath + "/" + move.toPath),
|
|
597
|
+
head: fromEntry.head, // will be updated later when heads advance
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Create new remote file document
|
|
604
|
+
*/
|
|
605
|
+
private async createRemoteFile(
|
|
606
|
+
change: DetectedChange,
|
|
607
|
+
dryRun: boolean
|
|
608
|
+
): Promise<DocHandle<FileDocument> | null> {
|
|
609
|
+
if (dryRun || !change.localContent) return null;
|
|
610
|
+
|
|
611
|
+
const isText = this.isTextContent(change.localContent);
|
|
612
|
+
|
|
613
|
+
// Create initial document structure
|
|
614
|
+
const fileDoc: FileDocument = {
|
|
615
|
+
"@patchwork": { type: "file" },
|
|
616
|
+
name: change.path.split("/").pop() || "",
|
|
617
|
+
extension: getFileExtension(change.path),
|
|
618
|
+
mimeType: getEnhancedMimeType(change.path),
|
|
619
|
+
content: isText ? "" : change.localContent, // Empty string for text, actual content for binary
|
|
620
|
+
metadata: {
|
|
621
|
+
permissions: 0o644,
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
const handle = this.repo.create(fileDoc);
|
|
626
|
+
|
|
627
|
+
// For text files, use updateText to set the content properly
|
|
628
|
+
if (isText && typeof change.localContent === "string") {
|
|
629
|
+
handle.change((doc: FileDocument) => {
|
|
630
|
+
updateText(doc, ["content"], change.localContent as string);
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Always track newly created files for network sync
|
|
635
|
+
// (they always represent a change that needs to sync)
|
|
636
|
+
this.handlesToWaitOn.push(handle);
|
|
637
|
+
|
|
638
|
+
return handle;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Update existing remote file document
|
|
643
|
+
*/
|
|
644
|
+
private async updateRemoteFile(
|
|
645
|
+
url: AutomergeUrl,
|
|
646
|
+
content: string | Uint8Array,
|
|
647
|
+
dryRun: boolean,
|
|
648
|
+
snapshot: SyncSnapshot,
|
|
649
|
+
filePath: string
|
|
650
|
+
): Promise<void> {
|
|
651
|
+
if (dryRun) return;
|
|
652
|
+
|
|
653
|
+
const handle = await this.repo.find<FileDocument>(url);
|
|
654
|
+
|
|
655
|
+
// Check if content actually changed before tracking for sync
|
|
656
|
+
const doc = await handle.doc();
|
|
657
|
+
const currentContent = doc?.content;
|
|
658
|
+
const contentChanged = !isContentEqual(content, currentContent);
|
|
659
|
+
|
|
660
|
+
if (!contentChanged) {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const snapshotEntry = snapshot.files.get(filePath);
|
|
665
|
+
const heads = snapshotEntry?.head;
|
|
666
|
+
|
|
667
|
+
if (!heads) {
|
|
668
|
+
throw new Error(`No heads found for ${url}`);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
handle.changeAt(heads, (doc: FileDocument) => {
|
|
672
|
+
const isText = this.isTextContent(content);
|
|
673
|
+
if (isText && typeof content === "string") {
|
|
674
|
+
updateText(doc, ["content"], content);
|
|
675
|
+
} else {
|
|
676
|
+
doc.content = content;
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
if (!dryRun) {
|
|
681
|
+
snapshot.files.set(filePath, {
|
|
682
|
+
...snapshotEntry,
|
|
683
|
+
head: handle.heads(),
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Only track files that actually changed content
|
|
688
|
+
this.handlesToWaitOn.push(handle);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Delete remote file document
|
|
693
|
+
*/
|
|
694
|
+
private async deleteRemoteFile(
|
|
695
|
+
url: AutomergeUrl,
|
|
696
|
+
dryRun: boolean,
|
|
697
|
+
snapshot?: SyncSnapshot,
|
|
698
|
+
filePath?: string
|
|
699
|
+
): Promise<void> {
|
|
700
|
+
if (dryRun) return;
|
|
701
|
+
|
|
702
|
+
// In Automerge, we don't actually delete documents
|
|
703
|
+
// They become orphaned and will be garbage collected
|
|
704
|
+
// For now, we just mark them as deleted by clearing content
|
|
705
|
+
const handle = await this.repo.find<FileDocument>(url);
|
|
706
|
+
// const doc = await handle.doc(); // no longer needed
|
|
707
|
+
let heads;
|
|
708
|
+
if (snapshot && filePath) {
|
|
709
|
+
heads = snapshot.files.get(filePath)?.head;
|
|
710
|
+
}
|
|
711
|
+
if (heads) {
|
|
712
|
+
handle.changeAt(heads, (doc: FileDocument) => {
|
|
713
|
+
doc.content = "";
|
|
714
|
+
});
|
|
715
|
+
} else {
|
|
716
|
+
handle.change((doc: FileDocument) => {
|
|
717
|
+
doc.content = "";
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Add file entry to appropriate directory document (maintains hierarchy)
|
|
724
|
+
*/
|
|
725
|
+
private async addFileToDirectory(
|
|
726
|
+
snapshot: SyncSnapshot,
|
|
727
|
+
filePath: string,
|
|
728
|
+
fileUrl: AutomergeUrl,
|
|
729
|
+
dryRun: boolean
|
|
730
|
+
): Promise<void> {
|
|
731
|
+
if (dryRun || !snapshot.rootDirectoryUrl) return;
|
|
732
|
+
|
|
733
|
+
const pathParts = filePath.split("/");
|
|
734
|
+
const fileName = pathParts.pop() || "";
|
|
735
|
+
const directoryPath = pathParts.join("/");
|
|
736
|
+
|
|
737
|
+
// Get or create the parent directory document
|
|
738
|
+
const parentDirUrl = await this.ensureDirectoryDocument(
|
|
739
|
+
snapshot,
|
|
740
|
+
directoryPath,
|
|
741
|
+
dryRun
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
console.log(
|
|
745
|
+
`🔗 Adding ${fileName} (${fileUrl}) to directory ${parentDirUrl} (path: ${directoryPath})`
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
const dirHandle = await this.repo.find<DirectoryDocument>(parentDirUrl);
|
|
749
|
+
|
|
750
|
+
let didChange = false;
|
|
751
|
+
const snapshotEntry = snapshot.directories.get(directoryPath);
|
|
752
|
+
const heads = snapshotEntry?.head;
|
|
753
|
+
if (heads) {
|
|
754
|
+
dirHandle.changeAt(heads, (doc: DirectoryDocument) => {
|
|
755
|
+
const existingIndex = doc.docs.findIndex(
|
|
756
|
+
(entry) => entry.name === fileName && entry.type === "file"
|
|
757
|
+
);
|
|
758
|
+
if (existingIndex === -1) {
|
|
759
|
+
doc.docs.push({
|
|
760
|
+
name: fileName,
|
|
761
|
+
type: "file",
|
|
762
|
+
url: fileUrl,
|
|
763
|
+
});
|
|
764
|
+
didChange = true;
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
} else {
|
|
768
|
+
dirHandle.change((doc: DirectoryDocument) => {
|
|
769
|
+
const existingIndex = doc.docs.findIndex(
|
|
770
|
+
(entry) => entry.name === fileName && entry.type === "file"
|
|
771
|
+
);
|
|
772
|
+
if (existingIndex === -1) {
|
|
773
|
+
doc.docs.push({
|
|
774
|
+
name: fileName,
|
|
775
|
+
type: "file",
|
|
776
|
+
url: fileUrl,
|
|
777
|
+
});
|
|
778
|
+
didChange = true;
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
if (didChange) {
|
|
783
|
+
this.handlesToWaitOn.push(dirHandle);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Ensure directory document exists for the given path, creating hierarchy as needed
|
|
789
|
+
* First checks for existing shared directories before creating new ones
|
|
790
|
+
*/
|
|
791
|
+
private async ensureDirectoryDocument(
|
|
792
|
+
snapshot: SyncSnapshot,
|
|
793
|
+
directoryPath: string,
|
|
794
|
+
dryRun: boolean
|
|
795
|
+
): Promise<AutomergeUrl> {
|
|
796
|
+
// Root directory case
|
|
797
|
+
if (!directoryPath || directoryPath === "") {
|
|
798
|
+
return snapshot.rootDirectoryUrl!;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Check if we already have this directory in snapshot
|
|
802
|
+
const existingDir = snapshot.directories.get(directoryPath);
|
|
803
|
+
if (existingDir) {
|
|
804
|
+
return existingDir.url;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Split path into parent and current directory name
|
|
808
|
+
const pathParts = directoryPath.split("/");
|
|
809
|
+
const currentDirName = pathParts.pop() || "";
|
|
810
|
+
const parentPath = pathParts.join("/");
|
|
811
|
+
|
|
812
|
+
// Ensure parent directory exists first (recursive)
|
|
813
|
+
const parentDirUrl = await this.ensureDirectoryDocument(
|
|
814
|
+
snapshot,
|
|
815
|
+
parentPath,
|
|
816
|
+
dryRun
|
|
817
|
+
);
|
|
818
|
+
|
|
819
|
+
// DISCOVERY: Check if directory already exists in parent on server
|
|
820
|
+
try {
|
|
821
|
+
const parentHandle = await this.repo.find<DirectoryDocument>(
|
|
822
|
+
parentDirUrl
|
|
823
|
+
);
|
|
824
|
+
const parentDoc = await parentHandle.doc();
|
|
825
|
+
|
|
826
|
+
if (parentDoc) {
|
|
827
|
+
const existingDirEntry = parentDoc.docs.find(
|
|
828
|
+
(entry: { name: string; type: string; url: AutomergeUrl }) =>
|
|
829
|
+
entry.name === currentDirName && entry.type === "folder"
|
|
830
|
+
);
|
|
831
|
+
|
|
832
|
+
if (existingDirEntry) {
|
|
833
|
+
// Resolve the actual directory handle and use its current heads
|
|
834
|
+
// Directory entries in parent docs may not carry valid heads
|
|
835
|
+
try {
|
|
836
|
+
const childDirHandle = await this.repo.find<DirectoryDocument>(
|
|
837
|
+
existingDirEntry.url
|
|
838
|
+
);
|
|
839
|
+
const childHeads = childDirHandle.heads();
|
|
840
|
+
|
|
841
|
+
// Update snapshot with discovered directory using validated heads
|
|
842
|
+
if (!dryRun) {
|
|
843
|
+
this.snapshotManager.updateDirectoryEntry(
|
|
844
|
+
snapshot,
|
|
845
|
+
directoryPath,
|
|
846
|
+
{
|
|
847
|
+
path: normalizePath(this.rootPath + "/" + directoryPath),
|
|
848
|
+
url: existingDirEntry.url,
|
|
849
|
+
head: childHeads,
|
|
850
|
+
entries: [],
|
|
851
|
+
}
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return existingDirEntry.url;
|
|
856
|
+
} catch (resolveErr) {
|
|
857
|
+
console.warn(
|
|
858
|
+
`Failed to resolve child directory ${currentDirName} at ${directoryPath}: ${resolveErr}`
|
|
859
|
+
);
|
|
860
|
+
// Fall through to create a fresh directory document
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
} catch (error) {
|
|
865
|
+
console.warn(
|
|
866
|
+
`Failed to check for existing directory ${currentDirName}: ${error}`
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// CREATE: Directory doesn't exist, create new one
|
|
871
|
+
const dirDoc: DirectoryDocument = {
|
|
872
|
+
"@patchwork": { type: "folder" },
|
|
873
|
+
docs: [],
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
const dirHandle = this.repo.create(dirDoc);
|
|
877
|
+
|
|
878
|
+
// Add this directory to its parent
|
|
879
|
+
const parentHandle = await this.repo.find<DirectoryDocument>(parentDirUrl);
|
|
880
|
+
|
|
881
|
+
let didChange = false;
|
|
882
|
+
parentHandle.change((doc: DirectoryDocument) => {
|
|
883
|
+
// Double-check that entry doesn't exist (race condition protection)
|
|
884
|
+
const existingIndex = doc.docs.findIndex(
|
|
885
|
+
(entry: { name: string; type: string; url: AutomergeUrl }) =>
|
|
886
|
+
entry.name === currentDirName && entry.type === "folder"
|
|
887
|
+
);
|
|
888
|
+
if (existingIndex === -1) {
|
|
889
|
+
doc.docs.push({
|
|
890
|
+
name: currentDirName,
|
|
891
|
+
type: "folder",
|
|
892
|
+
url: dirHandle.url,
|
|
893
|
+
});
|
|
894
|
+
didChange = true;
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
// Track directory handles for sync
|
|
899
|
+
if (!dryRun) {
|
|
900
|
+
this.handlesToWaitOn.push(dirHandle);
|
|
901
|
+
if (didChange) {
|
|
902
|
+
this.handlesToWaitOn.push(parentHandle);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Update snapshot with new directory
|
|
906
|
+
this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
|
|
907
|
+
path: normalizePath(this.rootPath + "/" + directoryPath),
|
|
908
|
+
url: dirHandle.url,
|
|
909
|
+
head: dirHandle.heads(),
|
|
910
|
+
entries: [],
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
return dirHandle.url;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Remove file entry from directory document
|
|
919
|
+
*/
|
|
920
|
+
private async removeFileFromDirectory(
|
|
921
|
+
snapshot: SyncSnapshot,
|
|
922
|
+
filePath: string,
|
|
923
|
+
dryRun: boolean
|
|
924
|
+
): Promise<void> {
|
|
925
|
+
if (dryRun || !snapshot.rootDirectoryUrl) return;
|
|
926
|
+
|
|
927
|
+
const pathParts = filePath.split("/");
|
|
928
|
+
const fileName = pathParts.pop() || "";
|
|
929
|
+
const directoryPath = pathParts.join("/");
|
|
930
|
+
|
|
931
|
+
// Get the parent directory URL
|
|
932
|
+
let parentDirUrl: AutomergeUrl;
|
|
933
|
+
if (!directoryPath || directoryPath === "") {
|
|
934
|
+
parentDirUrl = snapshot.rootDirectoryUrl;
|
|
935
|
+
} else {
|
|
936
|
+
const existingDir = snapshot.directories.get(directoryPath);
|
|
937
|
+
if (!existingDir) {
|
|
938
|
+
console.warn(
|
|
939
|
+
`Directory ${directoryPath} not found in snapshot for file removal`
|
|
940
|
+
);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
parentDirUrl = existingDir.url;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
try {
|
|
947
|
+
const dirHandle = await this.repo.find<DirectoryDocument>(parentDirUrl);
|
|
948
|
+
|
|
949
|
+
// Track this handle for network sync waiting
|
|
950
|
+
this.handlesToWaitOn.push(dirHandle);
|
|
951
|
+
const snapshotEntry = snapshot.directories.get(directoryPath);
|
|
952
|
+
const heads = snapshotEntry?.head;
|
|
953
|
+
if (heads) {
|
|
954
|
+
dirHandle.changeAt(heads, (doc: DirectoryDocument) => {
|
|
955
|
+
const indexToRemove = doc.docs.findIndex(
|
|
956
|
+
(entry) => entry.name === fileName && entry.type === "file"
|
|
957
|
+
);
|
|
958
|
+
if (indexToRemove !== -1) {
|
|
959
|
+
doc.docs.splice(indexToRemove, 1);
|
|
960
|
+
console.log(
|
|
961
|
+
`🗑️ Removed ${fileName} from directory ${
|
|
962
|
+
directoryPath || "root"
|
|
963
|
+
}`
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
} else {
|
|
968
|
+
dirHandle.change((doc: DirectoryDocument) => {
|
|
969
|
+
const indexToRemove = doc.docs.findIndex(
|
|
970
|
+
(entry) => entry.name === fileName && entry.type === "file"
|
|
971
|
+
);
|
|
972
|
+
if (indexToRemove !== -1) {
|
|
973
|
+
doc.docs.splice(indexToRemove, 1);
|
|
974
|
+
console.log(
|
|
975
|
+
`🗑️ Removed ${fileName} from directory ${
|
|
976
|
+
directoryPath || "root"
|
|
977
|
+
}`
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
} catch (error) {
|
|
983
|
+
console.warn(
|
|
984
|
+
`Failed to remove ${fileName} from directory ${
|
|
985
|
+
directoryPath || "root"
|
|
986
|
+
}: ${error}`
|
|
987
|
+
);
|
|
988
|
+
throw error;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Find a file in the directory hierarchy by path
|
|
994
|
+
*/
|
|
995
|
+
private async findFileInDirectoryHierarchy(
|
|
996
|
+
directoryUrl: AutomergeUrl,
|
|
997
|
+
filePath: string
|
|
998
|
+
): Promise<{ name: string; type: string; url: AutomergeUrl } | null> {
|
|
999
|
+
try {
|
|
1000
|
+
const pathParts = filePath.split("/");
|
|
1001
|
+
let currentDirUrl = directoryUrl;
|
|
1002
|
+
|
|
1003
|
+
// Navigate through directories to find the parent directory
|
|
1004
|
+
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
1005
|
+
const dirName = pathParts[i];
|
|
1006
|
+
const dirHandle = await this.repo.find<DirectoryDocument>(
|
|
1007
|
+
currentDirUrl
|
|
1008
|
+
);
|
|
1009
|
+
const dirDoc = await dirHandle.doc();
|
|
1010
|
+
|
|
1011
|
+
if (!dirDoc) return null;
|
|
1012
|
+
|
|
1013
|
+
const subDirEntry = dirDoc.docs.find(
|
|
1014
|
+
(entry: { name: string; type: string; url: AutomergeUrl }) =>
|
|
1015
|
+
entry.name === dirName && entry.type === "folder"
|
|
1016
|
+
);
|
|
1017
|
+
|
|
1018
|
+
if (!subDirEntry) return null;
|
|
1019
|
+
currentDirUrl = subDirEntry.url;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Now look for the file in the final directory
|
|
1023
|
+
const fileName = pathParts[pathParts.length - 1];
|
|
1024
|
+
const finalDirHandle = await this.repo.find<DirectoryDocument>(
|
|
1025
|
+
currentDirUrl
|
|
1026
|
+
);
|
|
1027
|
+
const finalDirDoc = await finalDirHandle.doc();
|
|
1028
|
+
|
|
1029
|
+
if (!finalDirDoc) return null;
|
|
1030
|
+
|
|
1031
|
+
const fileEntry = finalDirDoc.docs.find(
|
|
1032
|
+
(entry: { name: string; type: string; url: AutomergeUrl }) =>
|
|
1033
|
+
entry.name === fileName && entry.type === "file"
|
|
1034
|
+
);
|
|
1035
|
+
|
|
1036
|
+
return fileEntry || null;
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
console.warn(
|
|
1039
|
+
`Failed to find file ${filePath} in directory hierarchy: ${error}`
|
|
1040
|
+
);
|
|
1041
|
+
return null;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Sort changes by dependency order
|
|
1047
|
+
*/
|
|
1048
|
+
private sortChangesByDependency(changes: DetectedChange[]): DetectedChange[] {
|
|
1049
|
+
// Sort by path depth (shallower paths first)
|
|
1050
|
+
return changes.sort((a, b) => {
|
|
1051
|
+
const depthA = a.path.split("/").length;
|
|
1052
|
+
const depthB = b.path.split("/").length;
|
|
1053
|
+
return depthA - depthB;
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Get sync status
|
|
1059
|
+
*/
|
|
1060
|
+
async getStatus(): Promise<{
|
|
1061
|
+
snapshot: SyncSnapshot | null;
|
|
1062
|
+
hasChanges: boolean;
|
|
1063
|
+
changeCount: number;
|
|
1064
|
+
lastSync: Date | null;
|
|
1065
|
+
}> {
|
|
1066
|
+
const snapshot = await this.snapshotManager.load();
|
|
1067
|
+
|
|
1068
|
+
if (!snapshot) {
|
|
1069
|
+
return {
|
|
1070
|
+
snapshot: null,
|
|
1071
|
+
hasChanges: false,
|
|
1072
|
+
changeCount: 0,
|
|
1073
|
+
lastSync: null,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const changes = await this.changeDetector.detectChanges(snapshot);
|
|
1078
|
+
|
|
1079
|
+
return {
|
|
1080
|
+
snapshot,
|
|
1081
|
+
hasChanges: changes.length > 0,
|
|
1082
|
+
changeCount: changes.length,
|
|
1083
|
+
lastSync: new Date(snapshot.timestamp),
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Preview changes without applying them
|
|
1089
|
+
*/
|
|
1090
|
+
async previewChanges(): Promise<{
|
|
1091
|
+
changes: DetectedChange[];
|
|
1092
|
+
moves: MoveCandidate[];
|
|
1093
|
+
summary: string;
|
|
1094
|
+
}> {
|
|
1095
|
+
const snapshot = await this.snapshotManager.load();
|
|
1096
|
+
if (!snapshot) {
|
|
1097
|
+
return {
|
|
1098
|
+
changes: [],
|
|
1099
|
+
moves: [],
|
|
1100
|
+
summary: "No snapshot found - run init first",
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const changes = await this.changeDetector.detectChanges(snapshot);
|
|
1105
|
+
const { moves } = await this.moveDetector.detectMoves(
|
|
1106
|
+
changes,
|
|
1107
|
+
snapshot,
|
|
1108
|
+
this.rootPath
|
|
1109
|
+
);
|
|
1110
|
+
|
|
1111
|
+
const summary = this.generateChangeSummary(changes, moves);
|
|
1112
|
+
|
|
1113
|
+
return { changes, moves, summary };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Generate human-readable summary of changes
|
|
1118
|
+
*/
|
|
1119
|
+
private generateChangeSummary(
|
|
1120
|
+
changes: DetectedChange[],
|
|
1121
|
+
moves: MoveCandidate[]
|
|
1122
|
+
): string {
|
|
1123
|
+
const localChanges = changes.filter(
|
|
1124
|
+
(c) =>
|
|
1125
|
+
c.changeType === ChangeType.LOCAL_ONLY ||
|
|
1126
|
+
c.changeType === ChangeType.BOTH_CHANGED
|
|
1127
|
+
).length;
|
|
1128
|
+
|
|
1129
|
+
const remoteChanges = changes.filter(
|
|
1130
|
+
(c) =>
|
|
1131
|
+
c.changeType === ChangeType.REMOTE_ONLY ||
|
|
1132
|
+
c.changeType === ChangeType.BOTH_CHANGED
|
|
1133
|
+
).length;
|
|
1134
|
+
|
|
1135
|
+
const conflicts = changes.filter(
|
|
1136
|
+
(c) => c.changeType === ChangeType.BOTH_CHANGED
|
|
1137
|
+
).length;
|
|
1138
|
+
|
|
1139
|
+
const parts: string[] = [];
|
|
1140
|
+
|
|
1141
|
+
if (localChanges > 0) {
|
|
1142
|
+
parts.push(`${localChanges} local change${localChanges > 1 ? "s" : ""}`);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
if (remoteChanges > 0) {
|
|
1146
|
+
parts.push(
|
|
1147
|
+
`${remoteChanges} remote change${remoteChanges > 1 ? "s" : ""}`
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (moves.length > 0) {
|
|
1152
|
+
parts.push(
|
|
1153
|
+
`${moves.length} potential move${moves.length > 1 ? "s" : ""}`
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (conflicts > 0) {
|
|
1158
|
+
parts.push(`${conflicts} conflict${conflicts > 1 ? "s" : ""}`);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (parts.length === 0) {
|
|
1162
|
+
return "No changes detected";
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
return parts.join(", ");
|
|
1166
|
+
}
|
|
1167
|
+
}
|