dsh-remote 0.8.13 → 0.8.15

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/lib/sync.js CHANGED
@@ -1,270 +1,270 @@
1
- // dsh-remote — three-way conflict-aware mirror sync (remote ⇄ local mirror).
2
- //
3
- // Why three-way: plain rsync-style pull/push silently clobbers whichever side
4
- // changed while the other side was edited. We keep a per-mirror snapshot of the
5
- // last-synced remote state (`.dsh-remote-sync-state.json`: relPath →
6
- // {mtime,size}) and compare remote (R), local (L) and last-synced (S):
7
- //
8
- // R==S && L==S → unchanged, skip
9
- // R!=S && L==S → remote changed → pull it (or push when pushing)
10
- // R==S && L!=S → local changed, remote untouched
11
- // • pull (rw_sync): would clobber local edit → conflict
12
- // • push (rw_push): normal upload
13
- // R!=S && L!=S && R==L → both changed to the same bytes → skip
14
- // R!=S && L!=S && R!=L → BOTH changed differently → conflict (never clobber)
15
- //
16
- // `force: true` downgrades every conflict to a plain overwrite (rsync behavior).
17
- // `dryRun: true` computes the full plan without writing anything.
18
- // After a successful pull the local mtime is aligned to the remote mtime, and
19
- // after a successful push the local mtime is aligned to the remote mtime the
20
- // server assigned — so the invariant L==R==S holds and the next run is cheap.
21
- import { readdirSync, statSync, mkdirSync, existsSync, readFileSync, writeFileSync, utimesSync, renameSync } from 'node:fs'
22
- import path from 'node:path'
23
- import { joinRemotePath, relPathUnder } from './paths.js'
24
-
25
- function mapLimit(items, limit, fn) {
26
- const limitN = Math.max(1, Math.min(Math.floor(limit) || 1, items.length))
27
- let next = 0
28
- const worker = async () => {
29
- while (next < items.length) {
30
- const i = next++
31
- try { await fn(items[i], i) } catch { /* per-item errors are the fn's concern */ }
32
- }
33
- }
34
- const workers = []
35
- for (let w = 0; w < limitN; w++) workers.push(worker())
36
- return Promise.all(workers)
37
- }
38
-
39
- const keyOf = (size, mtime) => `${size}:${Math.floor(mtime || 0)}`
40
- const localKey = (st) => (st ? keyOf(st.size, st.mtimeMs / 1000) : null)
41
- const remoteKey = (st) => (st ? keyOf(st.size, st.mtime) : null)
42
-
43
- /** Recursively pull remote → local mirror (three-way, ignore-aware). */
44
- export async function syncTree(sftp, remoteDir, localDir, opts = {}) {
45
- const {
46
- maxDepth = 5,
47
- maxFiles = 500,
48
- maxFileBytes = 0,
49
- isIgnored = () => false,
50
- dryRun = false,
51
- force = false,
52
- state = {},
53
- } = opts
54
- const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, conflicts: [], staleRemote: 0, touched: [] }
55
- const nextState = { ...state }
56
-
57
- const walk = async (rDir, lDir, depth) => {
58
- const entries = await sftp.readdir(rDir).then((l) => l || [], () => [])
59
- // Directories first (sequential, depth-bounded) so every file has a home.
60
- for (const e of entries) {
61
- const name = String(e.filename)
62
- if (name === '.' || name === '..') continue
63
- const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory())
64
- if (!isDir) continue
65
- if (depth <= 0 || stats.files >= maxFiles) continue
66
- const rp = joinRemotePath(rDir, name)
67
- const lp = path.join(lDir, name)
68
- const rel = relPathUnder(remoteDir, rp) || name
69
- if (isIgnored(rel, true)) continue
70
- if (!existsSync(lp)) mkdirSync(lp, { recursive: true })
71
- await walk(rp, lp, depth - 1)
72
- stats.dirs++
73
- if (stats.files >= maxFiles) break
74
- }
75
- if (stats.files >= maxFiles) return
76
-
77
- const files = entries.filter(
78
- (e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) &&
79
- String(e.filename) !== '.' && String(e.filename) !== '..',
80
- )
81
- await mapLimit(files, 4, async (e) => {
82
- if (stats.files >= maxFiles) return
83
- const name = String(e.filename)
84
- const rp = joinRemotePath(rDir, name)
85
- const lp = path.join(lDir, name)
86
- const rel = relPathUnder(remoteDir, rp) || name
87
- if (isIgnored(rel, false)) return
88
- let r
89
- try { r = await sftp.stat(rp) } catch { return }
90
- if (maxFileBytes > 0 && r.size > maxFileBytes) { stats.skippedLarge++; return }
91
- const rk = remoteKey(r)
92
- const sk = state[rel] ? keyOf(state[rel].size, state[rel].mtime) : null
93
- let lk = null
94
- if (existsSync(lp)) {
95
- try { lk = localKey(statSync(lp)) } catch { lk = null }
96
- }
97
- if (sk && rk === sk && lk && lk === sk) { stats.skippedUnchanged++; return }
98
- if (!force && sk && rk !== sk && lk && lk !== sk && rk !== lk) {
99
- stats.conflicts.push({ path: rp, reason: 'both-modified(远端与本地都改过,跳过不覆盖)' })
100
- return
101
- }
102
- if (!force && sk && rk === sk && lk && lk !== sk) {
103
- // Pull would clobber a local edit; the remote never changed.
104
- stats.conflicts.push({ path: rp, reason: 'local-modified(本地改过而远端未变;如需覆盖请用 force / rw_push)' })
105
- return
106
- }
107
- if (dryRun) {
108
- stats.files++
109
- stats.touched.push(rp)
110
- nextState[rel] = { size: r.size, mtime: r.mtime }
111
- return
112
- }
113
- try {
114
- const buf = await sftp.readFile(rp)
115
- writeFileSync(lp, buf)
116
- try { utimesSync(lp, new Date(r.mtime * 1000), new Date(r.mtime * 1000)) } catch {}
117
- stats.files++
118
- stats.touched.push(rp)
119
- nextState[rel] = { size: r.size, mtime: r.mtime }
120
- } catch { /* skip unreadable */ }
121
- })
122
- }
123
-
124
- await walk(remoteDir, localDir, maxDepth)
125
- // Informational: remote entries in the snapshot that no longer exist remotely.
126
- for (const rel of Object.keys(state)) {
127
- if (state[rel] && !nextState[rel]) stats.staleRemote++
128
- }
129
- return { stats, nextState }
130
- }
131
-
132
- /** Recursively push local mirror → remote (three-way, ignore-aware). */
133
- export async function pushTree(sftp, localDir, remoteDir, opts = {}) {
134
- const {
135
- maxFiles = 500,
136
- maxFileBytes = 0,
137
- isIgnored = () => false,
138
- dryRun = false,
139
- force = false,
140
- state = {},
141
- } = opts
142
- const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, conflicts: [], staleLocal: 0, pushed: [] }
143
- const nextState = { ...state }
144
-
145
- const walk = async (lDir, rDir) => {
146
- const entries = readdirSync(lDir, { withFileTypes: true }).filter(
147
- (e) => e.name !== '.dsh-remote-meta.json' && e.name !== '.dsh-remote-sync-state.json',
148
- )
149
- for (const e of entries) {
150
- if (!e.isDirectory()) continue
151
- if (stats.files >= maxFiles) break
152
- const rp = joinRemotePath(rDir, e.name)
153
- const rel = relPathUnder(remoteDir, rp) || e.name
154
- if (isIgnored(rel, true)) continue
155
- try { await sftp.mkdir(rp) } catch { /* already exists */ }
156
- stats.dirs++
157
- await walk(path.join(lDir, e.name), rp)
158
- if (stats.files >= maxFiles) break
159
- }
160
- if (stats.files >= maxFiles) return
161
-
162
- const files = entries.filter((e) => !e.isDirectory())
163
- await mapLimit(files, 4, async (e) => {
164
- if (stats.files >= maxFiles) return
165
- const lp = path.join(lDir, e.name)
166
- const rp = joinRemotePath(rDir, e.name)
167
- const rel = relPathUnder(remoteDir, rp) || e.name
168
- if (isIgnored(rel, false)) return
169
- let l
170
- try { l = statSync(lp) } catch { return }
171
- if (maxFileBytes > 0 && l.size > maxFileBytes) { stats.skippedLarge++; return }
172
- const lk = localKey(l)
173
- let r = null
174
- try { r = await sftp.stat(rp) } catch { /* absent → upload */ }
175
- const rk = remoteKey(r)
176
- const sk = state[rel] ? keyOf(state[rel].size, state[rel].mtime) : null
177
- if (r && sk && lk === sk && rk === sk) { stats.skippedUnchanged++; return }
178
- if (r && !force && sk && rk !== sk && lk !== sk && rk !== lk) {
179
- stats.conflicts.push({ path: rp, reason: 'both-modified(远端与本地都改过,跳过不覆盖)' })
180
- return
181
- }
182
- if (r && !force && sk && rk !== sk && lk === sk) {
183
- stats.conflicts.push({ path: rp, reason: 'remote-modified(远端改过而本地未变;如需覆盖请用 force / 先 rw_sync)' })
184
- return
185
- }
186
- if (r && !force && sk && rk === sk && lk && lk !== sk) {
187
- // Normal case: local edited since sync, remote untouched → push.
188
- }
189
- if (dryRun) {
190
- stats.files++
191
- stats.pushed.push(rp)
192
- return
193
- }
194
- try {
195
- const buf = readFileSync(lp)
196
- await sftp.writeFile(rp, buf)
197
- // Align local mtime to whatever the remote server assigned, so the
198
- // L==R==S invariant holds for the next run.
199
- let r2 = null
200
- try { r2 = await sftp.stat(rp) } catch {}
201
- const rmt = r2 ? r2.mtime : Math.floor(Date.now() / 1000)
202
- try { utimesSync(lp, new Date(rmt * 1000), new Date(rmt * 1000)) } catch {}
203
- stats.files++
204
- stats.pushed.push(rp)
205
- nextState[rel] = { size: l.size, mtime: rmt }
206
- } catch { /* skip unwritable */ }
207
- })
208
- }
209
-
210
- await walk(localDir, remoteDir)
211
- for (const rel of Object.keys(state)) {
212
- if (state[rel] && !nextState[rel]) stats.staleLocal++
213
- }
214
- return { stats, nextState }
215
- }
216
-
217
- /** Load the sync-state snapshot from a mirror dir (or {}). */
218
- export function loadSyncState(localDir) {
219
- try {
220
- const j = JSON.parse(readFileSync(path.join(localDir, '.dsh-remote-sync-state.json'), 'utf8'))
221
- if (j && typeof j === 'object' && !Array.isArray(j)) return j
222
- } catch {}
223
- return {}
224
- }
225
-
226
- /** Persist the snapshot (atomic-ish: tmp + rename). */
227
- export function saveSyncState(localDir, state) {
228
- try {
229
- mkdirSync(localDir, { recursive: true })
230
- const tmp = path.join(localDir, '.dsh-remote-sync-state.json.tmp')
231
- writeFileSync(tmp, JSON.stringify(state, null, 1))
232
- renameSync(tmp, path.join(localDir, '.dsh-remote-sync-state.json'))
233
- } catch { /* best effort */ }
234
- }
235
-
236
- /** Push ONE local mirror file back to the remote (used by auto-push).
237
- * Same three-way guard as pushTree: never clobbers a remote change. Returns
238
- * { status: 'pushed'|'unchanged'|'conflict'|'skipped'|'missing' } and the
239
- * updated state delta ({rel: {size, mtime}} when pushed). */
240
- export async function pushOneFile(sftp, localDir, remoteDir, rel, opts = {}) {
241
- const { maxFileBytes = 0, isIgnored = () => false, force = false, state = {} } = opts
242
- if (isIgnored(rel, false)) return { status: 'skipped' }
243
- const lp = path.join(localDir, rel.replace(/\//g, path.sep))
244
- const rp = joinRemotePath(remoteDir, rel)
245
- let l
246
- try { l = statSync(lp) } catch { return { status: 'missing' } }
247
- if (maxFileBytes > 0 && l.size > maxFileBytes) return { status: 'skipped' }
248
- const lk = localKey(l)
249
- let r = null
250
- try { r = await sftp.stat(rp) } catch { /* absent → upload */ }
251
- const rk = remoteKey(r)
252
- const sk = state[rel] ? keyOf(state[rel].size, state[rel].mtime) : null
253
- if (r && sk && lk === sk && rk === sk) return { status: 'unchanged' }
254
- if (r && !force && sk && rk !== sk && lk !== sk && rk !== lk) {
255
- return { status: 'conflict', reason: 'both-modified' }
256
- }
257
- if (r && !force && sk && rk !== sk && lk === sk) {
258
- return { status: 'conflict', reason: 'remote-modified' }
259
- }
260
- try {
261
- await sftp.writeFile(rp, readFileSync(lp))
262
- let r2 = null
263
- try { r2 = await sftp.stat(rp) } catch {}
264
- const rmt = r2 ? r2.mtime : Math.floor(Date.now() / 1000)
265
- try { utimesSync(lp, new Date(rmt * 1000), new Date(rmt * 1000)) } catch {}
266
- return { status: 'pushed', state: { [rel]: { size: l.size, mtime: rmt } } }
267
- } catch (e) {
268
- return { status: 'skipped', error: String((e && e.message) || e) }
269
- }
270
- }
1
+ // dsh-remote — three-way conflict-aware mirror sync (remote ⇄ local mirror).
2
+ //
3
+ // Why three-way: plain rsync-style pull/push silently clobbers whichever side
4
+ // changed while the other side was edited. We keep a per-mirror snapshot of the
5
+ // last-synced remote state (`.dsh-remote-sync-state.json`: relPath →
6
+ // {mtime,size}) and compare remote (R), local (L) and last-synced (S):
7
+ //
8
+ // R==S && L==S → unchanged, skip
9
+ // R!=S && L==S → remote changed → pull it (or push when pushing)
10
+ // R==S && L!=S → local changed, remote untouched
11
+ // • pull (rw_sync): would clobber local edit → conflict
12
+ // • push (rw_push): normal upload
13
+ // R!=S && L!=S && R==L → both changed to the same bytes → skip
14
+ // R!=S && L!=S && R!=L → BOTH changed differently → conflict (never clobber)
15
+ //
16
+ // `force: true` downgrades every conflict to a plain overwrite (rsync behavior).
17
+ // `dryRun: true` computes the full plan without writing anything.
18
+ // After a successful pull the local mtime is aligned to the remote mtime, and
19
+ // after a successful push the local mtime is aligned to the remote mtime the
20
+ // server assigned — so the invariant L==R==S holds and the next run is cheap.
21
+ import { readdirSync, statSync, mkdirSync, existsSync, readFileSync, writeFileSync, utimesSync, renameSync } from 'node:fs'
22
+ import path from 'node:path'
23
+ import { joinRemotePath, relPathUnder } from './paths.js'
24
+
25
+ function mapLimit(items, limit, fn) {
26
+ const limitN = Math.max(1, Math.min(Math.floor(limit) || 1, items.length))
27
+ let next = 0
28
+ const worker = async () => {
29
+ while (next < items.length) {
30
+ const i = next++
31
+ try { await fn(items[i], i) } catch { /* per-item errors are the fn's concern */ }
32
+ }
33
+ }
34
+ const workers = []
35
+ for (let w = 0; w < limitN; w++) workers.push(worker())
36
+ return Promise.all(workers)
37
+ }
38
+
39
+ const keyOf = (size, mtime) => `${size}:${Math.floor(mtime || 0)}`
40
+ const localKey = (st) => (st ? keyOf(st.size, st.mtimeMs / 1000) : null)
41
+ const remoteKey = (st) => (st ? keyOf(st.size, st.mtime) : null)
42
+
43
+ /** Recursively pull remote → local mirror (three-way, ignore-aware). */
44
+ export async function syncTree(sftp, remoteDir, localDir, opts = {}) {
45
+ const {
46
+ maxDepth = 5,
47
+ maxFiles = 500,
48
+ maxFileBytes = 0,
49
+ isIgnored = () => false,
50
+ dryRun = false,
51
+ force = false,
52
+ state = {},
53
+ } = opts
54
+ const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, conflicts: [], staleRemote: 0, touched: [] }
55
+ const nextState = { ...state }
56
+
57
+ const walk = async (rDir, lDir, depth) => {
58
+ const entries = await sftp.readdir(rDir).then((l) => l || [], () => [])
59
+ // Directories first (sequential, depth-bounded) so every file has a home.
60
+ for (const e of entries) {
61
+ const name = String(e.filename)
62
+ if (name === '.' || name === '..') continue
63
+ const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory())
64
+ if (!isDir) continue
65
+ if (depth <= 0 || stats.files >= maxFiles) continue
66
+ const rp = joinRemotePath(rDir, name)
67
+ const lp = path.join(lDir, name)
68
+ const rel = relPathUnder(remoteDir, rp) || name
69
+ if (isIgnored(rel, true)) continue
70
+ if (!existsSync(lp)) mkdirSync(lp, { recursive: true })
71
+ await walk(rp, lp, depth - 1)
72
+ stats.dirs++
73
+ if (stats.files >= maxFiles) break
74
+ }
75
+ if (stats.files >= maxFiles) return
76
+
77
+ const files = entries.filter(
78
+ (e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) &&
79
+ String(e.filename) !== '.' && String(e.filename) !== '..',
80
+ )
81
+ await mapLimit(files, 4, async (e) => {
82
+ if (stats.files >= maxFiles) return
83
+ const name = String(e.filename)
84
+ const rp = joinRemotePath(rDir, name)
85
+ const lp = path.join(lDir, name)
86
+ const rel = relPathUnder(remoteDir, rp) || name
87
+ if (isIgnored(rel, false)) return
88
+ let r
89
+ try { r = await sftp.stat(rp) } catch { return }
90
+ if (maxFileBytes > 0 && r.size > maxFileBytes) { stats.skippedLarge++; return }
91
+ const rk = remoteKey(r)
92
+ const sk = state[rel] ? keyOf(state[rel].size, state[rel].mtime) : null
93
+ let lk = null
94
+ if (existsSync(lp)) {
95
+ try { lk = localKey(statSync(lp)) } catch { lk = null }
96
+ }
97
+ if (sk && rk === sk && lk && lk === sk) { stats.skippedUnchanged++; return }
98
+ if (!force && sk && rk !== sk && lk && lk !== sk && rk !== lk) {
99
+ stats.conflicts.push({ path: rp, reason: 'both-modified(远端与本地都改过,跳过不覆盖)' })
100
+ return
101
+ }
102
+ if (!force && sk && rk === sk && lk && lk !== sk) {
103
+ // Pull would clobber a local edit; the remote never changed.
104
+ stats.conflicts.push({ path: rp, reason: 'local-modified(本地改过而远端未变;如需覆盖请用 force / rw_push)' })
105
+ return
106
+ }
107
+ if (dryRun) {
108
+ stats.files++
109
+ stats.touched.push(rp)
110
+ nextState[rel] = { size: r.size, mtime: r.mtime }
111
+ return
112
+ }
113
+ try {
114
+ const buf = await sftp.readFile(rp)
115
+ writeFileSync(lp, buf)
116
+ try { utimesSync(lp, new Date(r.mtime * 1000), new Date(r.mtime * 1000)) } catch {}
117
+ stats.files++
118
+ stats.touched.push(rp)
119
+ nextState[rel] = { size: r.size, mtime: r.mtime }
120
+ } catch { /* skip unreadable */ }
121
+ })
122
+ }
123
+
124
+ await walk(remoteDir, localDir, maxDepth)
125
+ // Informational: remote entries in the snapshot that no longer exist remotely.
126
+ for (const rel of Object.keys(state)) {
127
+ if (state[rel] && !nextState[rel]) stats.staleRemote++
128
+ }
129
+ return { stats, nextState }
130
+ }
131
+
132
+ /** Recursively push local mirror → remote (three-way, ignore-aware). */
133
+ export async function pushTree(sftp, localDir, remoteDir, opts = {}) {
134
+ const {
135
+ maxFiles = 500,
136
+ maxFileBytes = 0,
137
+ isIgnored = () => false,
138
+ dryRun = false,
139
+ force = false,
140
+ state = {},
141
+ } = opts
142
+ const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, conflicts: [], staleLocal: 0, pushed: [] }
143
+ const nextState = { ...state }
144
+
145
+ const walk = async (lDir, rDir) => {
146
+ const entries = readdirSync(lDir, { withFileTypes: true }).filter(
147
+ (e) => e.name !== '.dsh-remote-meta.json' && e.name !== '.dsh-remote-sync-state.json',
148
+ )
149
+ for (const e of entries) {
150
+ if (!e.isDirectory()) continue
151
+ if (stats.files >= maxFiles) break
152
+ const rp = joinRemotePath(rDir, e.name)
153
+ const rel = relPathUnder(remoteDir, rp) || e.name
154
+ if (isIgnored(rel, true)) continue
155
+ try { await sftp.mkdir(rp) } catch { /* already exists */ }
156
+ stats.dirs++
157
+ await walk(path.join(lDir, e.name), rp)
158
+ if (stats.files >= maxFiles) break
159
+ }
160
+ if (stats.files >= maxFiles) return
161
+
162
+ const files = entries.filter((e) => !e.isDirectory())
163
+ await mapLimit(files, 4, async (e) => {
164
+ if (stats.files >= maxFiles) return
165
+ const lp = path.join(lDir, e.name)
166
+ const rp = joinRemotePath(rDir, e.name)
167
+ const rel = relPathUnder(remoteDir, rp) || e.name
168
+ if (isIgnored(rel, false)) return
169
+ let l
170
+ try { l = statSync(lp) } catch { return }
171
+ if (maxFileBytes > 0 && l.size > maxFileBytes) { stats.skippedLarge++; return }
172
+ const lk = localKey(l)
173
+ let r = null
174
+ try { r = await sftp.stat(rp) } catch { /* absent → upload */ }
175
+ const rk = remoteKey(r)
176
+ const sk = state[rel] ? keyOf(state[rel].size, state[rel].mtime) : null
177
+ if (r && sk && lk === sk && rk === sk) { stats.skippedUnchanged++; return }
178
+ if (r && !force && sk && rk !== sk && lk !== sk && rk !== lk) {
179
+ stats.conflicts.push({ path: rp, reason: 'both-modified(远端与本地都改过,跳过不覆盖)' })
180
+ return
181
+ }
182
+ if (r && !force && sk && rk !== sk && lk === sk) {
183
+ stats.conflicts.push({ path: rp, reason: 'remote-modified(远端改过而本地未变;如需覆盖请用 force / 先 rw_sync)' })
184
+ return
185
+ }
186
+ if (r && !force && sk && rk === sk && lk && lk !== sk) {
187
+ // Normal case: local edited since sync, remote untouched → push.
188
+ }
189
+ if (dryRun) {
190
+ stats.files++
191
+ stats.pushed.push(rp)
192
+ return
193
+ }
194
+ try {
195
+ const buf = readFileSync(lp)
196
+ await sftp.writeFile(rp, buf)
197
+ // Align local mtime to whatever the remote server assigned, so the
198
+ // L==R==S invariant holds for the next run.
199
+ let r2 = null
200
+ try { r2 = await sftp.stat(rp) } catch {}
201
+ const rmt = r2 ? r2.mtime : Math.floor(Date.now() / 1000)
202
+ try { utimesSync(lp, new Date(rmt * 1000), new Date(rmt * 1000)) } catch {}
203
+ stats.files++
204
+ stats.pushed.push(rp)
205
+ nextState[rel] = { size: l.size, mtime: rmt }
206
+ } catch { /* skip unwritable */ }
207
+ })
208
+ }
209
+
210
+ await walk(localDir, remoteDir)
211
+ for (const rel of Object.keys(state)) {
212
+ if (state[rel] && !nextState[rel]) stats.staleLocal++
213
+ }
214
+ return { stats, nextState }
215
+ }
216
+
217
+ /** Load the sync-state snapshot from a mirror dir (or {}). */
218
+ export function loadSyncState(localDir) {
219
+ try {
220
+ const j = JSON.parse(readFileSync(path.join(localDir, '.dsh-remote-sync-state.json'), 'utf8'))
221
+ if (j && typeof j === 'object' && !Array.isArray(j)) return j
222
+ } catch {}
223
+ return {}
224
+ }
225
+
226
+ /** Persist the snapshot (atomic-ish: tmp + rename). */
227
+ export function saveSyncState(localDir, state) {
228
+ try {
229
+ mkdirSync(localDir, { recursive: true })
230
+ const tmp = path.join(localDir, '.dsh-remote-sync-state.json.tmp')
231
+ writeFileSync(tmp, JSON.stringify(state, null, 1))
232
+ renameSync(tmp, path.join(localDir, '.dsh-remote-sync-state.json'))
233
+ } catch { /* best effort */ }
234
+ }
235
+
236
+ /** Push ONE local mirror file back to the remote (used by auto-push).
237
+ * Same three-way guard as pushTree: never clobbers a remote change. Returns
238
+ * { status: 'pushed'|'unchanged'|'conflict'|'skipped'|'missing' } and the
239
+ * updated state delta ({rel: {size, mtime}} when pushed). */
240
+ export async function pushOneFile(sftp, localDir, remoteDir, rel, opts = {}) {
241
+ const { maxFileBytes = 0, isIgnored = () => false, force = false, state = {} } = opts
242
+ if (isIgnored(rel, false)) return { status: 'skipped' }
243
+ const lp = path.join(localDir, rel.replace(/\//g, path.sep))
244
+ const rp = joinRemotePath(remoteDir, rel)
245
+ let l
246
+ try { l = statSync(lp) } catch { return { status: 'missing' } }
247
+ if (maxFileBytes > 0 && l.size > maxFileBytes) return { status: 'skipped' }
248
+ const lk = localKey(l)
249
+ let r = null
250
+ try { r = await sftp.stat(rp) } catch { /* absent → upload */ }
251
+ const rk = remoteKey(r)
252
+ const sk = state[rel] ? keyOf(state[rel].size, state[rel].mtime) : null
253
+ if (r && sk && lk === sk && rk === sk) return { status: 'unchanged' }
254
+ if (r && !force && sk && rk !== sk && lk !== sk && rk !== lk) {
255
+ return { status: 'conflict', reason: 'both-modified' }
256
+ }
257
+ if (r && !force && sk && rk !== sk && lk === sk) {
258
+ return { status: 'conflict', reason: 'remote-modified' }
259
+ }
260
+ try {
261
+ await sftp.writeFile(rp, readFileSync(lp))
262
+ let r2 = null
263
+ try { r2 = await sftp.stat(rp) } catch {}
264
+ const rmt = r2 ? r2.mtime : Math.floor(Date.now() / 1000)
265
+ try { utimesSync(lp, new Date(rmt * 1000), new Date(rmt * 1000)) } catch {}
266
+ return { status: 'pushed', state: { [rel]: { size: l.size, mtime: rmt } } }
267
+ } catch (e) {
268
+ return { status: 'skipped', error: String((e && e.message) || e) }
269
+ }
270
+ }