code-server-sync 0.0.0-stage → 0.1.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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +251 -2
- package/bin/cs-sync.js +68 -0
- package/cs_sync/__init__.py +0 -0
- package/cs_sync/__main__.py +6 -0
- package/cs_sync/cli.py +650 -0
- package/cs_sync/client.py +186 -0
- package/cs_sync/config.py +184 -0
- package/cs_sync/files.py +199 -0
- package/cs_sync/git.py +683 -0
- package/cs_sync/ignore.py +165 -0
- package/cs_sync/merge.py +279 -0
- package/cs_sync/protocol.py +28 -0
- package/cs_sync/self_test.py +730 -0
- package/cs_sync/server.py +360 -0
- package/cs_sync/sync.py +812 -0
- package/package.json +19 -4
package/cs_sync/sync.py
ADDED
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
from typing import Dict, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from cs_sync.client import Client, check_ready, explain_http
|
|
9
|
+
from cs_sync.files import file_mode, remove_file, scan, sha256_bytes, sha256_file, write_file
|
|
10
|
+
from cs_sync.git import (
|
|
11
|
+
apply_saved_patch,
|
|
12
|
+
build_watch_change,
|
|
13
|
+
change_from_contents,
|
|
14
|
+
change_target_bytes,
|
|
15
|
+
decode_bytes,
|
|
16
|
+
find_repo_for_rel,
|
|
17
|
+
patch_already_applied,
|
|
18
|
+
transfer_line_changes,
|
|
19
|
+
)
|
|
20
|
+
from cs_sync.ignore import PathIgnore, ignore_dirs, load_path_ignore, should_skip
|
|
21
|
+
from cs_sync.merge import apply_line_delta_onto, refresh_local_known, try_merge_cloud_onto_local
|
|
22
|
+
from cs_sync.protocol import MAX_BYTES, eprint
|
|
23
|
+
|
|
24
|
+
def apply_change(client: Client, local_root: str, rel: str, delete: bool) -> None:
|
|
25
|
+
if delete:
|
|
26
|
+
status, body = client.delete_file(rel)
|
|
27
|
+
action = "本地改动(删除)"
|
|
28
|
+
fail_prefix = "未同步到云端(删除)"
|
|
29
|
+
else:
|
|
30
|
+
full = os.path.join(local_root, rel)
|
|
31
|
+
with open(full, "rb") as handle:
|
|
32
|
+
payload = handle.read()
|
|
33
|
+
status, body = client.put_file(rel, payload, file_mode(full))
|
|
34
|
+
action = "本地改动"
|
|
35
|
+
fail_prefix = "未同步到云端"
|
|
36
|
+
if status != 200 or body.strip() != b"ok":
|
|
37
|
+
eprint("%s %s :%s" % (fail_prefix, rel, explain_http(status, body)))
|
|
38
|
+
return False
|
|
39
|
+
print("%s %s" % (action, rel), flush=True)
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_dry_run(args) -> bool:
|
|
44
|
+
return bool(getattr(args, "dry_run", False))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def print_dry_run_banner(command: str) -> None:
|
|
48
|
+
print("【演练模式 --dry-run】%s:不会暂存、不会写入/删除文件、不会应用补丁。" % command, flush=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def summarize_counts(counts: dict) -> str:
|
|
52
|
+
order = ("add", "modify", "delete", "skip-same", "conflict", "binary", "fail", "upload", "download", "local-only", "remote-only")
|
|
53
|
+
parts = []
|
|
54
|
+
for key in order:
|
|
55
|
+
value = counts.get(key)
|
|
56
|
+
if value:
|
|
57
|
+
parts.append("%s %d" % (key, value))
|
|
58
|
+
for key, value in sorted(counts.items()):
|
|
59
|
+
if key not in order and value:
|
|
60
|
+
parts.append("%s %d" % (key, value))
|
|
61
|
+
return ",".join(parts) if parts else "无变更"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def preview_copy_local_to_cloud(local_root: str, ignored: set, remote: Optional[Dict[str, str]]) -> Tuple[int, int, int]:
|
|
65
|
+
upload = skipped = failed = 0
|
|
66
|
+
print("变更清单(本地 → 云端):", flush=True)
|
|
67
|
+
for rel in sorted(scan(local_root, ignored)):
|
|
68
|
+
full = os.path.join(local_root, rel)
|
|
69
|
+
try:
|
|
70
|
+
digest = sha256_file(full)
|
|
71
|
+
except OSError as exc:
|
|
72
|
+
print(" fail %s (读取本地失败:%s)" % (rel, exc), flush=True)
|
|
73
|
+
failed += 1
|
|
74
|
+
continue
|
|
75
|
+
if remote is not None and remote.get(rel) == digest:
|
|
76
|
+
print(" skip-same %s" % rel, flush=True)
|
|
77
|
+
skipped += 1
|
|
78
|
+
continue
|
|
79
|
+
if remote is None or rel not in remote:
|
|
80
|
+
print(" add %s" % rel, flush=True)
|
|
81
|
+
else:
|
|
82
|
+
print(" modify %s" % rel, flush=True)
|
|
83
|
+
upload += 1
|
|
84
|
+
print("演练合计:将本地改动 %d,相同跳过 %d,失败 %d。" % (upload, skipped, failed), flush=True)
|
|
85
|
+
print("只在云端、本地没有的文件保持原样。", flush=True)
|
|
86
|
+
return upload, skipped, failed
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def preview_copy_cloud_to_local(local_root: str, remote: Dict[str, str]) -> Tuple[int, int, int]:
|
|
90
|
+
download = skipped = failed = 0
|
|
91
|
+
print("变更清单(云端 → 本地):", flush=True)
|
|
92
|
+
for rel, digest in sorted(remote.items()):
|
|
93
|
+
full = os.path.join(local_root, rel)
|
|
94
|
+
if os.path.isfile(full) and not os.path.islink(full):
|
|
95
|
+
try:
|
|
96
|
+
if sha256_file(full) == digest:
|
|
97
|
+
print(" skip-same %s" % rel, flush=True)
|
|
98
|
+
skipped += 1
|
|
99
|
+
continue
|
|
100
|
+
except OSError as exc:
|
|
101
|
+
print(" fail %s (读取本地失败:%s)" % (rel, exc), flush=True)
|
|
102
|
+
failed += 1
|
|
103
|
+
continue
|
|
104
|
+
print(" modify %s" % rel, flush=True)
|
|
105
|
+
else:
|
|
106
|
+
print(" add %s" % rel, flush=True)
|
|
107
|
+
download += 1
|
|
108
|
+
print("演练合计:将本地写入 %d,相同跳过 %d,失败 %d。" % (download, skipped, failed), flush=True)
|
|
109
|
+
print("只在本地、云端没有的文件保持原样。", flush=True)
|
|
110
|
+
return download, skipped, failed
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def preview_line_changes(changes: list, read_dest) -> Tuple[int, int]:
|
|
114
|
+
would = failed = 0
|
|
115
|
+
print("变更清单(git diff 行级):", flush=True)
|
|
116
|
+
for change in changes:
|
|
117
|
+
rel = str(change.get("path") or "")
|
|
118
|
+
kind = str(change.get("kind") or "")
|
|
119
|
+
repo_path = str(change.get("repoPath") or rel)
|
|
120
|
+
try:
|
|
121
|
+
exists, dest, _mode = read_dest(rel)
|
|
122
|
+
except OSError as exc:
|
|
123
|
+
print(" fail %s (读取对端失败:%s)" % (rel, exc), flush=True)
|
|
124
|
+
failed += 1
|
|
125
|
+
continue
|
|
126
|
+
if kind == "binary":
|
|
127
|
+
print(" binary %s (二进制不能按行同步)" % rel, flush=True)
|
|
128
|
+
failed += 1
|
|
129
|
+
continue
|
|
130
|
+
if kind == "add":
|
|
131
|
+
try:
|
|
132
|
+
payload = decode_bytes(str(change.get("content") or ""))
|
|
133
|
+
except ValueError:
|
|
134
|
+
print(" fail %s (新增内容无法读取)" % rel, flush=True)
|
|
135
|
+
failed += 1
|
|
136
|
+
continue
|
|
137
|
+
if exists:
|
|
138
|
+
if dest == payload:
|
|
139
|
+
print(" skip-same %s (对端已是目标内容)" % rel, flush=True)
|
|
140
|
+
else:
|
|
141
|
+
print(" conflict %s (对端已有,不会整份覆盖)" % rel, flush=True)
|
|
142
|
+
failed += 1
|
|
143
|
+
else:
|
|
144
|
+
print(" add %s" % rel, flush=True)
|
|
145
|
+
would += 1
|
|
146
|
+
continue
|
|
147
|
+
patch_raw = change.get("patch")
|
|
148
|
+
if not patch_raw:
|
|
149
|
+
print(" fail %s (没有 git diff)" % rel, flush=True)
|
|
150
|
+
failed += 1
|
|
151
|
+
continue
|
|
152
|
+
try:
|
|
153
|
+
patch = decode_bytes(str(patch_raw))
|
|
154
|
+
except ValueError:
|
|
155
|
+
print(" fail %s (git diff 无法读取)" % rel, flush=True)
|
|
156
|
+
failed += 1
|
|
157
|
+
continue
|
|
158
|
+
if kind == "delete":
|
|
159
|
+
if not exists:
|
|
160
|
+
print(" skip-same %s (对端已无)" % rel, flush=True)
|
|
161
|
+
continue
|
|
162
|
+
outcome, _payload = apply_saved_patch(repo_path, dest, patch)
|
|
163
|
+
if outcome == "deleted":
|
|
164
|
+
print(" delete %s" % rel, flush=True)
|
|
165
|
+
would += 1
|
|
166
|
+
else:
|
|
167
|
+
print(" conflict %s (补丁对不上,不会删除)" % rel, flush=True)
|
|
168
|
+
failed += 1
|
|
169
|
+
continue
|
|
170
|
+
if not exists:
|
|
171
|
+
print(" fail %s (对端没有这个文件)" % rel, flush=True)
|
|
172
|
+
failed += 1
|
|
173
|
+
continue
|
|
174
|
+
target = change_target_bytes(change)
|
|
175
|
+
if (target is not None and dest == target) or patch_already_applied(repo_path, dest, patch):
|
|
176
|
+
print(" skip-same %s (对端已是补丁结果)" % rel, flush=True)
|
|
177
|
+
continue
|
|
178
|
+
outcome, payload = apply_saved_patch(repo_path, dest, patch)
|
|
179
|
+
if outcome == "ok" and payload is not None:
|
|
180
|
+
if payload == dest:
|
|
181
|
+
print(" skip-same %s (对端已是补丁结果)" % rel, flush=True)
|
|
182
|
+
elif target is not None and payload != target:
|
|
183
|
+
back_ok, back = apply_saved_patch(repo_path, payload, patch, reverse=True)
|
|
184
|
+
if not (back_ok == "ok" and back == dest):
|
|
185
|
+
print(" conflict %s (补丁对不上,不会覆盖)" % rel, flush=True)
|
|
186
|
+
failed += 1
|
|
187
|
+
else:
|
|
188
|
+
print(" modify %s" % rel, flush=True)
|
|
189
|
+
would += 1
|
|
190
|
+
else:
|
|
191
|
+
print(" modify %s" % rel, flush=True)
|
|
192
|
+
would += 1
|
|
193
|
+
elif target is not None and dest == target:
|
|
194
|
+
print(" skip-same %s (对端已是目标内容)" % rel, flush=True)
|
|
195
|
+
else:
|
|
196
|
+
print(" conflict %s (补丁对不上,不会覆盖)" % rel, flush=True)
|
|
197
|
+
failed += 1
|
|
198
|
+
print("演练合计:可应用 %d,不可应用 %d。" % (would, failed), flush=True)
|
|
199
|
+
return would, failed
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def preview_sync_divergence(local_root: str, ignored: set, remote: Dict[str, str], pull_enabled: bool) -> None:
|
|
203
|
+
print("当前双边差异预览(注意:sync 监视只会同步启动之后的保存,不会自动处理启动前差异):", flush=True)
|
|
204
|
+
local_files = scan(local_root, ignored)
|
|
205
|
+
counts = {"upload": 0, "download": 0, "conflict": 0, "skip-same": 0, "local-only": 0, "remote-only": 0}
|
|
206
|
+
all_rels = sorted(set(local_files) | set(remote))
|
|
207
|
+
for rel in all_rels:
|
|
208
|
+
local_exists = rel in local_files
|
|
209
|
+
remote_exists = rel in remote
|
|
210
|
+
local_digest = None
|
|
211
|
+
if local_exists:
|
|
212
|
+
full = os.path.join(local_root, rel)
|
|
213
|
+
try:
|
|
214
|
+
local_digest = sha256_file(full)
|
|
215
|
+
except OSError as exc:
|
|
216
|
+
print(" fail %s (读取本地失败:%s)" % (rel, exc), flush=True)
|
|
217
|
+
continue
|
|
218
|
+
if local_exists and remote_exists:
|
|
219
|
+
if local_digest == remote[rel]:
|
|
220
|
+
print(" skip-same %s" % rel, flush=True)
|
|
221
|
+
counts["skip-same"] += 1
|
|
222
|
+
else:
|
|
223
|
+
print(" conflict %s (本地与云端内容不同)" % rel, flush=True)
|
|
224
|
+
counts["conflict"] += 1
|
|
225
|
+
continue
|
|
226
|
+
if local_exists and not remote_exists:
|
|
227
|
+
print(" local-only %s (sync 启动前不会自动同步到云端;可用 to-cloud)" % rel, flush=True)
|
|
228
|
+
counts["local-only"] += 1
|
|
229
|
+
counts["upload"] += 1
|
|
230
|
+
continue
|
|
231
|
+
if remote_exists and not local_exists:
|
|
232
|
+
label = "remote-only %s" % rel
|
|
233
|
+
if pull_enabled:
|
|
234
|
+
print(" %s (sync 启动前不会自动拉回;可用 to-local)" % label, flush=True)
|
|
235
|
+
else:
|
|
236
|
+
print(" %s (云端旧接收程序,无法拉取)" % label, flush=True)
|
|
237
|
+
counts["remote-only"] += 1
|
|
238
|
+
counts["download"] += 1
|
|
239
|
+
print("演练合计:%s。" % summarize_counts(counts), flush=True)
|
|
240
|
+
print("未进入持续监视。去掉 --dry-run 才会开始 sync。", flush=True)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def copy_local_to_cloud(client: Client, local_root: str, ignored: set, remote: Optional[Dict[str, str]]) -> Tuple[int, int, int]:
|
|
244
|
+
uploaded = skipped = failed = 0
|
|
245
|
+
for rel in sorted(scan(local_root, ignored)):
|
|
246
|
+
full = os.path.join(local_root, rel)
|
|
247
|
+
if remote is not None:
|
|
248
|
+
try:
|
|
249
|
+
if remote.get(rel) == sha256_file(full):
|
|
250
|
+
skipped += 1
|
|
251
|
+
continue
|
|
252
|
+
except OSError as exc:
|
|
253
|
+
eprint("读取本地失败 %s :%s" % (rel, exc))
|
|
254
|
+
failed += 1
|
|
255
|
+
continue
|
|
256
|
+
if apply_change(client, local_root, rel, False):
|
|
257
|
+
uploaded += 1
|
|
258
|
+
else:
|
|
259
|
+
failed += 1
|
|
260
|
+
return uploaded, skipped, failed
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def copy_cloud_to_local(client: Client, local_root: str, remote: Dict[str, str]) -> Tuple[int, int, int]:
|
|
264
|
+
downloaded = skipped = failed = 0
|
|
265
|
+
for rel, digest in sorted(remote.items()):
|
|
266
|
+
full = os.path.join(local_root, rel)
|
|
267
|
+
if os.path.isfile(full) and not os.path.islink(full):
|
|
268
|
+
try:
|
|
269
|
+
if sha256_file(full) == digest:
|
|
270
|
+
skipped += 1
|
|
271
|
+
continue
|
|
272
|
+
except OSError:
|
|
273
|
+
pass
|
|
274
|
+
status, payload, mode = client.pull_file(rel)
|
|
275
|
+
if status != 200:
|
|
276
|
+
eprint("未写入本地 %s :%s" % (rel, explain_http(status, payload)))
|
|
277
|
+
failed += 1
|
|
278
|
+
continue
|
|
279
|
+
try:
|
|
280
|
+
write_file(local_root, rel, payload, mode)
|
|
281
|
+
except (ValueError, OSError) as exc:
|
|
282
|
+
eprint("本地写入失败 %s :%s" % (rel, exc))
|
|
283
|
+
failed += 1
|
|
284
|
+
continue
|
|
285
|
+
print("本地写入 %s" % rel, flush=True)
|
|
286
|
+
downloaded += 1
|
|
287
|
+
return downloaded, skipped, failed
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def push_watch_edit_to_cloud(
|
|
292
|
+
rel: str,
|
|
293
|
+
local_root: str,
|
|
294
|
+
new_payload: bytes,
|
|
295
|
+
last_synced: Dict[str, bytes],
|
|
296
|
+
cloud_reader,
|
|
297
|
+
cloud_writer,
|
|
298
|
+
cloud_deleter,
|
|
299
|
+
last_cloud_synced: Optional[Dict[str, bytes]] = None,
|
|
300
|
+
) -> Tuple[str, Optional[bytes], str]:
|
|
301
|
+
"""把监视到的本地编辑推到云端。
|
|
302
|
+
|
|
303
|
+
只把 last_synced→new_local 的行级增量打到当前云端;保留云端启动前独有行。
|
|
304
|
+
成功后不把合并结果写回本地(本地保持用户刚保存的内容)。
|
|
305
|
+
返回 (status, cloud_bytes_after, mode),status 为 ok / conflict / error / noop。
|
|
306
|
+
"""
|
|
307
|
+
_repo, _prefix, repo_path = find_repo_for_rel(local_root, rel)
|
|
308
|
+
if not repo_path:
|
|
309
|
+
repo_path = rel
|
|
310
|
+
mode = "644"
|
|
311
|
+
full = os.path.join(local_root, *rel.split("/"))
|
|
312
|
+
if os.path.isfile(full):
|
|
313
|
+
mode = file_mode(full)
|
|
314
|
+
try:
|
|
315
|
+
exists, cloud_now, cloud_mode = cloud_reader(rel)
|
|
316
|
+
except urllib.error.URLError:
|
|
317
|
+
raise
|
|
318
|
+
except OSError as exc:
|
|
319
|
+
return "error", None, str(exc)
|
|
320
|
+
mode_use = cloud_mode or mode
|
|
321
|
+
old_local = last_synced.get(rel)
|
|
322
|
+
|
|
323
|
+
if not exists:
|
|
324
|
+
# 云端曾有过此文件(启动基线或之后已同步)却已不存在:保留云端删除,不复活
|
|
325
|
+
if last_cloud_synced is not None and rel in last_cloud_synced:
|
|
326
|
+
return "conflict", None, "cloud-deleted"
|
|
327
|
+
# 启动后从未进入云端基线的新文件:整份新增
|
|
328
|
+
if cloud_writer(rel, new_payload, mode_use):
|
|
329
|
+
print("本地改动 %s" % rel, flush=True)
|
|
330
|
+
return "ok", new_payload, mode_use
|
|
331
|
+
return "error", None, mode_use
|
|
332
|
+
|
|
333
|
+
if cloud_now == new_payload:
|
|
334
|
+
return "noop", cloud_now, mode_use
|
|
335
|
+
|
|
336
|
+
if old_local is None:
|
|
337
|
+
# 缺少行级基线(旧 recv / 启动时未记下本地字节):回退整份上传,避免已存在文件永远推不上去
|
|
338
|
+
if cloud_writer(rel, new_payload, mode_use):
|
|
339
|
+
print("本地改动 %s" % rel, flush=True)
|
|
340
|
+
return "ok", new_payload, mode_use
|
|
341
|
+
return "error", None, mode_use
|
|
342
|
+
|
|
343
|
+
if old_local == new_payload:
|
|
344
|
+
return "noop", cloud_now, mode_use
|
|
345
|
+
|
|
346
|
+
# 快路径:云端仍等于本地基线,行补丁可直接打上
|
|
347
|
+
if cloud_now == old_local:
|
|
348
|
+
change = change_from_contents(rel, repo_path, old_local, new_payload, mode_use)
|
|
349
|
+
if change is None:
|
|
350
|
+
return "noop", cloud_now, mode_use
|
|
351
|
+
if change.get("kind") == "binary":
|
|
352
|
+
return "conflict", None, "binary"
|
|
353
|
+
applied, failed = transfer_line_changes(
|
|
354
|
+
[change], cloud_reader, cloud_writer, cloud_deleter, success_verb="本地改动"
|
|
355
|
+
)
|
|
356
|
+
if applied and not failed:
|
|
357
|
+
return "ok", new_payload, mode_use
|
|
358
|
+
# 补丁意外失败则走增量合入
|
|
359
|
+
|
|
360
|
+
# 云端已分叉:只把 last_synced→new 增量打到云端,不把本地启动前独有行整包推上去。
|
|
361
|
+
# 不用 git apply 短路:重复行等场景下上下文仍可能命中,插入类 hunk 会再写一遍。
|
|
362
|
+
change = change_from_contents(rel, repo_path, old_local, new_payload, mode_use)
|
|
363
|
+
if change is not None and change.get("kind") == "binary":
|
|
364
|
+
return "conflict", None, "binary"
|
|
365
|
+
|
|
366
|
+
merged = apply_line_delta_onto(old_local, new_payload, cloud_now)
|
|
367
|
+
if merged is None:
|
|
368
|
+
return "conflict", None, "three-way"
|
|
369
|
+
if merged == cloud_now:
|
|
370
|
+
return "noop", cloud_now, mode_use
|
|
371
|
+
if cloud_writer(rel, merged, mode_use):
|
|
372
|
+
print("本地改动 %s" % rel, flush=True)
|
|
373
|
+
return "ok", merged, mode_use
|
|
374
|
+
return "error", None, mode_use
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def watch(config: dict) -> None:
|
|
378
|
+
from cs_sync.cli import command_text
|
|
379
|
+
from cs_sync.cli import (
|
|
380
|
+
cloud_change_deleter,
|
|
381
|
+
cloud_change_reader,
|
|
382
|
+
cloud_change_writer,
|
|
383
|
+
local_change_deleter,
|
|
384
|
+
local_change_reader,
|
|
385
|
+
local_change_writer,
|
|
386
|
+
)
|
|
387
|
+
local_root = config["localRoot"]
|
|
388
|
+
if not os.path.isdir(local_root):
|
|
389
|
+
raise SystemExit("本地目录不存在: %s" % local_root)
|
|
390
|
+
client = Client(config)
|
|
391
|
+
check_ready(client)
|
|
392
|
+
ignored = ignore_dirs(config)
|
|
393
|
+
path_ignore = load_path_ignore(local_root)
|
|
394
|
+
print("已连上 %s" % client.endpoint, flush=True)
|
|
395
|
+
ignore_status, _ignore_body = client.push_ignore(ignored)
|
|
396
|
+
print("正在建立基线…", flush=True)
|
|
397
|
+
current = scan(local_root, ignored, path_ignore)
|
|
398
|
+
known = dict(current)
|
|
399
|
+
pending: Dict[str, FileState] = {}
|
|
400
|
+
# 上次已成功同步的本地内容(用于本地→云端增量)
|
|
401
|
+
last_synced: Dict[str, bytes] = {}
|
|
402
|
+
# 上次已接受的云端内容(用于云端→本地增量;绝不用相对 HEAD 的整份 diff)
|
|
403
|
+
last_cloud_synced: Dict[str, bytes] = {}
|
|
404
|
+
print("基线 %d 个文件。之后本地和云端的保存会按行级增量同步。" % len(known), flush=True)
|
|
405
|
+
print("启动前本地已经改过、且之后没有再保存的文件,不会自动同步到云端。要整份覆盖云端,停掉后改用 %s" % command_text(config, "to-cloud"), flush=True)
|
|
406
|
+
remote_base, pull_enabled = load_remote_base(client, ignored, ignore_status, path_ignore)
|
|
407
|
+
diverge_count = 0
|
|
408
|
+
if pull_enabled:
|
|
409
|
+
print("已记录云端基线 %d 个文件。之后云端的修改会按启动后增量拉回本地。" % len(remote_base), flush=True)
|
|
410
|
+
print("启动前云端已经改过、且之后没有再保存的文件,不会自动拉回。要整份覆盖本地,停掉后改用 %s" % command_text(config, "to-local"), flush=True)
|
|
411
|
+
for rel, digest in list(remote_base.items()):
|
|
412
|
+
full = os.path.join(local_root, *rel.split("/"))
|
|
413
|
+
if not os.path.isfile(full) or os.path.islink(full):
|
|
414
|
+
continue
|
|
415
|
+
try:
|
|
416
|
+
if os.path.getsize(full) > MAX_BYTES:
|
|
417
|
+
continue
|
|
418
|
+
with open(full, "rb") as handle:
|
|
419
|
+
local_bytes = handle.read()
|
|
420
|
+
except OSError:
|
|
421
|
+
continue
|
|
422
|
+
if len(local_bytes) > MAX_BYTES:
|
|
423
|
+
continue
|
|
424
|
+
if sha256_bytes(local_bytes) == digest:
|
|
425
|
+
last_synced[rel] = local_bytes
|
|
426
|
+
last_cloud_synced[rel] = local_bytes
|
|
427
|
+
continue
|
|
428
|
+
# 启动时两边已不一致:记下云端作增量基线,同时记下当时本地内容,
|
|
429
|
+
# 之后本地编辑只上传 local_at_start → new_local,不会把启动前本地独有行整包推上云端。
|
|
430
|
+
status_pull, cloud_payload, _mode = client.pull_file(rel)
|
|
431
|
+
if status_pull == 200 and len(cloud_payload) <= MAX_BYTES:
|
|
432
|
+
if sha256_bytes(cloud_payload) != digest:
|
|
433
|
+
eprint(
|
|
434
|
+
"启动基线跳过 %s :云端清单与正文不一致,下一轮再记。"
|
|
435
|
+
% rel
|
|
436
|
+
)
|
|
437
|
+
continue
|
|
438
|
+
last_cloud_synced[rel] = cloud_payload
|
|
439
|
+
last_synced[rel] = local_bytes
|
|
440
|
+
diverge_count += 1
|
|
441
|
+
if diverge_count:
|
|
442
|
+
print(
|
|
443
|
+
"启动时有 %d 个文件两边内容不一致,已记录云端基线;只同步启动之后的增量。"
|
|
444
|
+
% diverge_count,
|
|
445
|
+
flush=True,
|
|
446
|
+
)
|
|
447
|
+
else:
|
|
448
|
+
print("云端还是旧的接收程序,目前只把本地修改推上去。" , flush=True)
|
|
449
|
+
print("请在云端升级 code-server-sync 并重启 cs-sync recv,然后再运行 sync。", flush=True)
|
|
450
|
+
# 旧 recv 无云端清单:仍记下当前本地字节,便于之后行级推送;缺基线时 push 会整份上传
|
|
451
|
+
for rel in known:
|
|
452
|
+
full = os.path.join(local_root, *rel.split("/"))
|
|
453
|
+
if not os.path.isfile(full) or os.path.islink(full):
|
|
454
|
+
continue
|
|
455
|
+
try:
|
|
456
|
+
if os.path.getsize(full) > MAX_BYTES:
|
|
457
|
+
continue
|
|
458
|
+
with open(full, "rb") as handle:
|
|
459
|
+
local_bytes = handle.read()
|
|
460
|
+
except OSError:
|
|
461
|
+
continue
|
|
462
|
+
if len(local_bytes) > MAX_BYTES:
|
|
463
|
+
continue
|
|
464
|
+
last_synced[rel] = local_bytes
|
|
465
|
+
missing_seen: set = set()
|
|
466
|
+
remote_missing: set = set()
|
|
467
|
+
conflicts: set = set()
|
|
468
|
+
delete_failed: set = set()
|
|
469
|
+
cloud_reader = cloud_change_reader(client)
|
|
470
|
+
cloud_writer = cloud_change_writer(client)
|
|
471
|
+
cloud_deleter = cloud_change_deleter(client)
|
|
472
|
+
local_reader = local_change_reader(local_root)
|
|
473
|
+
local_writer = local_change_writer(local_root)
|
|
474
|
+
local_deleter = local_change_deleter(local_root)
|
|
475
|
+
|
|
476
|
+
def accept_cloud_baseline(rel: str, cloud_payload: bytes, digest: Optional[str] = None) -> None:
|
|
477
|
+
if digest is None:
|
|
478
|
+
digest = sha256_bytes(cloud_payload)
|
|
479
|
+
remote_base[rel] = digest
|
|
480
|
+
last_cloud_synced[rel] = cloud_payload
|
|
481
|
+
|
|
482
|
+
last_network_log = 0.0
|
|
483
|
+
while True:
|
|
484
|
+
time.sleep(1)
|
|
485
|
+
try:
|
|
486
|
+
try:
|
|
487
|
+
path_ignore = load_path_ignore(local_root)
|
|
488
|
+
current = scan(local_root, ignored, path_ignore)
|
|
489
|
+
except OSError as exc:
|
|
490
|
+
eprint("扫描失败: %s" % exc)
|
|
491
|
+
continue
|
|
492
|
+
remote_now = {}
|
|
493
|
+
if pull_enabled:
|
|
494
|
+
status, remote_now = client.manifest()
|
|
495
|
+
if status != 200:
|
|
496
|
+
eprint("读取云端清单失败:%s" % explain_http(status, b""))
|
|
497
|
+
remote_now = {}
|
|
498
|
+
pull_enabled = False
|
|
499
|
+
else:
|
|
500
|
+
remote_now = {rel: sha for rel, sha in remote_now.items() if not should_skip(rel, ignored, path_ignore)}
|
|
501
|
+
# 本地 → 云端
|
|
502
|
+
for rel, state in current.items():
|
|
503
|
+
if known.get(rel) == state:
|
|
504
|
+
pending.pop(rel, None)
|
|
505
|
+
continue
|
|
506
|
+
if pending.get(rel) != state:
|
|
507
|
+
pending[rel] = state
|
|
508
|
+
continue
|
|
509
|
+
full_path = os.path.join(local_root, rel)
|
|
510
|
+
try:
|
|
511
|
+
if os.path.getsize(full_path) > MAX_BYTES:
|
|
512
|
+
if rel not in conflicts:
|
|
513
|
+
eprint("未同步 %s :文件过大。" % rel)
|
|
514
|
+
conflicts.add(rel)
|
|
515
|
+
known[rel] = state
|
|
516
|
+
pending.pop(rel, None)
|
|
517
|
+
continue
|
|
518
|
+
with open(full_path, "rb") as handle:
|
|
519
|
+
new_payload = handle.read()
|
|
520
|
+
except OSError as exc:
|
|
521
|
+
eprint("读取本地失败 %s :%s" % (rel, exc))
|
|
522
|
+
continue
|
|
523
|
+
# 相对上次已接受的本地内容无字节变化:只刷新 known,避免空转上传
|
|
524
|
+
if rel in last_synced and last_synced[rel] == new_payload:
|
|
525
|
+
known[rel] = state
|
|
526
|
+
pending.pop(rel, None)
|
|
527
|
+
conflicts.discard(rel)
|
|
528
|
+
continue
|
|
529
|
+
if len(new_payload) > MAX_BYTES:
|
|
530
|
+
if rel not in conflicts:
|
|
531
|
+
eprint("未同步 %s :文件过大。" % rel)
|
|
532
|
+
conflicts.add(rel)
|
|
533
|
+
known[rel] = state
|
|
534
|
+
pending.pop(rel, None)
|
|
535
|
+
continue
|
|
536
|
+
try:
|
|
537
|
+
status_push, cloud_after, mode_info = push_watch_edit_to_cloud(
|
|
538
|
+
rel, local_root, new_payload, last_synced,
|
|
539
|
+
cloud_reader, cloud_writer, cloud_deleter,
|
|
540
|
+
last_cloud_synced,
|
|
541
|
+
)
|
|
542
|
+
except urllib.error.URLError:
|
|
543
|
+
raise
|
|
544
|
+
if status_push in ("conflict", "error"):
|
|
545
|
+
reason = {
|
|
546
|
+
"binary": "二进制文件不能按行同步。",
|
|
547
|
+
"no-baseline": "没有启动后本地基线,不能安全按行推送(没有覆盖)。",
|
|
548
|
+
"three-way": "与云端同一处改动对不上,已跳过(没有覆盖)。",
|
|
549
|
+
"cloud-deleted": "云端已删除该文件,没有用本地改动复活。",
|
|
550
|
+
}.get(mode_info, mode_info if status_push == "error" else "与云端合并不上,已跳过(没有覆盖)。")
|
|
551
|
+
# known 冻结后同一状态不会再进这里,故每条失败只打一次
|
|
552
|
+
eprint("未同步到云端 %s :%s" % (rel, reason))
|
|
553
|
+
conflicts.add(rel)
|
|
554
|
+
known[rel] = state
|
|
555
|
+
pending.pop(rel, None)
|
|
556
|
+
continue
|
|
557
|
+
known[rel] = state
|
|
558
|
+
last_synced[rel] = new_payload
|
|
559
|
+
pending.pop(rel, None)
|
|
560
|
+
if status_push == "noop":
|
|
561
|
+
if cloud_after is not None:
|
|
562
|
+
accept_cloud_baseline(rel, cloud_after)
|
|
563
|
+
remote_now[rel] = sha256_bytes(cloud_after)
|
|
564
|
+
conflicts.discard(rel)
|
|
565
|
+
delete_failed.discard(rel)
|
|
566
|
+
continue
|
|
567
|
+
# 推成功后只更新云端基线;云端若仍保留启动前独有行是预期,绝不把那些行写回本地
|
|
568
|
+
if cloud_after is not None:
|
|
569
|
+
cloud_payload = cloud_after
|
|
570
|
+
status_cloud = 200
|
|
571
|
+
else:
|
|
572
|
+
status_cloud, cloud_payload, _cloud_mode = client.pull_file(rel)
|
|
573
|
+
if status_cloud != 200:
|
|
574
|
+
if rel not in conflicts:
|
|
575
|
+
eprint("冲突 %s :已推到云端但无法回读校验,没有用本地哈希冒充基线。" % rel)
|
|
576
|
+
conflicts.add(rel)
|
|
577
|
+
continue
|
|
578
|
+
cloud_digest = sha256_bytes(cloud_payload)
|
|
579
|
+
# 推送结果若已含「推送前云端独有行」,不能当成本地已接受的云端基线,
|
|
580
|
+
# 否则本轮云端→本地会跳过,启动后才出现的云端行进不来。
|
|
581
|
+
# cloud_after == new_payload 表示云端推送前仍等于本地基线(快路径)。
|
|
582
|
+
if cloud_payload == new_payload:
|
|
583
|
+
accept_cloud_baseline(rel, cloud_payload, cloud_digest)
|
|
584
|
+
remote_now[rel] = cloud_digest
|
|
585
|
+
conflicts.discard(rel)
|
|
586
|
+
delete_failed.discard(rel)
|
|
587
|
+
missing = {rel for rel in known if rel not in current}
|
|
588
|
+
for rel in missing & missing_seen:
|
|
589
|
+
# 内容冲突冻结后用户删了本地:清掉内容冲突,继续把删除同步到云端。
|
|
590
|
+
# delete_failed 只抑制重复日志,不永久跳过:云端摘要变化或可删时仍再试。
|
|
591
|
+
conflicts.discard(rel)
|
|
592
|
+
if pull_enabled and rel in remote_now and remote_base.get(rel) != remote_now.get(rel):
|
|
593
|
+
if rel not in delete_failed:
|
|
594
|
+
eprint("冲突 %s :本地删了,云端也改了,这一轮没有覆盖。" % rel)
|
|
595
|
+
conflicts.add(rel)
|
|
596
|
+
delete_failed.add(rel)
|
|
597
|
+
continue
|
|
598
|
+
change = build_watch_change(local_root, ignored, rel, None, last_synced, deleted=True)
|
|
599
|
+
if change is None:
|
|
600
|
+
if not apply_change(client, local_root, rel, True):
|
|
601
|
+
continue
|
|
602
|
+
else:
|
|
603
|
+
if change.get("kind") == "binary":
|
|
604
|
+
eprint("未同步到云端(删除) %s :无法按行处理。" % rel)
|
|
605
|
+
conflicts.add(rel)
|
|
606
|
+
delete_failed.add(rel)
|
|
607
|
+
continue
|
|
608
|
+
applied, failed = transfer_line_changes(
|
|
609
|
+
[change], cloud_reader, cloud_writer, cloud_deleter, success_verb="本地改动"
|
|
610
|
+
)
|
|
611
|
+
if not applied or failed:
|
|
612
|
+
if rel not in delete_failed:
|
|
613
|
+
eprint("未同步到云端(删除) %s :补丁对不上,已跳过(没有删除)。" % rel)
|
|
614
|
+
conflicts.add(rel)
|
|
615
|
+
delete_failed.add(rel)
|
|
616
|
+
continue
|
|
617
|
+
known.pop(rel, None)
|
|
618
|
+
last_synced.pop(rel, None)
|
|
619
|
+
last_cloud_synced.pop(rel, None)
|
|
620
|
+
pending.pop(rel, None)
|
|
621
|
+
remote_base.pop(rel, None)
|
|
622
|
+
remote_now.pop(rel, None)
|
|
623
|
+
conflicts.discard(rel)
|
|
624
|
+
delete_failed.discard(rel)
|
|
625
|
+
missing_seen = missing
|
|
626
|
+
if not pull_enabled:
|
|
627
|
+
continue
|
|
628
|
+
# 云端 → 本地:只用 last_cloud_synced → cloud_now 的增量,绝不套用相对 HEAD 的 /v1/changes
|
|
629
|
+
for rel, digest in remote_now.items():
|
|
630
|
+
full_path = os.path.join(local_root, rel)
|
|
631
|
+
local_exists = os.path.isfile(full_path) and not os.path.islink(full_path)
|
|
632
|
+
local_bytes = None
|
|
633
|
+
local_digest = None
|
|
634
|
+
if local_exists:
|
|
635
|
+
try:
|
|
636
|
+
with open(full_path, "rb") as handle:
|
|
637
|
+
local_bytes = handle.read()
|
|
638
|
+
local_digest = sha256_bytes(local_bytes)
|
|
639
|
+
except OSError:
|
|
640
|
+
continue
|
|
641
|
+
if remote_base.get(rel) == digest:
|
|
642
|
+
if local_exists and local_digest == digest:
|
|
643
|
+
# 用磁盘当前 mtime,避免同一轮本地→云端刚 refresh 过又被 scan 旧 state 盖回去,引发假「本地改动」
|
|
644
|
+
try:
|
|
645
|
+
st = os.stat(full_path)
|
|
646
|
+
known[rel] = (st.st_mtime_ns, st.st_size)
|
|
647
|
+
except OSError:
|
|
648
|
+
known[rel] = current.get(rel) or known.get(rel)
|
|
649
|
+
last_synced[rel] = local_bytes
|
|
650
|
+
last_cloud_synced[rel] = local_bytes
|
|
651
|
+
pending.pop(rel, None)
|
|
652
|
+
conflicts.discard(rel)
|
|
653
|
+
elif not local_exists:
|
|
654
|
+
conflicts.discard(rel)
|
|
655
|
+
elif local_exists and rel not in last_cloud_synced:
|
|
656
|
+
# 启动时清单与正文不一致而跳过的分叉基线:本轮再拉并核对后记下
|
|
657
|
+
status_b, cloud_b, _mode_b = client.pull_file(rel)
|
|
658
|
+
if (
|
|
659
|
+
status_b == 200
|
|
660
|
+
and len(cloud_b) <= MAX_BYTES
|
|
661
|
+
and sha256_bytes(cloud_b) == digest
|
|
662
|
+
):
|
|
663
|
+
last_cloud_synced[rel] = cloud_b
|
|
664
|
+
last_synced[rel] = local_bytes
|
|
665
|
+
continue
|
|
666
|
+
# 云端相对启动/上次基线有新内容
|
|
667
|
+
status_c, cloud_payload, cloud_mode = client.pull_file(rel)
|
|
668
|
+
if status_c != 200:
|
|
669
|
+
eprint("未写入本地 %s :%s" % (rel, explain_http(status_c, cloud_payload)))
|
|
670
|
+
continue
|
|
671
|
+
if sha256_bytes(cloud_payload) != digest:
|
|
672
|
+
# 清单与内容短暂不一致,下一轮再试
|
|
673
|
+
continue
|
|
674
|
+
if not local_exists:
|
|
675
|
+
# 已跟踪的本地删除/删除冲突:保持缺失,绝不整份写回还原。
|
|
676
|
+
# 不用 remote_base 挡:启动时本地不存在的云端路径,启动后云端再改
|
|
677
|
+
# 应整份拉回(启动扫描不会给这类路径留下可合并基线)。
|
|
678
|
+
if (
|
|
679
|
+
rel in known
|
|
680
|
+
or rel in missing
|
|
681
|
+
or rel in missing_seen
|
|
682
|
+
or rel in delete_failed
|
|
683
|
+
or rel in conflicts
|
|
684
|
+
):
|
|
685
|
+
continue
|
|
686
|
+
# 本地不存在且非删除跟踪:整份写入当前云端内容
|
|
687
|
+
try:
|
|
688
|
+
write_file(local_root, rel, cloud_payload, cloud_mode or "644")
|
|
689
|
+
except (ValueError, OSError) as exc:
|
|
690
|
+
eprint("本地写入失败 %s :%s" % (rel, exc))
|
|
691
|
+
continue
|
|
692
|
+
print("本地写入 %s" % rel, flush=True)
|
|
693
|
+
last_synced[rel] = cloud_payload
|
|
694
|
+
accept_cloud_baseline(rel, cloud_payload, digest)
|
|
695
|
+
refresh_local_known(local_root, rel, known, last_synced)
|
|
696
|
+
pending.pop(rel, None)
|
|
697
|
+
conflicts.discard(rel)
|
|
698
|
+
continue
|
|
699
|
+
if local_digest == digest:
|
|
700
|
+
accept_cloud_baseline(rel, cloud_payload, digest)
|
|
701
|
+
known[rel] = current.get(rel) or known.get(rel)
|
|
702
|
+
last_synced[rel] = local_bytes
|
|
703
|
+
pending.pop(rel, None)
|
|
704
|
+
conflicts.discard(rel)
|
|
705
|
+
continue
|
|
706
|
+
# 本地已有文件且内容不同:只应用启动后云端增量,绝不整份覆盖
|
|
707
|
+
base = last_cloud_synced.get(rel)
|
|
708
|
+
if base is None and remote_base.get(rel) is not None and local_digest == remote_base.get(rel):
|
|
709
|
+
base = local_bytes
|
|
710
|
+
if base is None:
|
|
711
|
+
if rel not in conflicts:
|
|
712
|
+
eprint("冲突 %s :云端有更新但没有启动后云端基线,没有整份覆盖本地。" % rel)
|
|
713
|
+
conflicts.add(rel)
|
|
714
|
+
continue
|
|
715
|
+
if base == cloud_payload:
|
|
716
|
+
accept_cloud_baseline(rel, cloud_payload, digest)
|
|
717
|
+
continue
|
|
718
|
+
ok_m, merged = try_merge_cloud_onto_local(
|
|
719
|
+
rel, local_root, base, cloud_payload, cloud_mode or "644",
|
|
720
|
+
local_reader, local_writer, local_deleter,
|
|
721
|
+
)
|
|
722
|
+
if ok_m:
|
|
723
|
+
# 合完刷新 known,避免下一轮把「下载写盘」误当成用户编辑再上传
|
|
724
|
+
refresh_local_known(local_root, rel, known, last_synced)
|
|
725
|
+
if merged is not None:
|
|
726
|
+
last_synced[rel] = merged
|
|
727
|
+
pending.pop(rel, None)
|
|
728
|
+
accept_cloud_baseline(rel, cloud_payload, digest)
|
|
729
|
+
# 本地仍可能与云端不同(启动前本地独有行):这是预期,绝不回推云端
|
|
730
|
+
conflicts.discard(rel)
|
|
731
|
+
continue
|
|
732
|
+
if rel not in conflicts:
|
|
733
|
+
eprint("冲突 %s :云端行级变更合不到本地(同一处改动对不上),没有整份覆盖。" % rel)
|
|
734
|
+
conflicts.add(rel)
|
|
735
|
+
gone = {rel for rel in remote_base if rel not in remote_now}
|
|
736
|
+
if remote_base and len(gone) > max(20, len(remote_base) // 5):
|
|
737
|
+
eprint("云端清单一下子少了 %d 个文件,已跳过按云端删除。" % len(gone))
|
|
738
|
+
gone = set()
|
|
739
|
+
for rel in gone & remote_missing:
|
|
740
|
+
if rel in current and known.get(rel) != current.get(rel):
|
|
741
|
+
if rel not in conflicts:
|
|
742
|
+
eprint("冲突 %s :云端删了,本地也改了,这一轮没有覆盖。" % rel)
|
|
743
|
+
conflicts.add(rel)
|
|
744
|
+
continue
|
|
745
|
+
if rel in current:
|
|
746
|
+
full_path = os.path.join(local_root, rel)
|
|
747
|
+
try:
|
|
748
|
+
with open(full_path, "rb") as handle:
|
|
749
|
+
local_bytes = handle.read()
|
|
750
|
+
except OSError as exc:
|
|
751
|
+
eprint("读取本地失败 %s :%s" % (rel, exc))
|
|
752
|
+
continue
|
|
753
|
+
cloud_base = last_cloud_synced.get(rel)
|
|
754
|
+
# 只与上次云端基线比较。启动时两边不一致会把当时本地字节写入
|
|
755
|
+
# last_synced;若用它判可删,预启动本地独有行会被误删。
|
|
756
|
+
safe = cloud_base is not None and local_bytes == cloud_base
|
|
757
|
+
if not safe:
|
|
758
|
+
if rel not in conflicts:
|
|
759
|
+
eprint("冲突 %s :云端删了,但本地与上次云端基线不一致,没有删除本地。" % rel)
|
|
760
|
+
conflicts.add(rel)
|
|
761
|
+
continue
|
|
762
|
+
try:
|
|
763
|
+
remove_file(local_root, rel)
|
|
764
|
+
except (ValueError, OSError) as exc:
|
|
765
|
+
eprint("按云端删除本地文件失败 %s :%s" % (rel, exc))
|
|
766
|
+
continue
|
|
767
|
+
print("本地写入(删除) %s" % rel, flush=True)
|
|
768
|
+
known.pop(rel, None)
|
|
769
|
+
last_synced.pop(rel, None)
|
|
770
|
+
last_cloud_synced.pop(rel, None)
|
|
771
|
+
pending.pop(rel, None)
|
|
772
|
+
remote_base.pop(rel, None)
|
|
773
|
+
conflicts.discard(rel)
|
|
774
|
+
remote_missing = gone
|
|
775
|
+
except urllib.error.URLError as exc:
|
|
776
|
+
reason = getattr(exc, "reason", exc)
|
|
777
|
+
now = time.time()
|
|
778
|
+
if now - last_network_log >= 30:
|
|
779
|
+
eprint("网络超时或中断,稍后重试:%s" % reason)
|
|
780
|
+
last_network_log = now
|
|
781
|
+
time.sleep(4)
|
|
782
|
+
continue
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def load_remote_base(client: Client, ignored: set, ignore_status: int, path_ignore: PathIgnore = None) -> Tuple[Dict[str, str], bool]:
|
|
790
|
+
if ignore_status == 404:
|
|
791
|
+
return {}, False
|
|
792
|
+
status, files = client.manifest()
|
|
793
|
+
if status == 404:
|
|
794
|
+
return {}, False
|
|
795
|
+
if status != 200:
|
|
796
|
+
eprint("读取云端清单失败:%s" % explain_http(status, b""))
|
|
797
|
+
return {}, False
|
|
798
|
+
return {rel: sha for rel, sha in files.items() if not should_skip(rel, ignored, path_ignore)}, True
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def remote_conflict(local_root: str, rel: str, remote_base: Dict[str, str], remote_now: Dict[str, str]) -> bool:
|
|
802
|
+
if rel not in remote_base or rel not in remote_now:
|
|
803
|
+
return False
|
|
804
|
+
if remote_now[rel] == remote_base[rel]:
|
|
805
|
+
return False
|
|
806
|
+
full = os.path.join(local_root, rel)
|
|
807
|
+
if not os.path.isfile(full) or os.path.islink(full):
|
|
808
|
+
return False
|
|
809
|
+
try:
|
|
810
|
+
return sha256_file(full) != remote_now[rel]
|
|
811
|
+
except OSError:
|
|
812
|
+
return False
|