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/cs_sync/git.py ADDED
@@ -0,0 +1,683 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import os
5
+ import subprocess
6
+ import tempfile
7
+ import urllib.error
8
+ from typing import Dict, Optional, Tuple
9
+
10
+ from cs_sync.files import file_mode, sha256_bytes
11
+ from cs_sync.ignore import IGNORE_PREFIXES, PathIgnore, load_path_ignore, should_skip
12
+ from cs_sync.protocol import MAX_BYTES, eprint
13
+
14
+ class StageError(Exception):
15
+ pass
16
+
17
+
18
+ def is_git_repo(path: str) -> bool:
19
+ git_path = os.path.join(path, ".git")
20
+ return os.path.isdir(git_path) or os.path.isfile(git_path)
21
+
22
+
23
+ def git_repos(root: str, ignored: set, path_ignore: PathIgnore = None) -> list:
24
+ root = os.path.abspath(root)
25
+ if path_ignore is None:
26
+ path_ignore = load_path_ignore(root)
27
+ if is_git_repo(root):
28
+ return [root]
29
+ found = []
30
+ if not os.path.isdir(root):
31
+ return found
32
+ for dirpath, dirnames, _filenames in os.walk(root):
33
+ kept = []
34
+ for name in dirnames:
35
+ if name in ignored or name == ".git" or name.startswith(IGNORE_PREFIXES):
36
+ continue
37
+ rel_dir = os.path.relpath(os.path.join(dirpath, name), root).replace(os.sep, "/")
38
+ if should_skip(rel_dir, ignored, path_ignore, is_dir=True):
39
+ continue
40
+ full = os.path.join(dirpath, name)
41
+ if is_git_repo(full):
42
+ found.append(full)
43
+ continue
44
+ kept.append(name)
45
+ dirnames[:] = kept
46
+ return found
47
+
48
+
49
+ def git_run(repo: str, args: list) -> subprocess.CompletedProcess:
50
+ env = dict(os.environ)
51
+ env["GIT_TERMINAL_PROMPT"] = "0"
52
+ try:
53
+ return subprocess.run(
54
+ ["git", "-C", repo] + args,
55
+ stdout=subprocess.PIPE,
56
+ stderr=subprocess.PIPE,
57
+ check=False,
58
+ env=env,
59
+ )
60
+ except OSError as exc:
61
+ raise StageError("找不到 git 命令:%s" % exc)
62
+
63
+
64
+ def stage_changes(root: str, ignored: set) -> Tuple[list, int]:
65
+ repos = git_repos(root, ignored, load_path_ignore(root))
66
+ if not repos:
67
+ print("项目里没有 git 仓库,跳过暂存。", flush=True)
68
+ return [], 0
69
+ staged = []
70
+ for repo in repos:
71
+ rel = os.path.relpath(repo, os.path.abspath(root)).replace(os.sep, "/")
72
+ status = git_run(repo, ["status", "--porcelain", "--untracked-files=all"])
73
+ if status.returncode != 0:
74
+ detail = status.stderr.decode("utf-8", errors="replace").strip()
75
+ raise StageError("暂存失败 %s :%s" % (rel, detail or "git status 失败"))
76
+ if not status.stdout.strip():
77
+ continue
78
+ added = git_run(repo, ["add", "-A"])
79
+ if added.returncode != 0:
80
+ detail = added.stderr.decode("utf-8", errors="replace").strip()
81
+ raise StageError("暂存失败 %s :%s" % (rel, detail or "git add -A 失败"))
82
+ print("暂存 %s" % rel, flush=True)
83
+ staged.append(rel)
84
+ if not staged:
85
+ print("没有需要暂存的修改。", flush=True)
86
+ return staged, len(repos)
87
+
88
+
89
+ def git_bytes(repo: str, args: list) -> bytes:
90
+ result = git_run(repo, ["-c", "core.quotepath=false"] + args)
91
+ if result.returncode != 0:
92
+ detail = result.stderr.decode("utf-8", errors="replace").strip()
93
+ raise StageError(detail or "git 命令失败")
94
+ return result.stdout
95
+
96
+
97
+ def parse_name_status(raw: bytes) -> list:
98
+ parts = raw.split(b"\0")
99
+ found = []
100
+ index = 0
101
+ while index + 1 < len(parts) and parts[index]:
102
+ status = parts[index].decode("utf-8", errors="strict")
103
+ path = parts[index + 1].decode("utf-8", errors="strict")
104
+ found.append((status[:1], path.replace("\\", "/")))
105
+ index += 2
106
+ return found
107
+
108
+
109
+ def project_rel(prefix: str, path: str) -> str:
110
+ path = path.replace("\\", "/")
111
+ if prefix in ("", "."):
112
+ return path
113
+ return prefix + "/" + path
114
+
115
+
116
+ def encode_bytes(payload: bytes) -> str:
117
+ return base64.b64encode(payload).decode("ascii")
118
+
119
+
120
+ def decode_bytes(value: str) -> bytes:
121
+ return base64.b64decode(value.encode("ascii"))
122
+
123
+
124
+ def collect_changes(root: str, ignored: set) -> Tuple[list, int]:
125
+ path_ignore = load_path_ignore(root)
126
+ repos = git_repos(root, ignored, path_ignore)
127
+ changes = []
128
+ seen = set()
129
+ for repo in repos:
130
+ prefix = os.path.relpath(repo, os.path.abspath(root)).replace(os.sep, "/")
131
+ has_head = git_run(repo, ["rev-parse", "--verify", "HEAD"]).returncode == 0
132
+ diff_base = ["diff", "HEAD", "--no-renames"] if has_head else ["diff", "--cached", "--no-renames"]
133
+ listed = parse_name_status(git_bytes(repo, diff_base + ["--name-status", "-z"]))
134
+ for status, path in listed:
135
+ rel = project_rel(prefix, path)
136
+ if should_skip(rel, ignored, path_ignore) or rel in seen:
137
+ continue
138
+ full = os.path.join(repo, *path.split("/"))
139
+ if status == "D":
140
+ patch = git_bytes(repo, diff_base + ["-U3", "--", path])
141
+ changes.append({"path": rel, "repoPath": path, "kind": "delete", "patch": encode_bytes(patch)})
142
+ seen.add(rel)
143
+ continue
144
+ if os.path.islink(full) or not os.path.isfile(full):
145
+ continue
146
+ if os.path.getsize(full) > MAX_BYTES:
147
+ changes.append({"path": rel, "repoPath": path, "kind": "binary"})
148
+ seen.add(rel)
149
+ continue
150
+ numstat = git_bytes(repo, diff_base + ["--numstat", "-z", "--", path])
151
+ if numstat.startswith(b"-\t-\t"):
152
+ if status == "A":
153
+ with open(full, "rb") as handle:
154
+ payload = handle.read()
155
+ changes.append({
156
+ "path": rel,
157
+ "repoPath": path,
158
+ "kind": "add",
159
+ "mode": file_mode(full),
160
+ "content": encode_bytes(payload),
161
+ })
162
+ else:
163
+ changes.append({"path": rel, "repoPath": path, "kind": "binary"})
164
+ seen.add(rel)
165
+ continue
166
+ if status == "A":
167
+ with open(full, "rb") as handle:
168
+ payload = handle.read()
169
+ changes.append({
170
+ "path": rel,
171
+ "repoPath": path,
172
+ "kind": "add",
173
+ "mode": file_mode(full),
174
+ "content": encode_bytes(payload),
175
+ })
176
+ seen.add(rel)
177
+ continue
178
+ patch = git_bytes(repo, diff_base + ["-U3", "--", path])
179
+ with open(full, "rb") as handle:
180
+ payload = handle.read()
181
+ changes.append({
182
+ "path": rel,
183
+ "repoPath": path,
184
+ "kind": "modify",
185
+ "mode": file_mode(full),
186
+ "patch": encode_bytes(patch),
187
+ "content": encode_bytes(payload),
188
+ })
189
+ seen.add(rel)
190
+ others = git_bytes(repo, ["ls-files", "--others", "--exclude-standard", "-z"])
191
+ for raw in others.split(b"\0"):
192
+ if not raw:
193
+ continue
194
+ path = raw.decode("utf-8").replace("\\", "/")
195
+ rel = project_rel(prefix, path)
196
+ full = os.path.join(repo, *path.split("/"))
197
+ if should_skip(rel, ignored, path_ignore) or rel in seen or os.path.islink(full) or not os.path.isfile(full):
198
+ continue
199
+ if os.path.getsize(full) > MAX_BYTES:
200
+ continue
201
+ with open(full, "rb") as handle:
202
+ payload = handle.read()
203
+ changes.append({
204
+ "path": rel,
205
+ "repoPath": path,
206
+ "kind": "add",
207
+ "mode": file_mode(full),
208
+ "content": encode_bytes(payload),
209
+ })
210
+ seen.add(rel)
211
+ return changes, len(repos)
212
+
213
+
214
+ def find_repo_for_rel(root: str, rel: str) -> Tuple[Optional[str], str, str]:
215
+ """返回 (repo绝对路径, 项目内repo前缀, repo内相对路径)。找不到则 (None, '', '')."""
216
+ root = os.path.abspath(root)
217
+ full = os.path.join(root, *rel.split("/"))
218
+ start = full if os.path.isdir(full) else os.path.dirname(full)
219
+ cur = start
220
+ while True:
221
+ if is_git_repo(cur):
222
+ prefix = os.path.relpath(cur, root).replace(os.sep, "/")
223
+ if prefix == ".":
224
+ return cur, "", rel
225
+ if rel == prefix:
226
+ return cur, prefix, ""
227
+ if rel.startswith(prefix + "/"):
228
+ return cur, prefix, rel[len(prefix) + 1 :]
229
+ return None, "", ""
230
+ if cur == root:
231
+ break
232
+ parent = os.path.dirname(cur)
233
+ if parent == cur:
234
+ break
235
+ cur = parent
236
+ if is_git_repo(root):
237
+ return root, "", rel
238
+ return None, "", ""
239
+
240
+
241
+ def collect_path_change(root: str, ignored: set, rel: str) -> Optional[dict]:
242
+ """只收集某一个项目相对路径相对 HEAD(或无 HEAD 时相对 index)的行级变更。"""
243
+ path_ignore = load_path_ignore(root)
244
+ if should_skip(rel, ignored, path_ignore):
245
+ return None
246
+ repo, prefix, repo_path = find_repo_for_rel(root, rel)
247
+ if not repo or not repo_path:
248
+ return None
249
+ has_head = git_run(repo, ["rev-parse", "--verify", "HEAD"]).returncode == 0
250
+ diff_base = ["diff", "HEAD", "--no-renames"] if has_head else ["diff", "--cached", "--no-renames"]
251
+ full = os.path.join(repo, *repo_path.split("/"))
252
+ listed = parse_name_status(git_bytes(repo, diff_base + ["--name-status", "-z", "--", repo_path]))
253
+ status = None
254
+ for item_status, item_path in listed:
255
+ if item_path.replace("\\", "/") == repo_path:
256
+ status = item_status
257
+ break
258
+ if status == "D" or (status is None and not os.path.exists(full)):
259
+ # 可能是已跟踪删除
260
+ if status == "D":
261
+ patch = git_bytes(repo, diff_base + ["-U3", "--", repo_path])
262
+ return {"path": rel, "repoPath": repo_path, "kind": "delete", "patch": encode_bytes(patch)}
263
+ # 未跟踪且文件不在,不算变更
264
+ tracked = git_run(repo, ["ls-files", "--", repo_path])
265
+ if tracked.returncode == 0 and tracked.stdout.strip():
266
+ patch = git_bytes(repo, diff_base + ["-U3", "--", repo_path])
267
+ return {"path": rel, "repoPath": repo_path, "kind": "delete", "patch": encode_bytes(patch)}
268
+ return None
269
+ if status is None:
270
+ # 未跟踪新文件
271
+ others = git_run(repo, ["ls-files", "--others", "--exclude-standard", "-z", "--", repo_path])
272
+ if others.returncode != 0 or repo_path.encode("utf-8") not in others.stdout.split(b"\0"):
273
+ # 也可能路径恰好匹配
274
+ found = False
275
+ for raw in others.stdout.split(b"\0"):
276
+ if raw.decode("utf-8", errors="replace").replace("\\", "/") == repo_path:
277
+ found = True
278
+ break
279
+ if not found:
280
+ return None
281
+ if os.path.islink(full) or not os.path.isfile(full):
282
+ return None
283
+ if os.path.getsize(full) > MAX_BYTES:
284
+ return None
285
+ with open(full, "rb") as handle:
286
+ payload = handle.read()
287
+ return {
288
+ "path": rel,
289
+ "repoPath": repo_path,
290
+ "kind": "add",
291
+ "mode": file_mode(full),
292
+ "content": encode_bytes(payload),
293
+ }
294
+ if os.path.islink(full) or not os.path.isfile(full):
295
+ return None
296
+ if os.path.getsize(full) > MAX_BYTES:
297
+ return {"path": rel, "repoPath": repo_path, "kind": "binary"}
298
+ numstat = git_bytes(repo, diff_base + ["--numstat", "-z", "--", repo_path])
299
+ if numstat.startswith(b"-\t-\t"):
300
+ if status == "A":
301
+ with open(full, "rb") as handle:
302
+ payload = handle.read()
303
+ return {
304
+ "path": rel,
305
+ "repoPath": repo_path,
306
+ "kind": "add",
307
+ "mode": file_mode(full),
308
+ "content": encode_bytes(payload),
309
+ }
310
+ return {"path": rel, "repoPath": repo_path, "kind": "binary"}
311
+ if status == "A":
312
+ with open(full, "rb") as handle:
313
+ payload = handle.read()
314
+ return {
315
+ "path": rel,
316
+ "repoPath": repo_path,
317
+ "kind": "add",
318
+ "mode": file_mode(full),
319
+ "content": encode_bytes(payload),
320
+ }
321
+ patch = git_bytes(repo, diff_base + ["-U3", "--", repo_path])
322
+ with open(full, "rb") as handle:
323
+ payload = handle.read()
324
+ return {
325
+ "path": rel,
326
+ "repoPath": repo_path,
327
+ "kind": "modify",
328
+ "mode": file_mode(full),
329
+ "patch": encode_bytes(patch),
330
+ "content": encode_bytes(payload),
331
+ }
332
+
333
+
334
+ def unified_diff_patch(old: bytes, new: bytes, repo_path: str) -> bytes:
335
+ """生成可被 git apply 的 unified diff(保留原始换行;正确处理无结尾换行)。"""
336
+ import difflib
337
+ import re
338
+ old_text = old.decode("utf-8")
339
+ new_text = new.decode("utf-8")
340
+
341
+ def split_keep(text: str):
342
+ if text == "":
343
+ return []
344
+ return text.splitlines(keepends=True)
345
+
346
+ def missing_final_nl(lines) -> bool:
347
+ return bool(lines) and not (lines[-1].endswith("\n") or lines[-1].endswith("\r"))
348
+
349
+ old_lines = split_keep(old_text)
350
+ new_lines = split_keep(new_text)
351
+ old_missing = missing_final_nl(old_lines)
352
+ new_missing = missing_final_nl(new_lines)
353
+ # 行已带原始换行(含 CRLF);lineterm 必须为空,避免重复追加
354
+ parts = list(
355
+ difflib.unified_diff(
356
+ old_lines,
357
+ new_lines,
358
+ fromfile="a/" + repo_path,
359
+ tofile="b/" + repo_path,
360
+ lineterm="",
361
+ )
362
+ )
363
+ if not parts:
364
+ return b""
365
+ # 无换行标记只能跟在「真正对应文件最后一行」的补丁行后。
366
+ # 用 @@ 行号判断;末行未出现在 hunk 中时不要输出标记,否则 git apply
367
+ # 会把标记误贴到仍带换行的上下文行上,吃掉换行并把下一行拼上。
368
+ # 文件头 / hunk 头只在进入正文前识别;hunk 内以 -- / ++ 开头的正文不得当头
369
+ def is_diff_header(line: str, in_hunk: bool) -> bool:
370
+ if not line:
371
+ return not in_hunk
372
+ if line.startswith("@@"):
373
+ return True
374
+ if not in_hunk and (line.startswith("--- ") or line.startswith("+++ ")):
375
+ return True
376
+ return False
377
+
378
+ hunk_re = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
379
+ out = []
380
+ in_hunk = False
381
+ old_lineno = 0 # 1-based:下一条旧侧行号
382
+ new_lineno = 0
383
+ old_n = len(old_lines)
384
+ new_n = len(new_lines)
385
+ for line in parts:
386
+ if is_diff_header(line, in_hunk):
387
+ if line.startswith("@@"):
388
+ in_hunk = True
389
+ match = hunk_re.match(line)
390
+ if match:
391
+ old_lineno = int(match.group(1))
392
+ new_lineno = int(match.group(3))
393
+ out.append(line if line.endswith("\n") else line + "\n")
394
+ continue
395
+ tag = line[0]
396
+ body = line[1:]
397
+ if body.endswith("\n"):
398
+ out.append(line)
399
+ else:
400
+ out.append(line + "\n")
401
+ hit_old_last = False
402
+ hit_new_last = False
403
+ if tag in (" ", "-"):
404
+ # lineno 为 0 表示空文件侧,不应当成「最后一行」
405
+ if old_lineno >= 1 and old_lineno == old_n:
406
+ hit_old_last = True
407
+ if old_lineno > 0:
408
+ old_lineno += 1
409
+ if tag in (" ", "+"):
410
+ if new_lineno >= 1 and new_lineno == new_n:
411
+ hit_new_last = True
412
+ if new_lineno > 0:
413
+ new_lineno += 1
414
+ # 同一上下文行同时是两侧最后一行时只写一条标记
415
+ if (old_missing and hit_old_last) or (new_missing and hit_new_last):
416
+ out.append("\\n")
417
+ return "".join(out).encode("utf-8")
418
+
419
+
420
+ def empty_file_delete_patch(repo_path: str) -> bytes:
421
+ """空文件删除:difflib 无法生成有效补丁,用 git 风格 deleted 头。"""
422
+ return (
423
+ "diff --git a/%(p)s b/%(p)s\n"
424
+ "deleted file mode 100644\n"
425
+ "index e69de29..0000000\n"
426
+ "--- a/%(p)s\n"
427
+ "+++ /dev/null\n"
428
+ ) % {"p": repo_path}
429
+
430
+
431
+ def change_from_contents(
432
+ rel: str,
433
+ repo_path: str,
434
+ old: bytes,
435
+ new: bytes,
436
+ mode: str,
437
+ deleted: bool = False,
438
+ ) -> Optional[dict]:
439
+ """把两次内容做成与 transfer_line_changes 兼容的变更(相对上次已同步内容)。
440
+
441
+ 空字节表示空文件内容,不是删除;只有 deleted=True 才生成 delete。
442
+ """
443
+ if not deleted and old == new:
444
+ return None
445
+ if not repo_path:
446
+ repo_path = rel
447
+ try:
448
+ old.decode("utf-8")
449
+ new.decode("utf-8")
450
+ except UnicodeDecodeError:
451
+ return {"path": rel, "repoPath": repo_path, "kind": "binary"}
452
+ if deleted:
453
+ if old == b"":
454
+ patch = empty_file_delete_patch(repo_path)
455
+ else:
456
+ patch = unified_diff_patch(old, b"", repo_path)
457
+ if not patch.strip():
458
+ patch = empty_file_delete_patch(repo_path)
459
+ return {"path": rel, "repoPath": repo_path, "kind": "delete", "patch": encode_bytes(patch)}
460
+ # 空↔有内容一律按 modify(对端已有空文件时不能标成 add)
461
+ patch = unified_diff_patch(old, new, repo_path)
462
+ if not patch.strip():
463
+ return None
464
+ return {
465
+ "path": rel,
466
+ "repoPath": repo_path,
467
+ "kind": "modify",
468
+ "mode": mode,
469
+ "patch": encode_bytes(patch),
470
+ "content": encode_bytes(new),
471
+ }
472
+
473
+
474
+ def build_watch_change(root: str, ignored: set, rel: str, new_payload: Optional[bytes], last_synced: Dict[str, bytes], deleted: bool = False) -> Optional[dict]:
475
+ """为 sync 监视生成单文件行级变更:优先相对上次已同步内容,否则相对 git HEAD。"""
476
+ repo, _prefix, repo_path = find_repo_for_rel(root, rel)
477
+ if not repo_path:
478
+ repo_path = rel
479
+ if deleted:
480
+ old = last_synced.get(rel)
481
+ if old is not None:
482
+ # 有基线即可打删除补丁;无 git 仓库时也不能整份强删云端分叉内容
483
+ return change_from_contents(rel, repo_path, old, b"", "644", deleted=True)
484
+ if not repo:
485
+ return None
486
+ return collect_path_change(root, ignored, rel)
487
+ if not repo:
488
+ return None
489
+ if new_payload is None:
490
+ return None
491
+ mode = "644"
492
+ full = os.path.join(root, *rel.split("/"))
493
+ if os.path.isfile(full):
494
+ mode = file_mode(full)
495
+ if rel in last_synced:
496
+ return change_from_contents(rel, repo_path, last_synced[rel], new_payload, mode)
497
+ return collect_path_change(root, ignored, rel)
498
+
499
+
500
+ def apply_saved_patch(
501
+ repo_path: str,
502
+ dest: Optional[bytes],
503
+ patch: bytes,
504
+ reverse: bool = False,
505
+ ) -> Tuple[str, Optional[bytes]]:
506
+ with tempfile.TemporaryDirectory(prefix="cs-sync-patch-") as tmp:
507
+ patch_path = os.path.join(tmp, "change.diff")
508
+ with open(patch_path, "wb") as handle:
509
+ handle.write(patch)
510
+ if dest is not None:
511
+ dest_path = os.path.join(tmp, *repo_path.split("/"))
512
+ parent = os.path.dirname(dest_path)
513
+ if parent:
514
+ os.makedirs(parent, exist_ok=True)
515
+ with open(dest_path, "wb") as handle:
516
+ handle.write(dest)
517
+ env = dict(os.environ)
518
+ env["GIT_TERMINAL_PROMPT"] = "0"
519
+ cmd = ["git", "apply", "--unsafe-paths", "--whitespace=nowarn"]
520
+ if reverse:
521
+ cmd.append("--reverse")
522
+ cmd.append(patch_path)
523
+ result = subprocess.run(
524
+ cmd,
525
+ cwd=tmp,
526
+ stdout=subprocess.PIPE,
527
+ stderr=subprocess.PIPE,
528
+ check=False,
529
+ env=env,
530
+ )
531
+ if result.returncode != 0:
532
+ return "failed", None
533
+ dest_path = os.path.join(tmp, *repo_path.split("/"))
534
+ if not os.path.exists(dest_path):
535
+ return "deleted", None
536
+ with open(dest_path, "rb") as handle:
537
+ return "ok", handle.read()
538
+
539
+
540
+ def change_target_bytes(change: dict) -> Optional[bytes]:
541
+ """变更目标内容(工作区新内容);没有 content 字段时返回 None。"""
542
+ raw = change.get("content")
543
+ if not raw:
544
+ return None
545
+ try:
546
+ return decode_bytes(str(raw))
547
+ except ValueError:
548
+ return None
549
+
550
+
551
+ def patch_already_applied(repo_path: str, dest: Optional[bytes], patch: bytes) -> bool:
552
+ """对端是否已含本补丁结果(反向 git apply 能打上)。"""
553
+ if dest is None:
554
+ return False
555
+ outcome, _payload = apply_saved_patch(repo_path, dest, patch, reverse=True)
556
+ return outcome in ("ok", "deleted")
557
+
558
+
559
+ def transfer_line_changes(
560
+ changes: list,
561
+ read_dest,
562
+ write_dest,
563
+ delete_dest,
564
+ success_verb: Optional[str] = None,
565
+ quiet: bool = False,
566
+ ) -> Tuple[int, int]:
567
+ """按行把变更写到对面。
568
+
569
+ success_verb 为「本地改动」或「本地写入」时按方向打成功日志;
570
+ 兼容旧值「上传」「下载」。否则沿用新增/同步行/删除。
571
+ quiet=True 时只静默失败日志(用于三路合并前的试探,避免假警报);成功日志仍打印。
572
+ """
573
+ applied = failed = 0
574
+ verb = {"上传": "本地改动", "下载": "本地写入"}.get(success_verb or "", success_verb)
575
+ if verb in ("本地改动", "本地写入"):
576
+ add_verb = verb
577
+ modify_verb = verb
578
+ delete_verb = verb + "(删除)"
579
+ fail_prefix = "未同步到云端" if verb == "本地改动" else "未写入本地"
580
+ else:
581
+ add_verb = "新增"
582
+ modify_verb = "同步行"
583
+ delete_verb = "删除"
584
+ fail_prefix = "未应用"
585
+
586
+ def fail(rel: str, reason: str) -> None:
587
+ nonlocal failed
588
+ failed += 1
589
+ if not quiet:
590
+ eprint("%s %s :%s" % (fail_prefix, rel, reason))
591
+
592
+ def already_synced(rel: str) -> None:
593
+ nonlocal applied
594
+ applied += 1
595
+ if not quiet:
596
+ print("已同步(跳过) %s" % rel, flush=True)
597
+
598
+ for change in changes:
599
+ rel = str(change.get("path") or "")
600
+ kind = str(change.get("kind") or "")
601
+ repo_path = str(change.get("repoPath") or rel)
602
+ mode = str(change.get("mode") or "644")
603
+ try:
604
+ exists, dest, dest_mode = read_dest(rel)
605
+ except urllib.error.URLError:
606
+ raise
607
+ except OSError as exc:
608
+ fail(rel, str(exc))
609
+ continue
610
+ if kind == "binary":
611
+ fail(rel, "二进制文件不能按行同步。")
612
+ continue
613
+ if kind == "add":
614
+ try:
615
+ payload = decode_bytes(str(change.get("content") or ""))
616
+ except ValueError:
617
+ fail(rel, "新增内容无法读取。")
618
+ continue
619
+ if exists:
620
+ if dest == payload:
621
+ already_synced(rel)
622
+ else:
623
+ fail(rel, "对方已有这个文件,已跳过(没有覆盖)。")
624
+ continue
625
+ if write_dest(rel, payload, mode):
626
+ print("%s %s" % (add_verb, rel), flush=True)
627
+ applied += 1
628
+ else:
629
+ failed += 1
630
+ continue
631
+ patch_raw = change.get("patch")
632
+ if not patch_raw:
633
+ fail(rel, "没有 git diff。")
634
+ continue
635
+ try:
636
+ patch = decode_bytes(str(patch_raw))
637
+ except ValueError:
638
+ fail(rel, "git diff 无法读取。")
639
+ continue
640
+ if kind == "delete":
641
+ if not exists:
642
+ # 对端已无此文件:视为删除已同步,避免调用方误判失败
643
+ already_synced(rel)
644
+ continue
645
+ outcome, payload = apply_saved_patch(repo_path, dest, patch)
646
+ removed = outcome == "deleted" or (outcome == "ok" and payload == b"")
647
+ if removed and delete_dest(rel):
648
+ print("%s %s" % (delete_verb, rel), flush=True)
649
+ applied += 1
650
+ else:
651
+ fail(rel, "补丁对不上,已跳过(没有删除)。")
652
+ continue
653
+ if not exists:
654
+ fail(rel, "对方没有这个文件,无法按行补上。")
655
+ continue
656
+ target = change_target_bytes(change)
657
+ # 对端已是目标态:绝不再打补丁(避免插入类 hunk 重复写入)
658
+ if (target is not None and dest == target) or patch_already_applied(repo_path, dest, patch):
659
+ already_synced(rel)
660
+ continue
661
+ outcome, payload = apply_saved_patch(repo_path, dest, patch)
662
+ if outcome == "ok" and payload is not None:
663
+ if payload == dest:
664
+ already_synced(rel)
665
+ continue
666
+ # 错误的 \ No newline 标记可能让 git apply 返回 0 却写坏内容。
667
+ # 若结果不等于目标,且同一补丁无法反向还原 dest,则视为写坏并跳过。
668
+ # 对端另有 hunk 外改动时,反向仍能还原 dest,结果可以不等于目标。
669
+ if target is not None and payload != target:
670
+ back_ok, back = apply_saved_patch(repo_path, payload, patch, reverse=True)
671
+ if not (back_ok == "ok" and back == dest):
672
+ fail(rel, "补丁对不上,已跳过(没有覆盖)。")
673
+ continue
674
+ if write_dest(rel, payload, dest_mode or mode):
675
+ print("%s %s" % (modify_verb, rel), flush=True)
676
+ applied += 1
677
+ else:
678
+ failed += 1
679
+ elif target is not None and dest == target:
680
+ already_synced(rel)
681
+ else:
682
+ fail(rel, "补丁对不上,已跳过(没有覆盖)。")
683
+ return applied, failed