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/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
|