@filen/sync 0.2.1 → 0.3.2
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/.node-version +1 -1
- package/dist/ignorer.d.ts +6 -0
- package/dist/ignorer.js +43 -24
- package/dist/ignorer.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/deltas.d.ts +58 -2
- package/dist/lib/deltas.js +693 -108
- package/dist/lib/deltas.js.map +1 -1
- package/dist/lib/environment.d.ts +47 -0
- package/dist/lib/environment.js +71 -0
- package/dist/lib/environment.js.map +1 -0
- package/dist/lib/filesystems/dirTree.d.ts +70 -0
- package/dist/lib/filesystems/dirTree.js +157 -0
- package/dist/lib/filesystems/dirTree.js.map +1 -0
- package/dist/lib/filesystems/local.d.ts +18 -8
- package/dist/lib/filesystems/local.js +166 -160
- package/dist/lib/filesystems/local.js.map +1 -1
- package/dist/lib/filesystems/remote.d.ts +12 -5
- package/dist/lib/filesystems/remote.js +226 -172
- package/dist/lib/filesystems/remote.js.map +1 -1
- package/dist/lib/ipc.js +1 -2
- package/dist/lib/ipc.js.map +1 -1
- package/dist/lib/lock.js +19 -12
- package/dist/lib/lock.js.map +1 -1
- package/dist/lib/logger.js +9 -7
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/state.js +159 -63
- package/dist/lib/state.js.map +1 -1
- package/dist/lib/sync.d.ts +18 -0
- package/dist/lib/sync.js +165 -96
- package/dist/lib/sync.js.map +1 -1
- package/dist/lib/tasks.d.ts +7 -8
- package/dist/lib/tasks.js +38 -45
- package/dist/lib/tasks.js.map +1 -1
- package/dist/semaphore.d.ts +1 -0
- package/dist/semaphore.js +22 -5
- package/dist/semaphore.js.map +1 -1
- package/dist/utils.js +51 -35
- package/dist/utils.js.map +1 -1
- package/eslint.config.mjs +36 -0
- package/package.json +19 -15
- package/tests/bench/collapse.bench.ts +114 -0
- package/tests/bench/cycle.bench.ts +111 -0
- package/tests/bench/deltas.bench.ts +151 -0
- package/tests/bench/harness/fake-sync.ts +32 -0
- package/tests/bench/harness/measure.ts +276 -0
- package/tests/bench/harness/scale-world.ts +160 -0
- package/tests/bench/harness/trees.ts +275 -0
- package/tests/bench/local-scan.bench.ts +74 -0
- package/tests/bench/longrun.bench.ts +130 -0
- package/tests/bench/profile-incremental.ts +90 -0
- package/tests/bench/remote-build.bench.ts +104 -0
- package/tests/bench/render.ts +14 -0
- package/tests/bench/semaphore.bench.ts +79 -0
- package/tests/bench/state.bench.ts +85 -0
- package/tests/bench/tasks-dispatch.bench.ts +156 -0
- package/tests/conformance/virtual-fs.test.ts +213 -0
- package/tests/e2e/backup.e2e.test.ts +130 -0
- package/tests/e2e/confirm.e2e.test.ts +191 -0
- package/tests/e2e/conflict.e2e.test.ts +261 -0
- package/tests/e2e/edge.e2e.test.ts +339 -0
- package/tests/e2e/harness/account.ts +104 -0
- package/tests/e2e/harness/assert.ts +127 -0
- package/tests/e2e/harness/drive.ts +88 -0
- package/tests/e2e/harness/mutations.ts +249 -0
- package/tests/e2e/harness/world.ts +222 -0
- package/tests/e2e/ignore.e2e.test.ts +123 -0
- package/tests/e2e/lifecycle.e2e.test.ts +290 -0
- package/tests/e2e/modes.e2e.test.ts +215 -0
- package/tests/e2e/platform.e2e.test.ts +157 -0
- package/tests/e2e/property.e2e.test.ts +163 -0
- package/tests/e2e/races.e2e.test.ts +90 -0
- package/tests/e2e/regressions.e2e.test.ts +212 -0
- package/tests/e2e/resilience.e2e.test.ts +231 -0
- package/tests/e2e/special.e2e.test.ts +185 -0
- package/tests/e2e/state.e2e.test.ts +229 -0
- package/tests/e2e/sync.e2e.test.ts +222 -0
- package/tests/fakes/fake-cloud.test.ts +267 -0
- package/tests/fakes/fake-cloud.ts +1094 -0
- package/tests/fakes/virtual-fs.ts +354 -0
- package/tests/harness/known-bug.ts +17 -0
- package/tests/harness/mutations.ts +65 -0
- package/tests/harness/runner.ts +141 -0
- package/tests/harness/snapshot.ts +113 -0
- package/tests/harness/world.ts +187 -0
- package/tests/scenarios/a-baseline.test.ts +107 -0
- package/tests/scenarios/aa-races.test.ts +258 -0
- package/tests/scenarios/ab-mode-property.test.ts +189 -0
- package/tests/scenarios/ac-platform.test.ts +320 -0
- package/tests/scenarios/ad-unicode-normalization.test.ts +67 -0
- package/tests/scenarios/b-additions.test.ts +160 -0
- package/tests/scenarios/c-modifications.test.ts +194 -0
- package/tests/scenarios/d-deletions.test.ts +259 -0
- package/tests/scenarios/e-rename-move.test.ts +288 -0
- package/tests/scenarios/f-ignore-filter.test.ts +346 -0
- package/tests/scenarios/g-large-deletion.test.ts +277 -0
- package/tests/scenarios/h-resilience.test.ts +167 -0
- package/tests/scenarios/i-lifecycle.test.ts +353 -0
- package/tests/scenarios/j-state-cache.test.ts +264 -0
- package/tests/scenarios/k-scale.test.ts +202 -0
- package/tests/scenarios/l-property.test.ts +145 -0
- package/tests/scenarios/m-golden.test.ts +452 -0
- package/tests/scenarios/o-task-errors.test.ts +497 -0
- package/tests/scenarios/p-remote-originated.test.ts +306 -0
- package/tests/scenarios/q-cycle-lifecycle.test.ts +234 -0
- package/tests/scenarios/r-rename-stress.test.ts +208 -0
- package/tests/scenarios/s-upgrade-transition.test.ts +171 -0
- package/tests/scenarios/t-type-change.test.ts +144 -0
- package/tests/scenarios/u-mode-local-to-cloud.test.ts +347 -0
- package/tests/scenarios/v-mode-local-backup.test.ts +201 -0
- package/tests/scenarios/w-mode-cloud-to-local.test.ts +304 -0
- package/tests/scenarios/x-mode-cloud-backup.test.ts +201 -0
- package/tests/scenarios/y-conflict-matrix.test.ts +292 -0
- package/tests/scenarios/z-cross-ops.test.ts +285 -0
- package/tests/scenarios/zb-dir-rename-cross.test.ts +296 -0
- package/tests/scenarios/zc-crash-recovery.test.ts +189 -0
- package/tests/scenarios/zd-inode-reuse.test.ts +118 -0
- package/tests/scenarios/ze-move-into-new-dir.test.ts +130 -0
- package/tests/scenarios/zf-remote-change-unchanged-local.test.ts +81 -0
- package/tests/scenarios/zg-edit-during-scan.test.ts +68 -0
- package/tests/scenarios/zh-dir-delete-vs-child.test.ts +104 -0
- package/tests/scenarios/zi-smoke-test-outage.test.ts +78 -0
- package/tests/scenarios/zj-trash-cleanup.test.ts +133 -0
- package/tests/scenarios/zk-ignore-asymmetry.test.ts +150 -0
- package/tests/scenarios/zl-mode-atomicity.test.ts +104 -0
- package/tests/scenarios/zm-scan-concurrency.test.ts +78 -0
- package/tests/scenarios/zn-delta-ordering.test.ts +130 -0
- package/tests/scenarios/zo-download-temp-cleanup.test.ts +65 -0
- package/tests/unit/collapse-deltas.test.ts +276 -0
- package/tests/unit/dir-tree.test.ts +159 -0
- package/tests/unit/icloud.test.ts +115 -0
- package/tests/unit/ignorer-cache-regression.test.ts +70 -0
- package/tests/unit/ignorer.test.ts +63 -0
- package/tests/unit/ipc-lock.test.ts +438 -0
- package/tests/unit/lock.test.ts +135 -0
- package/tests/unit/n-unit.test.ts +632 -0
- package/tests/unit/remote-tree-unordered-regression.test.ts +101 -0
- package/tests/unit/semaphore-regression.test.ts +140 -0
- package/tests/unit/state-refencode-regression.test.ts +224 -0
- package/tests/unit/state.test.ts +809 -0
- package/tests/unit/tasks-dispatch-order-regression.test.ts +53 -0
- package/tests/unit/worker-api.test.ts +379 -0
- package/tsconfig.json +10 -1
- package/tsconfig.test.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.bench.config.ts +32 -0
- package/vitest.config.ts +27 -0
- package/vitest.e2e.config.ts +68 -0
- package/.eslintrc +0 -16
- package/jest.config.js +0 -5
package/dist/lib/deltas.js
CHANGED
|
@@ -1,7 +1,254 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.Deltas = void 0;
|
|
4
|
+
exports.collapseDeltas = collapseDeltas;
|
|
5
|
+
exports.directoriesWithSurvivingChildren = directoriesWithSurvivingChildren;
|
|
6
|
+
exports.rebasePathAcrossRenames = rebasePathAcrossRenames;
|
|
7
|
+
exports.rebaseLocalTreeAcrossRenames = rebaseLocalTreeAcrossRenames;
|
|
8
|
+
exports.rebaseRemoteTreeAcrossRenames = rebaseRemoteTreeAcrossRenames;
|
|
4
9
|
const utils_1 = require("../utils");
|
|
10
|
+
/**
|
|
11
|
+
* Collapses child rename/move/delete deltas into the parent-directory delta that already covers them.
|
|
12
|
+
*
|
|
13
|
+
* When a directory is renamed or deleted, the tree diff also emits a delta for every descendant (each
|
|
14
|
+
* child's path changed too). Executing all of them is wasteful — the parent op already moves or removes
|
|
15
|
+
* the whole subtree — so each child is folded into its parent here, saving disk usage and API calls.
|
|
16
|
+
*
|
|
17
|
+
* Pure and side-effect free so it can be unit-tested directly against adversarial delta arrays. Two
|
|
18
|
+
* correctness properties that the previous in-place version did NOT guarantee:
|
|
19
|
+
*
|
|
20
|
+
* 1. Most-specific parent wins. A child can sit under more than one renamed ancestor (e.g. both
|
|
21
|
+
* "/a" -> "/x" and "/a/b" -> "/x/y" cover "/a/b/c.txt"). The longest-`from` ancestor is the
|
|
22
|
+
* immediate enclosing renamed directory, and its `to` already reflects every outer rename — so
|
|
23
|
+
* applying ONLY that one yields the correct post-rename source. Composing every match in array
|
|
24
|
+
* order (the old loop) was order-dependent and could mis-compose.
|
|
25
|
+
* 2. No neighbour corruption. We build a fresh array instead of splicing the input while iterating.
|
|
26
|
+
* The old loop spliced `deltas[i]` in place, so a removal shifted an unrelated delta into slot `i`
|
|
27
|
+
* that a later iteration could overwrite — silently dropping a real op and leaving a bogus one.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Walk `path`'s ancestor directories from the immediate parent upward, returning the value of the FIRST
|
|
31
|
+
* (deepest, i.e. longest) ancestor that is a key of `map` — the most-specific enclosing entry — or
|
|
32
|
+
* `undefined`. Equivalent to scanning every entry for the longest key that is a strict ancestor of `path`
|
|
33
|
+
* (`path.startsWith(key + "/")`), but O(depth) map lookups instead of O(entries) prefix comparisons. The
|
|
34
|
+
* walk stops above the root and never returns `path` itself, matching the original `startsWith(key + "/")`
|
|
35
|
+
* (strict-ancestor) semantics, including the "/" boundary that stops "/abc" matching a key of "/ab".
|
|
36
|
+
*/
|
|
37
|
+
function nearestAncestor(path, map) {
|
|
38
|
+
let end = path.lastIndexOf("/");
|
|
39
|
+
while (end > 0) {
|
|
40
|
+
const hit = map.get(path.slice(0, end));
|
|
41
|
+
if (hit !== undefined) {
|
|
42
|
+
return hit;
|
|
43
|
+
}
|
|
44
|
+
end = path.lastIndexOf("/", end - 1);
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
/** Whether any strict ancestor directory of `path` is in `set` (same ancestor-walk as {@link nearestAncestor}). */
|
|
49
|
+
function hasAncestorIn(path, set) {
|
|
50
|
+
let end = path.lastIndexOf("/");
|
|
51
|
+
while (end > 0) {
|
|
52
|
+
if (set.has(path.slice(0, end))) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
end = path.lastIndexOf("/", end - 1);
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
function collapseDeltas({ deltas, renamedLocalDirectories, renamedRemoteDirectories, deletedLocalDirectories, deletedRemoteDirectories }) {
|
|
60
|
+
// Index the renamed/deleted directories by path so each child delta finds its nearest enclosing ancestor
|
|
61
|
+
// via an O(depth) ancestor-walk instead of an O(directories) scan. A deep/wide directory move otherwise
|
|
62
|
+
// makes this pass O(deltas * directories * pathLength) — super-linear, seconds-long for a large move (P1).
|
|
63
|
+
const localRenameByFrom = new Map();
|
|
64
|
+
const remoteRenameByFrom = new Map();
|
|
65
|
+
const localDeletedDirs = new Set();
|
|
66
|
+
const remoteDeletedDirs = new Set();
|
|
67
|
+
for (const directoryDelta of renamedLocalDirectories) {
|
|
68
|
+
if (directoryDelta.type === "renameLocalDirectory") {
|
|
69
|
+
localRenameByFrom.set(directoryDelta.from, directoryDelta);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const directoryDelta of renamedRemoteDirectories) {
|
|
73
|
+
if (directoryDelta.type === "renameRemoteDirectory") {
|
|
74
|
+
remoteRenameByFrom.set(directoryDelta.from, directoryDelta);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const directoryDelta of deletedLocalDirectories) {
|
|
78
|
+
if (directoryDelta.type === "deleteLocalDirectory") {
|
|
79
|
+
localDeletedDirs.add(directoryDelta.path);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const directoryDelta of deletedRemoteDirectories) {
|
|
83
|
+
if (directoryDelta.type === "deleteRemoteDirectory") {
|
|
84
|
+
remoteDeletedDirs.add(directoryDelta.path);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const collapsed = [];
|
|
88
|
+
for (const delta of deltas) {
|
|
89
|
+
if (delta.type === "renameLocalDirectory" || delta.type === "renameLocalFile") {
|
|
90
|
+
const parent = nearestAncestor(delta.from, localRenameByFrom);
|
|
91
|
+
if (parent) {
|
|
92
|
+
const newFromPath = (0, utils_1.replacePathStartWithFromAndTo)(delta.from, parent.from, parent.to);
|
|
93
|
+
// Equal to the target => the parent rename already lands the child here, so it is redundant.
|
|
94
|
+
if (newFromPath !== delta.to) {
|
|
95
|
+
collapsed.push(Object.assign(Object.assign({}, delta), { from: newFromPath }));
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else if (delta.type === "renameRemoteDirectory" || delta.type === "renameRemoteFile") {
|
|
101
|
+
const parent = nearestAncestor(delta.from, remoteRenameByFrom);
|
|
102
|
+
if (parent) {
|
|
103
|
+
const newFromPath = (0, utils_1.replacePathStartWithFromAndTo)(delta.from, parent.from, parent.to);
|
|
104
|
+
if (newFromPath !== delta.to) {
|
|
105
|
+
collapsed.push(Object.assign(Object.assign({}, delta), { from: newFromPath }));
|
|
106
|
+
}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else if (delta.type === "deleteLocalDirectory" || delta.type === "deleteLocalFile") {
|
|
111
|
+
if (hasAncestorIn(delta.path, localDeletedDirs)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else if (delta.type === "deleteRemoteDirectory" || delta.type === "deleteRemoteFile") {
|
|
116
|
+
if (hasAncestorIn(delta.path, remoteDeletedDirs)) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
collapsed.push(delta);
|
|
121
|
+
}
|
|
122
|
+
return collapsed;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Find directories slated for deletion that STILL hold live content on the deleting-resistant side — a
|
|
126
|
+
* freshly-added child, or a base child kept alive by newer-modify-wins — and must therefore survive.
|
|
127
|
+
*
|
|
128
|
+
* When one side deletes a directory and the other adds/keeps something inside it in the same cycle, the
|
|
129
|
+
* raw delta set contains both `deleteXDirectory <dir>` and the child's own add. Left alone, collapseDeltas
|
|
130
|
+
* subsumes the child's sibling DELETES under the dir-delete and the dir-delete then cascades over the
|
|
131
|
+
* surviving child at execution time — wiping a brand-new file before it is ever propagated (data loss).
|
|
132
|
+
* Newer content beats a delete (the same rule the per-file passes already apply), so the directory must
|
|
133
|
+
* stay: drop its delete and let the surviving child's own add re-assert it.
|
|
134
|
+
*
|
|
135
|
+
* A child "survives under <dir>" iff it is present in the current tree (`tree`), is NOT itself slated for
|
|
136
|
+
* deletion (same-direction `delete{File,Directory}`), and is NOT leaving via a rename — neither the child
|
|
137
|
+
* itself nor any ancestor is the `from` of a same-direction `rename{File,Directory}` (a renamed directory
|
|
138
|
+
* carries all its descendants out with it). Without the rename exclusion, a directory rename — which is
|
|
139
|
+
* emitted as "rename the children to the new path + delete the now-empty old directory" — would look like
|
|
140
|
+
* the old directory still has live children and never get deleted (its children are still at their old
|
|
141
|
+
* paths in this pre-rebase tree). Returns the set of dir paths to KEEP. Pure: the caller applies the
|
|
142
|
+
* pruning. Linear in tree size times depth; short-circuits when nothing is being deleted.
|
|
143
|
+
*/
|
|
144
|
+
function directoriesWithSurvivingChildren(deltas, dirDeleteType, fileDeleteType, renameDirType, renameFileType, tree) {
|
|
145
|
+
const deletedDirPaths = new Set();
|
|
146
|
+
const deletedPaths = new Set();
|
|
147
|
+
const renamedFromPaths = new Set();
|
|
148
|
+
for (const delta of deltas) {
|
|
149
|
+
if (delta.type === dirDeleteType) {
|
|
150
|
+
deletedDirPaths.add(delta.path);
|
|
151
|
+
deletedPaths.add(delta.path);
|
|
152
|
+
}
|
|
153
|
+
else if (delta.type === fileDeleteType) {
|
|
154
|
+
deletedPaths.add(delta.path);
|
|
155
|
+
}
|
|
156
|
+
else if (delta.type === renameDirType || delta.type === renameFileType) {
|
|
157
|
+
renamedFromPaths.add(delta.from);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const keep = new Set();
|
|
161
|
+
if (deletedDirPaths.size === 0) {
|
|
162
|
+
return keep;
|
|
163
|
+
}
|
|
164
|
+
for (const path in tree) {
|
|
165
|
+
if (deletedPaths.has(path) || renamedFromPaths.has(path)) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// Walk the ancestor chain once, collecting any deleted-directory ancestors. If ANY ancestor is the
|
|
169
|
+
// source of a rename, this whole subtree is moving away, so it keeps nothing alive — discard them.
|
|
170
|
+
const deletedDirAncestors = [];
|
|
171
|
+
let leaving = false;
|
|
172
|
+
let parent = path;
|
|
173
|
+
let slash = parent.lastIndexOf("/");
|
|
174
|
+
while (slash > 0) {
|
|
175
|
+
parent = parent.slice(0, slash);
|
|
176
|
+
if (renamedFromPaths.has(parent)) {
|
|
177
|
+
leaving = true;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
if (deletedDirPaths.has(parent)) {
|
|
181
|
+
deletedDirAncestors.push(parent);
|
|
182
|
+
}
|
|
183
|
+
slash = parent.lastIndexOf("/");
|
|
184
|
+
}
|
|
185
|
+
if (!leaving) {
|
|
186
|
+
for (const ancestor of deletedDirAncestors) {
|
|
187
|
+
keep.add(ancestor);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return keep;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Remaps `path` across a set of propagated directory renames, returning its post-rename path (unchanged if
|
|
195
|
+
* none applies). The most-specific (longest `from`) ancestor wins, and each rename's `to` already encodes
|
|
196
|
+
* any outer renames (the rename pass records every directory's FINAL position), so one pass handles
|
|
197
|
+
* independent AND nested directory renames correctly.
|
|
198
|
+
*/
|
|
199
|
+
function rebasePathAcrossRenames(path, renames) {
|
|
200
|
+
let best;
|
|
201
|
+
for (const rename of renames) {
|
|
202
|
+
if (path.startsWith(`${rename.from}/`) && (!best || rename.from.length > best.from.length)) {
|
|
203
|
+
best = rename;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return best ? best.to + path.slice(best.from.length) : path;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Returns a COPY of `tree` with every DESCENDANT of a renamed directory moved to its post-rename path. The
|
|
210
|
+
* input is never mutated (entries outside any renamed subtree are shared by reference). The renamed
|
|
211
|
+
* directory nodes themselves are NOT moved here — they are handled via `pathsAdded` — only their children.
|
|
212
|
+
* Used to realign the base + the not-yet-renamed side so the per-descendant passes attribute change at the
|
|
213
|
+
* correct post-rename path (BUG-A / BUG-B).
|
|
214
|
+
*/
|
|
215
|
+
function rebaseLocalTreeAcrossRenames(tree, renames) {
|
|
216
|
+
// No rename touches this tree (the common single-direction cycle) — return it as-is rather than copying
|
|
217
|
+
// the whole map for nothing. (P1)
|
|
218
|
+
if (renames.length === 0) {
|
|
219
|
+
return tree;
|
|
220
|
+
}
|
|
221
|
+
// Index renames by `from` so each entry locates its nearest renamed ancestor in O(depth), not O(renames).
|
|
222
|
+
const renameByFrom = new Map(renames.map(rename => [rename.from, rename]));
|
|
223
|
+
const newTree = {};
|
|
224
|
+
const newINodes = {};
|
|
225
|
+
for (const path in tree.tree) {
|
|
226
|
+
const item = tree.tree[path];
|
|
227
|
+
const parent = nearestAncestor(path, renameByFrom);
|
|
228
|
+
const rebasedPath = parent ? parent.to + path.slice(parent.from.length) : path;
|
|
229
|
+
const newItem = rebasedPath === path ? item : Object.assign(Object.assign({}, item), { path: rebasedPath });
|
|
230
|
+
newTree[rebasedPath] = newItem;
|
|
231
|
+
newINodes[newItem.inode] = newItem;
|
|
232
|
+
}
|
|
233
|
+
return { tree: newTree, inodes: newINodes, size: tree.size };
|
|
234
|
+
}
|
|
235
|
+
function rebaseRemoteTreeAcrossRenames(tree, renames) {
|
|
236
|
+
if (renames.length === 0) {
|
|
237
|
+
return tree;
|
|
238
|
+
}
|
|
239
|
+
const renameByFrom = new Map(renames.map(rename => [rename.from, rename]));
|
|
240
|
+
const newTree = {};
|
|
241
|
+
const newUUIDs = {};
|
|
242
|
+
for (const path in tree.tree) {
|
|
243
|
+
const item = tree.tree[path];
|
|
244
|
+
const parent = nearestAncestor(path, renameByFrom);
|
|
245
|
+
const rebasedPath = parent ? parent.to + path.slice(parent.from.length) : path;
|
|
246
|
+
const newItem = rebasedPath === path ? item : Object.assign(Object.assign({}, item), { path: rebasedPath });
|
|
247
|
+
newTree[rebasedPath] = newItem;
|
|
248
|
+
newUUIDs[newItem.uuid] = newItem;
|
|
249
|
+
}
|
|
250
|
+
return { tree: newTree, uuids: newUUIDs, size: tree.size };
|
|
251
|
+
}
|
|
5
252
|
/**
|
|
6
253
|
* Deltas
|
|
7
254
|
* @date 3/1/2024 - 11:11:32 PM
|
|
@@ -46,19 +293,28 @@ class Deltas {
|
|
|
46
293
|
* deleteRemoteFileCountRaw: number
|
|
47
294
|
* }>}
|
|
48
295
|
*/
|
|
49
|
-
async process({ currentLocalTree, currentRemoteTree, previousLocalTree, previousRemoteTree, currentLocalTreeErrors }) {
|
|
296
|
+
async process({ currentLocalTree, currentRemoteTree, previousLocalTree, previousRemoteTree, currentLocalTreeErrors, currentLocalTreeIgnored }) {
|
|
297
|
+
// Snapshot the mode ONCE for the whole cycle. process() is async (it awaits hashing), and updateMode()
|
|
298
|
+
// mutates this.sync.mode synchronously from the main thread at any time. Reading this.sync.mode live in
|
|
299
|
+
// each of the ~20 passes below let a mid-cycle mode switch split one cycle across two modes — e.g. the
|
|
300
|
+
// local-deletions pass running as twoWay while the remote-deletions pass runs as cloudToLocal — yielding
|
|
301
|
+
// a self-contradictory delta set. The snapshot is returned so the caller's deletion-confirmation gate
|
|
302
|
+
// evaluates against the SAME mode the deltas were computed under; a mode change takes effect next cycle.
|
|
303
|
+
const mode = this.sync.mode;
|
|
50
304
|
if (this.sync.removed) {
|
|
51
305
|
return {
|
|
52
306
|
deltas: [],
|
|
53
307
|
deleteLocalDirectoryCountRaw: 0,
|
|
54
308
|
deleteLocalFileCountRaw: 0,
|
|
55
309
|
deleteRemoteDirectoryCountRaw: 0,
|
|
56
|
-
deleteRemoteFileCountRaw: 0
|
|
310
|
+
deleteRemoteFileCountRaw: 0,
|
|
311
|
+
mode
|
|
57
312
|
};
|
|
58
313
|
}
|
|
59
314
|
let deltas = [];
|
|
60
315
|
const pathsAdded = {};
|
|
61
316
|
const erroredLocalPaths = {};
|
|
317
|
+
const ignoredLocalPaths = {};
|
|
62
318
|
const renamedRemoteDirectories = [];
|
|
63
319
|
const renamedLocalDirectories = [];
|
|
64
320
|
const deletedRemoteDirectories = [];
|
|
@@ -70,6 +326,15 @@ class Deltas {
|
|
|
70
326
|
for (const error of currentLocalTreeErrors) {
|
|
71
327
|
erroredLocalPaths[error.relativePath] = true;
|
|
72
328
|
}
|
|
329
|
+
// A path that physically exists locally but is currently NOT syncable — a symlink, an
|
|
330
|
+
// unreadable/permission-denied entry, a case-insensitive duplicate, a special device, an
|
|
331
|
+
// over-long name — is recorded as "ignored", never as a deletion. Suppressing remote-deletes for
|
|
332
|
+
// these preserves the cloud copy of a path that was synced before it became ignored (e.g. a file
|
|
333
|
+
// now skipped because it is a symlink after upgrading to lstat), mirroring how the .filenignore
|
|
334
|
+
// filter below already protects ignored paths. (BUG-006)
|
|
335
|
+
for (const ignored of currentLocalTreeIgnored) {
|
|
336
|
+
ignoredLocalPaths[ignored.relativePath] = true;
|
|
337
|
+
}
|
|
73
338
|
// The order of delta processing:
|
|
74
339
|
// 1. Local directory/file move/rename
|
|
75
340
|
// 2. Remote directory/file move/rename
|
|
@@ -78,7 +343,7 @@ class Deltas {
|
|
|
78
343
|
// 5. Local additions/filemodifications
|
|
79
344
|
// 6. Remote additions/filemodifications
|
|
80
345
|
// Local file/directory move/rename
|
|
81
|
-
if (
|
|
346
|
+
if (mode === "twoWay" || mode === "localBackup" || mode === "localToCloud") {
|
|
82
347
|
for (const inode in currentLocalTree.inodes) {
|
|
83
348
|
const currentItem = currentLocalTree.inodes[inode];
|
|
84
349
|
const previousItem = previousLocalTree.inodes[inode];
|
|
@@ -93,10 +358,33 @@ class Deltas {
|
|
|
93
358
|
// Path from current item changed, it was either renamed or moved (same type)
|
|
94
359
|
if (currentItem.path !== previousItem.path &&
|
|
95
360
|
currentItem.type === previousItem.type &&
|
|
361
|
+
// A matching inode is NOT sufficient proof of a rename. The OS recycles inode numbers, and
|
|
362
|
+
// ext4 hands a freed inode straight to the next file created — so "delete a.txt + create
|
|
363
|
+
// c.txt" can land c.txt on a.txt's old inode and be misread as "rename a.txt -> c.txt".
|
|
364
|
+
// That phantom rename propagates as a REMOTE rename and DELETES the original: silent data
|
|
365
|
+
// loss in modes that keep deletions (localBackup), and invisible in twoWay only because the
|
|
366
|
+
// stale path was going to be deleted anyway. A genuine rename preserves the file's
|
|
367
|
+
// creation/birthtime (even a rename+modify only touches mtime/size); a reused inode belongs
|
|
368
|
+
// to a freshly-created file with a newer birthtime. Require both to match. A filesystem that
|
|
369
|
+
// cannot report birthtime reads 0 on both sides — still equal — so this degrades safely to
|
|
370
|
+
// the old inode-only behavior rather than dropping genuine renames. (F8 — inode reuse)
|
|
371
|
+
currentItem.creation === previousItem.creation &&
|
|
96
372
|
// Does an item with the same path and type already exist in the current remote tree (probably moved by something else prior)?
|
|
97
373
|
!(currentRemoteTree.tree[currentItem.path] && currentRemoteTree.tree[currentItem.path].type === currentItem.type) &&
|
|
98
374
|
// Because only comparing strings can be weird sometimes
|
|
99
375
|
Buffer.from(currentItem.path, "utf-8").toString("hex") !== Buffer.from(previousItem.path, "utf-8").toString("hex")) {
|
|
376
|
+
// Only propagate the rename if the REMOTE source is unchanged since the base — the same
|
|
377
|
+
// node (uuid) still sits at the old path. If the other side deleted, modified, or renamed
|
|
378
|
+
// that file/directory in the same window, renaming the stale remote node is invalid: skip
|
|
379
|
+
// it, leave the paths unmarked, and let the renamed item be re-added under its new name
|
|
380
|
+
// while the other side's change is applied by its own pass. The worlds still converge,
|
|
381
|
+
// keeping both edits rather than silently dropping one. (F2–F4)
|
|
382
|
+
const remoteSource = currentRemoteTree.tree[previousItem.path];
|
|
383
|
+
const baseRemoteSource = previousRemoteTree.tree[previousItem.path];
|
|
384
|
+
const remoteSourceUnchanged = !!remoteSource && !!baseRemoteSource && remoteSource.uuid === baseRemoteSource.uuid;
|
|
385
|
+
if (!remoteSourceUnchanged) {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
100
388
|
const delta = {
|
|
101
389
|
type: currentItem.type === "directory" ? "renameRemoteDirectory" : "renameRemoteFile",
|
|
102
390
|
path: currentItem.path,
|
|
@@ -109,11 +397,28 @@ class Deltas {
|
|
|
109
397
|
}
|
|
110
398
|
pathsAdded[currentItem.path] = true;
|
|
111
399
|
pathsAdded[previousItem.path] = true;
|
|
400
|
+
// Rename + in-place content modify of the SAME file in ONE cycle: the rename marks the new
|
|
401
|
+
// path "added", so the modification pass below would skip it and the new bytes would never
|
|
402
|
+
// upload (the remote would keep the OLD content under the new name, forever). Detect the
|
|
403
|
+
// content change here against the base and emit the upload too; phase order runs the rename
|
|
404
|
+
// (phase 4) before the upload (phase 11), so it lands on the renamed remote node. (F1)
|
|
405
|
+
if (currentItem.type === "file") {
|
|
406
|
+
const contentChanged = currentItem.size !== previousItem.size ||
|
|
407
|
+
(0, utils_1.normalizeLastModifiedMsForComparison)(currentItem.lastModified) !==
|
|
408
|
+
(0, utils_1.normalizeLastModifiedMsForComparison)(previousItem.lastModified);
|
|
409
|
+
if (contentChanged) {
|
|
410
|
+
deltas.push({
|
|
411
|
+
type: "uploadFile",
|
|
412
|
+
path: currentItem.path,
|
|
413
|
+
size: currentItem.size
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
112
417
|
}
|
|
113
418
|
}
|
|
114
419
|
}
|
|
115
420
|
// Remote file/directory move/rename
|
|
116
|
-
if (
|
|
421
|
+
if (mode === "twoWay" || mode === "cloudBackup" || mode === "cloudToLocal") {
|
|
117
422
|
for (const uuid in currentRemoteTree.uuids) {
|
|
118
423
|
const currentItem = currentRemoteTree.uuids[uuid];
|
|
119
424
|
const previousItem = previousRemoteTree.uuids[uuid];
|
|
@@ -127,6 +432,24 @@ class Deltas {
|
|
|
127
432
|
!(currentLocalTree.tree[currentItem.path] && currentLocalTree.tree[currentItem.path].type === currentItem.type) &&
|
|
128
433
|
// Because only comparing strings can be weird sometimes
|
|
129
434
|
Buffer.from(currentItem.path, "utf-8").toString("hex") !== Buffer.from(previousItem.path, "utf-8").toString("hex")) {
|
|
435
|
+
// Symmetric to the local rename pass: only propagate the remote rename if the LOCAL source
|
|
436
|
+
// is unchanged since the base — same inode AND (for files) same content. A remote modify
|
|
437
|
+
// mints a new uuid, but a LOCAL modify keeps the inode, so checking the inode alone would
|
|
438
|
+
// let a remote rename fire over a file the user edited in the same window and silently drop
|
|
439
|
+
// that edit. If the local side deleted, modified, or renamed it, skip the rename and let
|
|
440
|
+
// the file be handled by the deletion/addition passes → convergence, keeping both. (F2–F4)
|
|
441
|
+
const localSource = currentLocalTree.tree[previousItem.path];
|
|
442
|
+
const baseLocalSource = previousLocalTree.tree[previousItem.path];
|
|
443
|
+
const localSourceUnchanged = !!localSource &&
|
|
444
|
+
!!baseLocalSource &&
|
|
445
|
+
localSource.inode === baseLocalSource.inode &&
|
|
446
|
+
(currentItem.type === "directory" ||
|
|
447
|
+
(localSource.size === baseLocalSource.size &&
|
|
448
|
+
(0, utils_1.normalizeLastModifiedMsForComparison)(localSource.lastModified) ===
|
|
449
|
+
(0, utils_1.normalizeLastModifiedMsForComparison)(baseLocalSource.lastModified)));
|
|
450
|
+
if (!localSourceUnchanged) {
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
130
453
|
const delta = {
|
|
131
454
|
type: currentItem.type === "directory" ? "renameLocalDirectory" : "renameLocalFile",
|
|
132
455
|
path: currentItem.path,
|
|
@@ -142,11 +465,36 @@ class Deltas {
|
|
|
142
465
|
}
|
|
143
466
|
}
|
|
144
467
|
}
|
|
468
|
+
// Rename-aware rebase (BUG-A / BUG-B): a propagated directory rename relocates its ENTIRE subtree, but
|
|
469
|
+
// every per-descendant pass below compares the current tree against the base BY PATH. A child changed
|
|
470
|
+
// on the OTHER side still sits at the pre-rename path, so without realigning it is mis-attributed — the
|
|
471
|
+
// rename moves it to the new path while a stale same-path upload/download/delete clobbers the change
|
|
472
|
+
// (silent data loss, BUG-A) or resurrects a deleted child (BUG-B). Model the post-rename state: for
|
|
473
|
+
// every propagated dir rename F->T, remap the affected subtree to T in the BASE (both sides) and in the
|
|
474
|
+
// OTHER side's CURRENT tree (the emitted rename physically moves it there; the child transfer runs
|
|
475
|
+
// AFTER the rename in phase order). The renamed side's current tree already sits at T, and the directory
|
|
476
|
+
// nodes themselves are handled via pathsAdded, so only descendants are remapped. Skipped entirely when
|
|
477
|
+
// no directory rename happened, so the common case pays nothing. The originals are not mutated — these
|
|
478
|
+
// computation-only copies never feed the persisted base (advanced from the task results), they only
|
|
479
|
+
// correct THIS cycle's delta attribution.
|
|
480
|
+
if (renamedRemoteDirectories.length > 0 || renamedLocalDirectories.length > 0) {
|
|
481
|
+
// renameRemoteDirectory deltas come from LOCAL-originated dir renames; renameLocalDirectory deltas
|
|
482
|
+
// from REMOTE-originated ones.
|
|
483
|
+
const localOriginatedRenames = renamedRemoteDirectories.flatMap(delta => delta.type === "renameRemoteDirectory" ? [{ from: delta.from, to: delta.to }] : []);
|
|
484
|
+
const remoteOriginatedRenames = renamedLocalDirectories.flatMap(delta => delta.type === "renameLocalDirectory" ? [{ from: delta.from, to: delta.to }] : []);
|
|
485
|
+
const allDirRenames = [...localOriginatedRenames, ...remoteOriginatedRenames];
|
|
486
|
+
// The base follows BOTH sides' renames; each side's CURRENT tree follows only the OTHER side's
|
|
487
|
+
// rename (its own rename already moved its current tree to the new path).
|
|
488
|
+
previousLocalTree = rebaseLocalTreeAcrossRenames(previousLocalTree, allDirRenames);
|
|
489
|
+
previousRemoteTree = rebaseRemoteTreeAcrossRenames(previousRemoteTree, allDirRenames);
|
|
490
|
+
currentRemoteTree = rebaseRemoteTreeAcrossRenames(currentRemoteTree, localOriginatedRenames);
|
|
491
|
+
currentLocalTree = rebaseLocalTreeAcrossRenames(currentLocalTree, remoteOriginatedRenames);
|
|
492
|
+
}
|
|
145
493
|
// Local deletions
|
|
146
|
-
if (
|
|
147
|
-
if (
|
|
494
|
+
if (mode === "twoWay" || mode === "localToCloud") {
|
|
495
|
+
if (mode === "twoWay") {
|
|
148
496
|
for (const path in previousLocalTree.tree) {
|
|
149
|
-
if (pathsAdded[path] || erroredLocalPaths[path]) {
|
|
497
|
+
if (pathsAdded[path] || erroredLocalPaths[path] || ignoredLocalPaths[path]) {
|
|
150
498
|
continue;
|
|
151
499
|
}
|
|
152
500
|
const previousLocalItem = previousLocalTree.tree[path];
|
|
@@ -155,8 +503,23 @@ class Deltas {
|
|
|
155
503
|
// We also check if the previous inode does not exist in the current tree, and if so, we skip it (only in cloud -> local modes. It should always be deleted in local -> cloud modes if it exists remotely).
|
|
156
504
|
if (!currentLocalItem &&
|
|
157
505
|
previousLocalItem //&&
|
|
158
|
-
//(
|
|
506
|
+
//(mode !== "localToCloud" ? !currentLocalTree.inodes[previousLocalItem.inode] : true)
|
|
159
507
|
) {
|
|
508
|
+
// Symmetric to the remote-deletions resurrect (OBS-001): the local copy was deleted, but
|
|
509
|
+
// if the REMOTE file was modified since the base — a new uuid, i.e. a real re-upload — the
|
|
510
|
+
// newer modification wins over the delete. Skip the delete and leave the path unmarked so
|
|
511
|
+
// the remote-additions pass downloads (resurrects) it locally. A newer modify always beats
|
|
512
|
+
// a delete, in either direction. (F7)
|
|
513
|
+
if (previousLocalItem.type === "file") {
|
|
514
|
+
const previousRemoteItem = previousRemoteTree.tree[path];
|
|
515
|
+
const currentRemoteItem = currentRemoteTree.tree[path];
|
|
516
|
+
if (currentRemoteItem &&
|
|
517
|
+
currentRemoteItem.type === "file" &&
|
|
518
|
+
previousRemoteItem &&
|
|
519
|
+
currentRemoteItem.uuid !== previousRemoteItem.uuid) {
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
160
523
|
const delta = {
|
|
161
524
|
type: previousLocalItem.type === "directory" ? "deleteRemoteDirectory" : "deleteRemoteFile",
|
|
162
525
|
path
|
|
@@ -176,7 +539,7 @@ class Deltas {
|
|
|
176
539
|
}
|
|
177
540
|
else {
|
|
178
541
|
for (const path in currentRemoteTree.tree) {
|
|
179
|
-
if (pathsAdded[path] || erroredLocalPaths[path]) {
|
|
542
|
+
if (pathsAdded[path] || erroredLocalPaths[path] || ignoredLocalPaths[path]) {
|
|
180
543
|
continue;
|
|
181
544
|
}
|
|
182
545
|
const currentLocalItem = currentLocalTree.tree[path];
|
|
@@ -201,10 +564,17 @@ class Deltas {
|
|
|
201
564
|
}
|
|
202
565
|
}
|
|
203
566
|
// Remote deletions
|
|
204
|
-
if (
|
|
205
|
-
if (
|
|
567
|
+
if (mode === "twoWay" || mode === "cloudToLocal") {
|
|
568
|
+
if (mode === "twoWay") {
|
|
206
569
|
for (const path in previousRemoteTree.tree) {
|
|
207
|
-
|
|
570
|
+
// Symmetric to the local-deletions guard (BUG-006): never delete the LOCAL copy of a path the
|
|
571
|
+
// local scan ignored or errored on. Such a path is physically present on disk but absent from
|
|
572
|
+
// the scanned tree (an over-long name, an invalid path, a default-ignore that grew across an
|
|
573
|
+
// upgrade, a case-duplicate); when its REMOTE copy is deleted, propagating that delete would
|
|
574
|
+
// wipe a file the user never asked to sync. The end-of-process ignore filter does not catch
|
|
575
|
+
// these — it only knows the .filenignore matcher, not the nameLength/pathLength/invalidPath/
|
|
576
|
+
// defaultIgnore/duplicate reasons carried in ignoredLocalPaths. (M4)
|
|
577
|
+
if (pathsAdded[path] || erroredLocalPaths[path] || ignoredLocalPaths[path]) {
|
|
208
578
|
continue;
|
|
209
579
|
}
|
|
210
580
|
const previousRemoteItem = previousRemoteTree.tree[path];
|
|
@@ -213,8 +583,44 @@ class Deltas {
|
|
|
213
583
|
// We also check if the previous UUID does not exist in the current tree, and if so, we skip it (only in local -> cloud modes. It should always be deleted in cloud -> local modes if it exists locally).
|
|
214
584
|
if (!currentRemoteItem &&
|
|
215
585
|
previousRemoteItem //&&
|
|
216
|
-
//(
|
|
586
|
+
//(mode !== "cloudToLocal" ? !currentRemoteTree.uuids[previousRemoteItem.uuid] : true)
|
|
217
587
|
) {
|
|
588
|
+
// E2E-OBS-001 (newer-modify-wins): the remote deleted this path, but if the local FILE
|
|
589
|
+
// was modified since the last sync the modification wins — skip the delete and leave the
|
|
590
|
+
// path unmarked so the local-additions pass re-uploads (resurrects) it remotely. "Modified"
|
|
591
|
+
// requires a real CONTENT change: a size difference, or (same size, mtime moved) a cached
|
|
592
|
+
// upload hash that no longer matches. A bare mtime touch — or a same-size change we cannot
|
|
593
|
+
// confirm because no hash was stored — is NOT a modification, so the delete proceeds. The
|
|
594
|
+
// hash is an optional signal (older files carry none).
|
|
595
|
+
if (previousRemoteItem.type === "file") {
|
|
596
|
+
const previousLocalItem = previousLocalTree.tree[path];
|
|
597
|
+
const currentLocalItem = currentLocalTree.tree[path];
|
|
598
|
+
if (currentLocalItem && currentLocalItem.type === "file" && previousLocalItem) {
|
|
599
|
+
let localContentChanged = currentLocalItem.size !== previousLocalItem.size;
|
|
600
|
+
if (!localContentChanged &&
|
|
601
|
+
(0, utils_1.normalizeLastModifiedMsForComparison)(currentLocalItem.lastModified) !==
|
|
602
|
+
(0, utils_1.normalizeLastModifiedMsForComparison)(previousLocalItem.lastModified)) {
|
|
603
|
+
const cachedHash = this.sync.localFileHashes[currentLocalItem.path];
|
|
604
|
+
if (cachedHash) {
|
|
605
|
+
try {
|
|
606
|
+
const currentHash = await this.sync.localFileSystem.createFileHash({
|
|
607
|
+
relativePath: path,
|
|
608
|
+
algorithm: "md5"
|
|
609
|
+
});
|
|
610
|
+
localContentChanged = currentHash !== cachedHash;
|
|
611
|
+
}
|
|
612
|
+
catch (_a) {
|
|
613
|
+
// The file is mid-rename (a pending dir rename will move it to `path`); we cannot
|
|
614
|
+
// confirm a same-size content change without reading it, so leave it as "not
|
|
615
|
+
// modified" — the hash-optional policy already lets such a delete proceed.
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (localContentChanged) {
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
218
624
|
const delta = {
|
|
219
625
|
type: previousRemoteItem.type === "directory" ? "deleteLocalDirectory" : "deleteLocalFile",
|
|
220
626
|
path
|
|
@@ -258,8 +664,135 @@ class Deltas {
|
|
|
258
664
|
}
|
|
259
665
|
}
|
|
260
666
|
}
|
|
667
|
+
// Don't cascade a directory deletion over live content the OTHER side did not delete. When one side
|
|
668
|
+
// removes a directory while this side adds (or keeps a modified) child inside it, drop the directory
|
|
669
|
+
// delete so the surviving child's own add re-creates the directory — the genuinely-deleted siblings
|
|
670
|
+
// still delete individually. Without this the dir-delete subsumes the child at execution and the
|
|
671
|
+
// brand-new file is lost (newer content must beat a delete, symmetric to the per-file passes). (H5)
|
|
672
|
+
const localDirsToKeep = directoriesWithSurvivingChildren(deltas, "deleteLocalDirectory", "deleteLocalFile", "renameLocalDirectory", "renameLocalFile", currentLocalTree.tree);
|
|
673
|
+
const remoteDirsToKeep = directoriesWithSurvivingChildren(deltas, "deleteRemoteDirectory", "deleteRemoteFile", "renameRemoteDirectory", "renameRemoteFile", currentRemoteTree.tree);
|
|
674
|
+
if (localDirsToKeep.size > 0 || remoteDirsToKeep.size > 0) {
|
|
675
|
+
deltas = deltas.filter(delta => {
|
|
676
|
+
if (delta.type === "deleteLocalDirectory" && localDirsToKeep.has(delta.path)) {
|
|
677
|
+
deleteLocalDirectoryCountRaw -= 1;
|
|
678
|
+
// Un-mark so the additions pass re-creates the surviving directory on the deleting side.
|
|
679
|
+
delete pathsAdded[delta.path];
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
if (delta.type === "deleteRemoteDirectory" && remoteDirsToKeep.has(delta.path)) {
|
|
683
|
+
deleteRemoteDirectoryCountRaw -= 1;
|
|
684
|
+
delete pathsAdded[delta.path];
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
return true;
|
|
688
|
+
});
|
|
689
|
+
// Keep the bookkeeping arrays consistent — collapseDeltas uses them to subsume descendant
|
|
690
|
+
// deletes, and a kept directory must no longer subsume its (legitimately deleted) children.
|
|
691
|
+
for (let i = deletedLocalDirectories.length - 1; i >= 0; i--) {
|
|
692
|
+
if (localDirsToKeep.has(deletedLocalDirectories[i].path)) {
|
|
693
|
+
deletedLocalDirectories.splice(i, 1);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
for (let i = deletedRemoteDirectories.length - 1; i >= 0; i--) {
|
|
697
|
+
if (remoteDirsToKeep.has(deletedRemoteDirectories[i].path)) {
|
|
698
|
+
deletedRemoteDirectories.splice(i, 1);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
// Type change at a path (file <-> directory). The path exists in BOTH current trees but as
|
|
703
|
+
// different types, so it slips through every other pass: the rename passes need a matching
|
|
704
|
+
// inode/uuid (a type change has neither), the deletion passes need the path ABSENT on the other
|
|
705
|
+
// side (here it is present, only a different type), and the addition passes skip a path whose
|
|
706
|
+
// other-side item already exists. Left unhandled, the stale-type item lingers and its replacement
|
|
707
|
+
// cannot be created over it (E2E-BUG-001).
|
|
708
|
+
//
|
|
709
|
+
// We attribute the change against the last-synced base: whichever side's type differs from the
|
|
710
|
+
// base is the one that changed. In twoWay, if both changed (or neither is in the base) local wins,
|
|
711
|
+
// matching the tie policy used elsewhere; directional modes force the authoritative side. The
|
|
712
|
+
// authoritative side's new item is created and the other side's stale item is deleted — the
|
|
713
|
+
// phase-ordered task runner always runs deletes before creates, so the delete lands first. When
|
|
714
|
+
// the deleted (stale) side is a directory, its descendants are marked "added" so the addition
|
|
715
|
+
// passes don't try to sync now-obsolete children; the recursive delete removes them (and any
|
|
716
|
+
// child-delete deltas already emitted collapse into the parent). The surviving side's descendants
|
|
717
|
+
// stay unmarked and sync normally.
|
|
718
|
+
{
|
|
719
|
+
const canWriteRemote = mode === "twoWay" || mode === "localBackup" || mode === "localToCloud";
|
|
720
|
+
const canWriteLocal = mode === "twoWay" || mode === "cloudBackup" || mode === "cloudToLocal";
|
|
721
|
+
const markSubtreeAdded = (treePaths, parentPath) => {
|
|
722
|
+
const prefix = `${parentPath}/`;
|
|
723
|
+
for (const descendantPath in treePaths) {
|
|
724
|
+
if (descendantPath.startsWith(prefix)) {
|
|
725
|
+
pathsAdded[descendantPath] = true;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
for (const path in currentLocalTree.tree) {
|
|
730
|
+
if (pathsAdded[path] || erroredLocalPaths[path] || ignoredLocalPaths[path]) {
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
const currentLocalItem = currentLocalTree.tree[path];
|
|
734
|
+
const currentRemoteItem = currentRemoteTree.tree[path];
|
|
735
|
+
if (!currentLocalItem || !currentRemoteItem || currentLocalItem.type === currentRemoteItem.type) {
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
// Both sides have this path, with different types. Attribute the change against the base.
|
|
739
|
+
const previousLocalItem = previousLocalTree.tree[path];
|
|
740
|
+
const previousRemoteItem = previousRemoteTree.tree[path];
|
|
741
|
+
const localChangedType = !previousLocalItem || previousLocalItem.type !== currentLocalItem.type;
|
|
742
|
+
const remoteChangedType = !previousRemoteItem || previousRemoteItem.type !== currentRemoteItem.type;
|
|
743
|
+
let localWins;
|
|
744
|
+
if (mode === "localBackup" || mode === "localToCloud") {
|
|
745
|
+
localWins = true;
|
|
746
|
+
}
|
|
747
|
+
else if (mode === "cloudBackup" || mode === "cloudToLocal") {
|
|
748
|
+
localWins = false;
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
// twoWay: whoever's type diverged from base wins; local wins a tie / a both-changed conflict.
|
|
752
|
+
localWins = localChangedType || !remoteChangedType;
|
|
753
|
+
}
|
|
754
|
+
if (localWins && canWriteRemote) {
|
|
755
|
+
const deleteDelta = {
|
|
756
|
+
type: currentRemoteItem.type === "directory" ? "deleteRemoteDirectory" : "deleteRemoteFile",
|
|
757
|
+
path
|
|
758
|
+
};
|
|
759
|
+
deltas.push(deleteDelta);
|
|
760
|
+
if (currentRemoteItem.type === "directory") {
|
|
761
|
+
deletedRemoteDirectories.push(deleteDelta);
|
|
762
|
+
deleteRemoteDirectoryCountRaw += 1;
|
|
763
|
+
markSubtreeAdded(currentRemoteTree.tree, path);
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
deleteRemoteFileCountRaw += 1;
|
|
767
|
+
}
|
|
768
|
+
deltas.push(currentLocalItem.type === "directory"
|
|
769
|
+
? { type: "createRemoteDirectory", path }
|
|
770
|
+
: { type: "uploadFile", path, size: currentLocalItem.size });
|
|
771
|
+
pathsAdded[path] = true;
|
|
772
|
+
}
|
|
773
|
+
else if (!localWins && canWriteLocal) {
|
|
774
|
+
const deleteDelta = {
|
|
775
|
+
type: currentLocalItem.type === "directory" ? "deleteLocalDirectory" : "deleteLocalFile",
|
|
776
|
+
path
|
|
777
|
+
};
|
|
778
|
+
deltas.push(deleteDelta);
|
|
779
|
+
if (currentLocalItem.type === "directory") {
|
|
780
|
+
deletedLocalDirectories.push(deleteDelta);
|
|
781
|
+
deleteLocalDirectoryCountRaw += 1;
|
|
782
|
+
markSubtreeAdded(currentLocalTree.tree, path);
|
|
783
|
+
}
|
|
784
|
+
else {
|
|
785
|
+
deleteLocalFileCountRaw += 1;
|
|
786
|
+
}
|
|
787
|
+
deltas.push(currentRemoteItem.type === "directory"
|
|
788
|
+
? { type: "createLocalDirectory", path }
|
|
789
|
+
: { type: "downloadFile", path, size: currentRemoteItem.size });
|
|
790
|
+
pathsAdded[path] = true;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
261
794
|
// Local additions/fileModifications
|
|
262
|
-
if (
|
|
795
|
+
if (mode === "twoWay" || mode === "localBackup" || mode === "localToCloud") {
|
|
263
796
|
for (const path in currentLocalTree.tree) {
|
|
264
797
|
if (pathsAdded[path] || erroredLocalPaths[path]) {
|
|
265
798
|
continue;
|
|
@@ -270,7 +803,7 @@ class Deltas {
|
|
|
270
803
|
// We also check if it in fact has existed before (the inode), if so, we skip it (only in cloud -> local modes. It should always be uploaded in local -> cloud modes if it does not exist remotely).
|
|
271
804
|
if (!currentRemoteItem &&
|
|
272
805
|
currentLocalItem &&
|
|
273
|
-
//(
|
|
806
|
+
//(mode !== "localBackup" && mode !== "localToCloud"
|
|
274
807
|
// ? !previousLocalTree.inodes[currentLocalItem.inode]
|
|
275
808
|
// : true) &&
|
|
276
809
|
// Does an item with the same path and type already exist in the current remote tree (probably uploaded by something else prior)?
|
|
@@ -283,31 +816,78 @@ class Deltas {
|
|
|
283
816
|
pathsAdded[path] = true;
|
|
284
817
|
continue;
|
|
285
818
|
}
|
|
286
|
-
// If the item exists in both trees and
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
819
|
+
// If the item exists in both trees and the local copy changed since the last sync, upload it.
|
|
820
|
+
// The change is attributed against the last-synced BASE (previousLocalTree) by size + whole-
|
|
821
|
+
// second mtime — not by comparing the two current sides — so an edit that lands in the same
|
|
822
|
+
// whole-second as the base mtime, or that only changes the size (e.g. 0 -> N bytes), is still
|
|
823
|
+
// detected (E2E-OBS-002). The remote side's change is detected by a new uuid (every re-upload
|
|
824
|
+
// mints one). On a both-changed conflict the newer mtime wins; an unorderable same-second tie
|
|
825
|
+
// resolves to local because this pass runs before the download pass and marks the path added.
|
|
826
|
+
// The md5 comparison stays as an OPTIONAL dedup (so a pure mtime touch with identical bytes is
|
|
827
|
+
// not re-uploaded) — never a required signal, since older files carry no stored hash.
|
|
828
|
+
if (currentRemoteItem && currentRemoteItem.type === "file" && currentLocalItem && currentLocalItem.type === "file") {
|
|
829
|
+
const previousLocalItem = previousLocalTree.tree[path];
|
|
830
|
+
const previousRemoteItem = previousRemoteTree.tree[path];
|
|
831
|
+
// With a persisted base, attribute the change against it (size + whole-second mtime). With
|
|
832
|
+
// NO base — a genuine first sync, or lost/corrupt state — there is no common ancestor, so
|
|
833
|
+
// the remote copy is the only reference: fall back to the side-vs-side comparison and treat
|
|
834
|
+
// the file as changed only when the local copy is strictly newer (otherwise identical
|
|
835
|
+
// content on both sides would be needlessly re-uploaded). OBS-002's same-second case
|
|
836
|
+
// requires a base, so this fallback cannot reintroduce it.
|
|
837
|
+
const localChanged = previousLocalItem
|
|
838
|
+
? previousLocalItem.size !== currentLocalItem.size ||
|
|
839
|
+
(0, utils_1.normalizeLastModifiedMsForComparison)(previousLocalItem.lastModified) !==
|
|
840
|
+
(0, utils_1.normalizeLastModifiedMsForComparison)(currentLocalItem.lastModified)
|
|
841
|
+
: (0, utils_1.normalizeLastModifiedMsForComparison)(currentLocalItem.lastModified) >
|
|
842
|
+
(0, utils_1.normalizeLastModifiedMsForComparison)(currentRemoteItem.lastModified);
|
|
843
|
+
const remoteChanged = !previousRemoteItem || currentRemoteItem.uuid !== previousRemoteItem.uuid;
|
|
844
|
+
// Directional push modes (localToCloud / localBackup): the LOCAL side is authoritative, so a
|
|
845
|
+
// local change ALWAYS wins. The newer-mtime tiebreak is twoWay conflict resolution and must
|
|
846
|
+
// not let a foreign remote edit (with a newer mtime) suppress the push. (F5)
|
|
847
|
+
const directionalPush = mode === "localToCloud" || mode === "localBackup";
|
|
848
|
+
const localWins = directionalPush ||
|
|
849
|
+
!remoteChanged ||
|
|
850
|
+
(0, utils_1.normalizeLastModifiedMsForComparison)(currentLocalItem.lastModified) >=
|
|
851
|
+
(0, utils_1.normalizeLastModifiedMsForComparison)(currentRemoteItem.lastModified);
|
|
852
|
+
// Strict mirror (localToCloud only): the remote MUST equal local. If the remote was edited
|
|
853
|
+
// away from what we last pushed (a new uuid) — even when the local file itself is unchanged —
|
|
854
|
+
// re-assert the local copy to revert that foreign edit, bypassing the md5 dedup below (local
|
|
855
|
+
// bytes still equal the last upload, so the dedup would otherwise skip). localBackup is
|
|
856
|
+
// additive and deliberately tolerates foreign edits, so it is excluded. (F6)
|
|
857
|
+
const mirrorRevert = mode === "localToCloud" && remoteChanged;
|
|
858
|
+
// Directional push with NO remote base: a stray remote file occupies a path local also has.
|
|
859
|
+
// It cannot be the synced copy if the SIZES differ, so the authoritative local wins — push it
|
|
860
|
+
// up over the stray. (F9, symmetric to the pull side.)
|
|
861
|
+
const noBaseSizeDiverged = directionalPush && !previousRemoteItem && currentLocalItem.size !== currentRemoteItem.size;
|
|
862
|
+
if ((localChanged || mirrorRevert || noBaseSizeDiverged) && localWins) {
|
|
863
|
+
// The md5 is an OPTIONAL dedup. When a pending directory rename will move this file to
|
|
864
|
+
// `path` (a rebased descendant — BUG-A/BUG-B), the file is not at `path` yet at delta time,
|
|
865
|
+
// so the read throws; we already KNOW it changed (size/mtime vs base), so emit the upload
|
|
866
|
+
// and let the upload task hash the moved file. A same-content touch is still deduped when
|
|
867
|
+
// the hash IS readable.
|
|
868
|
+
let md5Hash;
|
|
869
|
+
try {
|
|
870
|
+
md5Hash = await this.sync.localFileSystem.createFileHash({
|
|
871
|
+
relativePath: path,
|
|
872
|
+
algorithm: "md5"
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
catch (_b) {
|
|
876
|
+
md5Hash = undefined;
|
|
877
|
+
}
|
|
878
|
+
if (mirrorRevert ||
|
|
879
|
+
noBaseSizeDiverged ||
|
|
880
|
+
md5Hash === undefined ||
|
|
881
|
+
md5Hash !== this.sync.localFileHashes[currentLocalItem.path]) {
|
|
882
|
+
deltas.push(Object.assign({ type: "uploadFile", path, size: currentLocalItem.size }, (md5Hash !== undefined ? { md5Hash } : {})));
|
|
883
|
+
pathsAdded[path] = true;
|
|
884
|
+
}
|
|
305
885
|
}
|
|
306
886
|
}
|
|
307
887
|
}
|
|
308
888
|
}
|
|
309
889
|
// Remote additions/changes
|
|
310
|
-
if (
|
|
890
|
+
if (mode === "twoWay" || mode === "cloudBackup" || mode === "cloudToLocal") {
|
|
311
891
|
for (const path in currentRemoteTree.tree) {
|
|
312
892
|
if (pathsAdded[path]) {
|
|
313
893
|
continue;
|
|
@@ -318,7 +898,7 @@ class Deltas {
|
|
|
318
898
|
// We also check if it in fact has existed before (the UUID), if so, we skip it (only in local -> cloud modes. It should always be downloaded in cloud -> local modes if it does not exist locally).
|
|
319
899
|
if (!currentLocalItem &&
|
|
320
900
|
currentRemoteItem &&
|
|
321
|
-
//(
|
|
901
|
+
//(mode !== "cloudBackup" && mode !== "cloudToLocal"
|
|
322
902
|
// ? !previousRemoteTree.uuids[currentRemoteItem.uuid]
|
|
323
903
|
// : true) &&
|
|
324
904
|
// Does an item with the same path and type already exist in the current local tree (probably downloaded by something else prior)?
|
|
@@ -332,27 +912,68 @@ class Deltas {
|
|
|
332
912
|
continue;
|
|
333
913
|
}
|
|
334
914
|
const previousRemoteItem = previousRemoteTree.tree[path];
|
|
335
|
-
// If the item exists in both trees and the
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
915
|
+
// If the item exists in both trees and the remote copy changed since the base, download it.
|
|
916
|
+
// This MIRRORS the local-additions modify branch. With a base the remote changed iff its uuid
|
|
917
|
+
// changed (every re-upload mints one); with NO base — a fresh add-vs-add, or lost/corrupt state
|
|
918
|
+
// — fall back to side-vs-side and treat it as changed only when the remote is strictly newer
|
|
919
|
+
// (otherwise identical content on both sides would be needlessly downloaded). The local pass ran
|
|
920
|
+
// first and claimed the path (pathsAdded) when local won; reaching here means the remote wins.
|
|
921
|
+
// Without this no-base fallback an add-vs-add where the REMOTE is newer never converged. (F8)
|
|
922
|
+
if (currentRemoteItem && currentRemoteItem.type === "file" && currentLocalItem && currentLocalItem.type === "file") {
|
|
923
|
+
const previousLocalItem = previousLocalTree.tree[path];
|
|
924
|
+
const remoteChanged = previousRemoteItem
|
|
925
|
+
? currentRemoteItem.uuid !== previousRemoteItem.uuid
|
|
926
|
+
: (0, utils_1.normalizeLastModifiedMsForComparison)(currentRemoteItem.lastModified) >
|
|
927
|
+
(0, utils_1.normalizeLastModifiedMsForComparison)(currentLocalItem.lastModified);
|
|
928
|
+
// Directional pull modes (cloudToLocal / cloudBackup): the REMOTE side is authoritative, so a
|
|
929
|
+
// remote change ALWAYS wins; the newer-mtime tiebreak is twoWay-only and must not let a
|
|
930
|
+
// foreign local edit (with a newer mtime) suppress the pull. (F5)
|
|
931
|
+
const directionalPull = mode === "cloudToLocal" || mode === "cloudBackup";
|
|
932
|
+
// The local copy is unchanged since the base (size + whole-second mtime both match). Then a
|
|
933
|
+
// remote change is NOT a conflict — there is nothing local to lose — so it must win outright.
|
|
934
|
+
// Mirrors the local pass, which already declines to claim an unchanged-local path. Without
|
|
935
|
+
// this, a genuine remote edit (new uuid) whose mtime was not strictly newer than the local
|
|
936
|
+
// mtime — equal whole-second, or an out-of-sync editing clock — was dropped and never pulled.
|
|
937
|
+
const localUnchanged = !!previousLocalItem &&
|
|
938
|
+
previousLocalItem.size === currentLocalItem.size &&
|
|
939
|
+
(0, utils_1.normalizeLastModifiedMsForComparison)(previousLocalItem.lastModified) ===
|
|
940
|
+
(0, utils_1.normalizeLastModifiedMsForComparison)(currentLocalItem.lastModified);
|
|
941
|
+
// The newer-mtime tiebreak (last term) stays the CONFLICT resolver: it only decides the case
|
|
942
|
+
// where the local copy ALSO changed. The local pass ran first and claimed the path when local
|
|
943
|
+
// won that conflict, so reaching here with a changed local means the remote is the newer side.
|
|
944
|
+
const remoteWins = directionalPull ||
|
|
945
|
+
!previousLocalItem ||
|
|
946
|
+
localUnchanged ||
|
|
947
|
+
(0, utils_1.normalizeLastModifiedMsForComparison)(currentRemoteItem.lastModified) >
|
|
948
|
+
(0, utils_1.normalizeLastModifiedMsForComparison)(currentLocalItem.lastModified);
|
|
949
|
+
// Strict mirror (cloudToLocal only): local MUST equal remote. If the local copy was edited
|
|
950
|
+
// away from what we last pulled (size or whole-second mtime differs from the base) — even when
|
|
951
|
+
// the remote is unchanged — re-download to revert that foreign local edit. cloudBackup is
|
|
952
|
+
// additive and deliberately tolerates local edits, so it is excluded. (F6)
|
|
953
|
+
const localDiverged = !!previousLocalItem &&
|
|
954
|
+
(previousLocalItem.size !== currentLocalItem.size ||
|
|
955
|
+
(0, utils_1.normalizeLastModifiedMsForComparison)(previousLocalItem.lastModified) !==
|
|
956
|
+
(0, utils_1.normalizeLastModifiedMsForComparison)(currentLocalItem.lastModified));
|
|
957
|
+
const mirrorRevert = mode === "cloudToLocal" && localDiverged;
|
|
958
|
+
// Directional pull with NO local base: a stray local file occupies a path the remote also has
|
|
959
|
+
// (e.g. the local copy was deleted then re-created with different content). It cannot be the
|
|
960
|
+
// synced copy if the SIZES differ, so the authoritative remote wins — pull it down. (F9)
|
|
961
|
+
const noBaseSizeDiverged = directionalPull && !previousLocalItem && currentLocalItem.size !== currentRemoteItem.size;
|
|
962
|
+
if ((remoteChanged || mirrorRevert || noBaseSizeDiverged) && remoteWins) {
|
|
963
|
+
deltas.push({
|
|
964
|
+
type: "downloadFile",
|
|
965
|
+
path,
|
|
966
|
+
size: currentRemoteItem.size
|
|
967
|
+
});
|
|
968
|
+
pathsAdded[path] = true;
|
|
969
|
+
}
|
|
349
970
|
}
|
|
350
971
|
}
|
|
351
972
|
}
|
|
352
|
-
//
|
|
973
|
+
// Filter out ignored paths. Ordering is deferred to the single sort on the collapsed result below:
|
|
974
|
+
// sorting here would order the larger PRE-collapse set, and collapseDeltas never depends on its input
|
|
975
|
+
// order (it indexes the renamed/deleted directories up front), so one sort on the final set suffices.
|
|
353
976
|
deltas = deltas
|
|
354
|
-
.sort((a, b) => a.path.split("/").length - b.path.split("/").length)
|
|
355
|
-
// Filter by ignored paths
|
|
356
977
|
.filter(delta => {
|
|
357
978
|
const trailingSlash = delta.type === "renameLocalDirectory" ||
|
|
358
979
|
delta.type === "createLocalDirectory" ||
|
|
@@ -389,65 +1010,29 @@ class Deltas {
|
|
|
389
1010
|
// This is pretty unnecessary, hence we filter them here.
|
|
390
1011
|
// Same for deletions. We only ever need to rename/move/delete the parent directory if the children did not change.
|
|
391
1012
|
// This saves a lot of disk usage and API requests. This also saves time applying all done tasks to the overall state,
|
|
392
|
-
// since we need to loop through less doneTasks.
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
else if (delta.type === "renameRemoteDirectory" || delta.type === "renameRemoteFile") {
|
|
411
|
-
for (const directoryDelta of renamedRemoteDirectories) {
|
|
412
|
-
if (directoryDelta.type === "renameRemoteDirectory" && delta.from.startsWith(directoryDelta.from + "/")) {
|
|
413
|
-
const newFromPath = (0, utils_1.replacePathStartWithFromAndTo)(delta.from, directoryDelta.from, directoryDelta.to);
|
|
414
|
-
if (newFromPath === delta.to) {
|
|
415
|
-
deltas.splice(i, 1);
|
|
416
|
-
moveUp = true;
|
|
417
|
-
}
|
|
418
|
-
else {
|
|
419
|
-
deltas.splice(i, 1, Object.assign(Object.assign({}, delta), { from: newFromPath }));
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
else if (delta.type === "deleteLocalDirectory" || delta.type === "deleteLocalFile") {
|
|
425
|
-
for (const directoryDelta of deletedLocalDirectories) {
|
|
426
|
-
if (directoryDelta.type === "deleteLocalDirectory" && delta.path.startsWith(directoryDelta.path + "/")) {
|
|
427
|
-
deltas.splice(i, 1);
|
|
428
|
-
moveUp = true;
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
else if (delta.type === "deleteRemoteDirectory" || delta.type === "deleteRemoteFile") {
|
|
433
|
-
for (const directoryDelta of deletedRemoteDirectories) {
|
|
434
|
-
if (directoryDelta.type === "deleteRemoteDirectory" && delta.path.startsWith(directoryDelta.path + "/")) {
|
|
435
|
-
deltas.splice(i, 1);
|
|
436
|
-
moveUp = true;
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
if (moveUp) {
|
|
441
|
-
i--;
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
// Work on deltas from "left to right" (ascending order, path length).
|
|
1013
|
+
// since we need to loop through less doneTasks. See collapseDeltas for the correctness properties.
|
|
1014
|
+
const collapsedDeltas = collapseDeltas({
|
|
1015
|
+
deltas,
|
|
1016
|
+
renamedLocalDirectories,
|
|
1017
|
+
renamedRemoteDirectories,
|
|
1018
|
+
deletedLocalDirectories,
|
|
1019
|
+
deletedRemoteDirectories
|
|
1020
|
+
});
|
|
1021
|
+
// Order the collapsed deltas "left to right" (ascending path depth) so the executor creates/renames
|
|
1022
|
+
// parents before children. Each delta's depth is computed ONCE here — a path.split inside the comparator
|
|
1023
|
+
// is otherwise recomputed on every comparison (O(n log n) splits) — and only the already-collapsed
|
|
1024
|
+
// (smallest) set is sorted.
|
|
1025
|
+
const sortedDeltas = collapsedDeltas
|
|
1026
|
+
.map(delta => [delta.path.split("/").length, delta])
|
|
1027
|
+
.sort((a, b) => a[0] - b[0])
|
|
1028
|
+
.map(entry => entry[1]);
|
|
445
1029
|
return {
|
|
446
|
-
deltas:
|
|
1030
|
+
deltas: sortedDeltas,
|
|
447
1031
|
deleteLocalDirectoryCountRaw,
|
|
448
1032
|
deleteLocalFileCountRaw,
|
|
449
1033
|
deleteRemoteDirectoryCountRaw,
|
|
450
|
-
deleteRemoteFileCountRaw
|
|
1034
|
+
deleteRemoteFileCountRaw,
|
|
1035
|
+
mode
|
|
451
1036
|
};
|
|
452
1037
|
}
|
|
453
1038
|
}
|