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.
@@ -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