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,730 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import threading
9
+ import urllib.parse
10
+ from http.server import ThreadingHTTPServer
11
+
12
+ from cs_sync.client import Client
13
+ from cs_sync.config import compose, load_host, load_json, project_files, resolve_project
14
+ from cs_sync.files import FileIndex, scan, sha256_bytes, sha256_file, write_file
15
+ from cs_sync.git import (
16
+ apply_saved_patch,
17
+ build_watch_change,
18
+ change_from_contents,
19
+ collect_changes,
20
+ decode_bytes,
21
+ encode_bytes,
22
+ stage_changes,
23
+ transfer_line_changes,
24
+ )
25
+ from cs_sync.ignore import DEFAULT_IGNORE_DIRS, ignore_dirs
26
+ from cs_sync.merge import apply_line_delta_onto, try_merge_cloud_onto_local
27
+ from cs_sync.protocol import MAX_BYTES, ROUTE_PULL
28
+ from cs_sync.server import listening_pids, make_handler, process_command_line
29
+ from cs_sync.sync import (
30
+ copy_cloud_to_local,
31
+ copy_local_to_cloud,
32
+ preview_copy_cloud_to_local,
33
+ preview_copy_local_to_cloud,
34
+ preview_line_changes,
35
+ preview_sync_divergence,
36
+ push_watch_edit_to_cloud,
37
+ )
38
+
39
+ def cmd_self_test(_args: argparse.Namespace) -> None:
40
+ import secrets
41
+ import shutil
42
+
43
+ from cs_sync.cli import (
44
+ build_parser,
45
+ cmd_init,
46
+ cmd_remove,
47
+ normalize_remote_path,
48
+ package_version,
49
+ recv_command,
50
+ )
51
+
52
+ token = secrets.token_urlsafe(32)
53
+ local = tempfile.mkdtemp(prefix="cs-sync-local-")
54
+ remote = tempfile.mkdtemp(prefix="cs-sync-remote-")
55
+ port = 0
56
+ try:
57
+ index = FileIndex(remote)
58
+ server = ThreadingHTTPServer(("127.0.0.1", 0), make_handler(remote, token, index))
59
+ port = server.server_address[1]
60
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
61
+ thread.start()
62
+ config = {
63
+ "localRoot": local,
64
+ "endpoint": "http://127.0.0.1:%d" % port,
65
+ "token": token,
66
+ "cookie": "",
67
+ "insecure": False,
68
+ "ignoreDirs": sorted(DEFAULT_IGNORE_DIRS),
69
+ }
70
+ client = Client(config)
71
+ status, body = client.health()
72
+ assert status == 200 and body.strip() == b"ok", body
73
+ if sys.platform.startswith("linux"):
74
+ seen = listening_pids(port)
75
+ assert os.getpid() in seen, "listening_pids 应能看到本机临时 recv:%s" % seen
76
+ assert process_command_line(os.getpid())
77
+ status, _ = client.put_file("../outside.txt", b"nope", "644")
78
+ assert status == 400
79
+ status, body = client.put_file("ok.txt", b"hello", "644")
80
+ assert status == 200 and body.strip() == b"ok", (status, body)
81
+ with open(os.path.join(remote, "ok.txt"), "rb") as handle:
82
+ assert handle.read() == b"hello"
83
+ bad = Client(dict(config, token="wrong-token-value-xxxx"))
84
+ status, _ = bad.put_file("ok.txt", b"hello", "644")
85
+ assert status == 403
86
+ status, body = client.put_file("sub/目录.txt", "内容".encode("utf-8"), "644")
87
+ assert status == 200 and body.strip() == b"ok", body
88
+ with open(os.path.join(remote, "sub", "目录.txt"), "rb") as handle:
89
+ assert handle.read() == "内容".encode("utf-8")
90
+ try:
91
+ os.symlink("/tmp", os.path.join(remote, "escape"))
92
+ except OSError:
93
+ pass
94
+ else:
95
+ status, _ = client.put_file("escape/pwned.txt", b"nope", "644")
96
+ assert status == 400
97
+ assert not os.path.exists("/tmp/pwned.txt")
98
+ nested = os.path.join(local, "node_modules")
99
+ os.makedirs(nested)
100
+ with open(os.path.join(nested, "skip.js"), "w", encoding="utf-8") as handle:
101
+ handle.write("nope")
102
+ with open(os.path.join(local, "keep.txt"), "w", encoding="utf-8") as handle:
103
+ handle.write("v1")
104
+ ignored = ignore_dirs(config)
105
+ found = scan(local, ignored)
106
+ assert "keep.txt" in found
107
+ assert "node_modules/skip.js" not in found
108
+ with open(os.path.join(local, ".cs-syncignore"), "w", encoding="utf-8") as handle:
109
+ handle.write("*.skip\nsecret/\n!keep.skip\n!node_modules\n!node_modules/\n")
110
+ os.makedirs(os.path.join(local, "secret"), exist_ok=True)
111
+ with open(os.path.join(local, "secret", "hidden.txt"), "w", encoding="utf-8") as handle:
112
+ handle.write("nope")
113
+ with open(os.path.join(local, "x.skip"), "w", encoding="utf-8") as handle:
114
+ handle.write("skip-me")
115
+ with open(os.path.join(local, "keep.skip"), "w", encoding="utf-8") as handle:
116
+ handle.write("keep-me")
117
+ found2 = scan(local, ignored)
118
+ assert "keep.txt" in found2 and "keep.skip" in found2
119
+ assert "x.skip" not in found2 and "secret/hidden.txt" not in found2
120
+ assert "node_modules/skip.js" not in found2 # 内置忽略不可被 ! 否定
121
+ with open(os.path.join(local, ".cs-syncignore"), "w", encoding="utf-8") as handle:
122
+ handle.write("/root-only.dat\n/rooted.skip\n")
123
+ os.makedirs(os.path.join(local, "nested"), exist_ok=True)
124
+ with open(os.path.join(local, "root-only.dat"), "w", encoding="utf-8") as handle:
125
+ handle.write("root-file")
126
+ with open(os.path.join(local, "nested", "root-only.dat"), "w", encoding="utf-8") as handle:
127
+ handle.write("nested-file")
128
+ with open(os.path.join(local, "rooted.skip"), "w", encoding="utf-8") as handle:
129
+ handle.write("root-skip")
130
+ with open(os.path.join(local, "nested", "rooted.skip"), "w", encoding="utf-8") as handle:
131
+ handle.write("nested-skip")
132
+ found3 = scan(local, ignored)
133
+ assert "root-only.dat" not in found3 and "rooted.skip" not in found3
134
+ assert "nested/root-only.dat" in found3 # /root-only.dat 不匹配嵌套路径
135
+ assert "nested/rooted.skip" in found3 # /rooted.skip 只忽略根下
136
+ with open(os.path.join(remote, ".cs-syncignore"), "w", encoding="utf-8") as handle:
137
+ handle.write("cloud-only.skip\n")
138
+ with open(os.path.join(remote, "cloud-only.skip"), "w", encoding="utf-8") as handle:
139
+ handle.write("x")
140
+ with open(os.path.join(remote, "cloud-ok.txt"), "w", encoding="utf-8") as handle:
141
+ handle.write("y")
142
+ index.scan()
143
+ status, files = client.manifest()
144
+ assert status == 200
145
+ assert "cloud-ok.txt" in files and "cloud-only.skip" not in files
146
+ status, _ = client.delete_file("sub/目录.txt")
147
+ assert status == 200
148
+ assert not os.path.exists(os.path.join(remote, "sub", "目录.txt"))
149
+ status, body = client.push_ignore(DEFAULT_IGNORE_DIRS)
150
+ assert status == 200 and body.strip() == b"ok"
151
+ with open(os.path.join(remote, "from-cloud.txt"), "wb") as handle:
152
+ handle.write(b"from-cloud")
153
+ index.scan()
154
+ status, files = client.manifest()
155
+ assert status == 200 and files.get("from-cloud.txt") == sha256_bytes(b"from-cloud")
156
+ status, payload, mode = client.pull_file("from-cloud.txt")
157
+ assert status == 200 and payload == b"from-cloud" and mode == 0o644
158
+ write_file(local, "from-cloud.txt", payload, mode)
159
+ global MAX_BYTES
160
+ import cs_sync.client as client_module
161
+ import cs_sync.files as files_module
162
+ import cs_sync.git as git_module
163
+ import cs_sync.protocol as protocol_module
164
+ import cs_sync.server as server_module
165
+ import cs_sync.sync as sync_module
166
+
167
+ max_bound = (
168
+ protocol_module,
169
+ server_module,
170
+ client_module,
171
+ files_module,
172
+ git_module,
173
+ sync_module,
174
+ )
175
+ saved_max = MAX_BYTES
176
+ saved_module_max = [module.MAX_BYTES for module in max_bound]
177
+ real_request = client.request
178
+ MAX_BYTES = 32
179
+ for module in max_bound:
180
+ module.MAX_BYTES = 32
181
+ try:
182
+ with open(os.path.join(remote, "too-big.bin"), "wb") as handle:
183
+ handle.write(b"x" * 64)
184
+ # 服务端拒绝:用 raw request,避免 pull_file 客户端夹断冒充 400
185
+ status, _headers, payload = client.request(
186
+ ROUTE_PULL,
187
+ headers={"X-Sync-Token": token, "X-Rel-Path": urllib.parse.quote("too-big.bin", safe="/")},
188
+ method="GET",
189
+ )
190
+ assert status == 400 and "文件过大" in payload.decode("utf-8", errors="replace"), (status, payload)
191
+
192
+ def fat_request(*_args, **_kwargs):
193
+ return 200, {"X-File-Mode": "644"}, b"z" * 64
194
+
195
+ client.request = fat_request
196
+ status, payload, _mode = client.pull_file("ignored-name.bin")
197
+ assert status == 400 and "文件过大" in payload.decode("utf-8", errors="replace"), (status, payload)
198
+ finally:
199
+ client.request = real_request
200
+ MAX_BYTES = saved_max
201
+ for module, previous in zip(max_bound, saved_module_max):
202
+ module.MAX_BYTES = previous
203
+ assert open(os.path.join(local, "from-cloud.txt"), "rb").read() == b"from-cloud"
204
+ with open(os.path.join(local, "only-local.txt"), "w", encoding="utf-8") as handle:
205
+ handle.write("local-only")
206
+ downloaded, skipped, failed = copy_cloud_to_local(client, local, files)
207
+ assert failed == 0 and downloaded >= 1 and skipped >= 1, (downloaded, skipped, failed)
208
+ assert open(os.path.join(local, "ok.txt"), "rb").read() == b"hello"
209
+ assert open(os.path.join(local, "only-local.txt"), encoding="utf-8").read() == "local-only"
210
+ with open(os.path.join(local, "keep.txt"), "w", encoding="utf-8") as handle:
211
+ handle.write("from-local")
212
+ uploaded, skipped, failed = copy_local_to_cloud(client, local, ignored, files)
213
+ assert failed == 0 and uploaded >= 1, (uploaded, skipped, failed)
214
+ with open(os.path.join(remote, "keep.txt"), "rb") as handle:
215
+ assert handle.read() == b"from-local"
216
+ assert open(os.path.join(remote, "from-cloud.txt"), "rb").read() == b"from-cloud"
217
+ assert not os.path.exists(os.path.join(remote, "node_modules", "skip.js"))
218
+
219
+ def make_repo(path: str, filename: str, content: str) -> None:
220
+ os.makedirs(path, exist_ok=True)
221
+ subprocess.check_call(["git", "init", "-q", path])
222
+ subprocess.check_call(["git", "-C", path, "config", "user.email", "cs-sync@example.com"])
223
+ subprocess.check_call(["git", "-C", path, "config", "user.name", "cs-sync"])
224
+ with open(os.path.join(path, filename), "w", encoding="utf-8") as handle:
225
+ handle.write(content)
226
+ subprocess.check_call(["git", "-C", path, "add", filename])
227
+ subprocess.check_call(["git", "-C", path, "-c", "commit.gpgsign=false", "commit", "-q", "-m", "init"])
228
+
229
+ mono = os.path.join(local, "mono")
230
+ os.makedirs(mono)
231
+ make_repo(os.path.join(mono, "one"), "a.txt", "v1\n")
232
+ make_repo(os.path.join(mono, "two"), "b.txt", "v1\n")
233
+ with open(os.path.join(mono, "one", "a.txt"), "w", encoding="utf-8") as handle:
234
+ handle.write("v2\n")
235
+ with open(os.path.join(mono, "one", "fresh.txt"), "w", encoding="utf-8") as handle:
236
+ handle.write("new\n")
237
+ staged, count = stage_changes(mono, set(DEFAULT_IGNORE_DIRS))
238
+ assert staged == ["one"] and count == 2, (staged, count)
239
+ cached = subprocess.check_output(["git", "-C", os.path.join(mono, "one"), "diff", "--cached", "--name-only"], text=True)
240
+ assert "a.txt" in cached and "fresh.txt" in cached, cached
241
+ assert subprocess.check_output(["git", "-C", os.path.join(mono, "two"), "diff", "--cached"], text=True) == ""
242
+ single = os.path.join(local, "single")
243
+ make_repo(single, "root.txt", "root\n")
244
+ make_repo(os.path.join(single, "nested"), "n.txt", "n\n")
245
+ with open(os.path.join(single, "nested", "n.txt"), "w", encoding="utf-8") as handle:
246
+ handle.write("n2\n")
247
+ with open(os.path.join(single, "root.txt"), "w", encoding="utf-8") as handle:
248
+ handle.write("root2\n")
249
+ staged, count = stage_changes(single, set(DEFAULT_IGNORE_DIRS))
250
+ assert staged == ["."] and count == 1, (staged, count)
251
+ assert subprocess.check_output(["git", "-C", os.path.join(single, "nested"), "diff", "--cached"], text=True) == ""
252
+ pkg = os.path.join(remote, "pkg")
253
+ make_repo(pkg, "c.txt", "c1\n")
254
+ with open(os.path.join(pkg, "c.txt"), "w", encoding="utf-8") as handle:
255
+ handle.write("c2\n")
256
+ stage_status, stage_info = client.stage()
257
+ assert stage_status == 200 and stage_info.get("staged") == ["pkg"], stage_info
258
+ assert "c2" in subprocess.check_output(["git", "-C", pkg, "diff", "--cached"], text=True)
259
+ line_repo = os.path.join(local, "lines")
260
+ make_repo(line_repo, "a.txt", "line1\nline2\nline3\n")
261
+ with open(os.path.join(line_repo, "a.txt"), "w", encoding="utf-8") as handle:
262
+ handle.write("line1\nchanged\nline3\n")
263
+ with open(os.path.join(line_repo, "new.txt"), "w", encoding="utf-8") as handle:
264
+ handle.write("brand-new\n")
265
+ found, repo_count = collect_changes(line_repo, set(DEFAULT_IGNORE_DIRS))
266
+ kinds = {item["path"]: item["kind"] for item in found}
267
+ assert repo_count == 1 and kinds.get("a.txt") == "modify" and kinds.get("new.txt") == "add", kinds
268
+ saved = {}
269
+
270
+ def read_dest(rel):
271
+ if rel == "a.txt":
272
+ return True, b"line1\nline2\nline3\nextra\n", "644"
273
+ return False, None, "644"
274
+
275
+ def write_dest(rel, payload, _mode):
276
+ saved[rel] = payload
277
+ return True
278
+
279
+ def delete_dest(_rel):
280
+ return True
281
+
282
+ applied, failed = transfer_line_changes(found, read_dest, write_dest, delete_dest)
283
+ assert failed == 0 and applied == 2, (applied, failed, saved)
284
+ assert saved["a.txt"] == b"line1\nchanged\nline3\nextra\n"
285
+ assert saved["new.txt"] == b"brand-new\n"
286
+ blocked = {}
287
+
288
+ def read_conflict(rel):
289
+ return True, b"line1\nother\nline3\n", "644"
290
+
291
+ def write_conflict(rel, payload, _mode):
292
+ blocked[rel] = payload
293
+ return True
294
+
295
+ modify_only = [item for item in found if item["path"] == "a.txt"]
296
+ applied, failed = transfer_line_changes(modify_only, read_conflict, write_conflict, delete_dest)
297
+ assert applied == 0 and failed == 1 and "a.txt" not in blocked, (applied, failed, blocked)
298
+ # 对端已是补丁结果:应跳过,不可再写入导致重复插入
299
+ already = {}
300
+
301
+ def read_already(rel):
302
+ return True, b"line1\nchanged\nline3\n", "644"
303
+
304
+ def write_already(rel, payload, _mode):
305
+ already[rel] = payload
306
+ return True
307
+
308
+ applied, failed = transfer_line_changes(modify_only, read_already, write_already, delete_dest)
309
+ assert applied == 1 and failed == 0 and "a.txt" not in already, (applied, failed, already)
310
+ # 无 content 字段时,靠反向 apply 也能识别已同步
311
+ no_content = [{k: v for k, v in item.items() if k != "content"} for item in modify_only]
312
+ already2 = {}
313
+
314
+ def write_already2(rel, payload, _mode):
315
+ already2[rel] = payload
316
+ return True
317
+
318
+ applied, failed = transfer_line_changes(no_content, read_already, write_already2, delete_dest)
319
+ assert applied == 1 and failed == 0 and "a.txt" not in already2, (applied, failed, already2)
320
+ status, info = client.changes()
321
+ assert status == 200 and any(item.get("path") == "pkg/c.txt" and item.get("kind") == "modify" for item in info.get("changes") or []), info
322
+ server.shutdown()
323
+ home = tempfile.mkdtemp(prefix="cs-sync-home-")
324
+ os.environ["CS_SYNC_HOME"] = home
325
+ try:
326
+ first = os.path.join(local, "alpha")
327
+ second = os.path.join(local, "beta")
328
+ os.makedirs(first)
329
+ os.makedirs(second)
330
+ cmd_init(argparse.Namespace(
331
+ name="alpha", local=first, remote="/srv/alpha", base="https://10.1.1.1:18888",
332
+ port=0, force=False,
333
+ ))
334
+ cmd_init(argparse.Namespace(
335
+ name="", local=second, remote="/srv/beta", base="", port=0, force=False,
336
+ ))
337
+ command = recv_command(resolve_project("alpha"))
338
+ assert command.startswith("cs-sync recv "), command
339
+ assert "--root" in command and "--token" in command and "--port" in command
340
+ files = project_files()
341
+ assert set(files) == {"alpha", "beta"}
342
+ host = load_host()
343
+ alpha = compose(host, load_json(files["alpha"]))
344
+ beta = compose(host, load_json(files["beta"]))
345
+ assert alpha["port"] == 27182
346
+ assert beta["port"] == 27183
347
+ assert alpha["endpoint"] == "https://10.1.1.1:18888/proxy/27182"
348
+ assert beta["endpoint"] == "https://10.1.1.1:18888/proxy/27183"
349
+ assert alpha["remoteRoot"] == "/srv/alpha"
350
+ assert resolve_project("beta")["localRoot"] == second
351
+ cmd_remove(argparse.Namespace(name="beta"))
352
+ assert "beta" not in project_files()
353
+ assert os.path.isdir(second)
354
+ assert resolve_project("alpha")["remoteRoot"] == "/srv/alpha"
355
+ upper = os.path.join(local, "case-upper")
356
+ lower = os.path.join(local, "case-lower")
357
+ os.makedirs(upper)
358
+ os.makedirs(lower)
359
+ cmd_init(argparse.Namespace(
360
+ name="Case", local=upper, remote="/srv/Case", base="", port=0, force=False,
361
+ ))
362
+ cmd_init(argparse.Namespace(
363
+ name="case", local=lower, remote="/srv/case", base="", port=0, force=False,
364
+ ))
365
+ names = project_files()
366
+ assert "Case" in names and "case" in names
367
+ assert os.path.basename(names["Case"]) != os.path.basename(names["case"])
368
+ cmd_remove(argparse.Namespace(name="case"))
369
+ assert "case" not in project_files()
370
+ assert resolve_project("Case")["remoteRoot"] == "/srv/Case"
371
+ finally:
372
+ os.environ.pop("CS_SYNC_HOME", None)
373
+ shutil.rmtree(home, ignore_errors=True)
374
+ parser = build_parser()
375
+ help_text = parser.format_help()
376
+ assert "to-local" in help_text and "to-cloud" in help_text
377
+ assert "--version" in help_text
378
+ assert package_version() == "0.1.0"
379
+ assert "监听 0.0.0.0" in help_text
380
+ assert "只听 127.0.0.1" not in help_text
381
+ try:
382
+ parser.parse_args(["sync", "--full"])
383
+ except SystemExit as exc:
384
+ assert exc.code == 2
385
+ else:
386
+ raise AssertionError("sync --full 应该已经删除")
387
+ assert normalize_remote_path("/opt/cs-sync.py") == "/opt/cs-sync.py"
388
+ assert normalize_remote_path("\\opt\\cs-sync.py") == "/opt/cs-sync.py"
389
+ try:
390
+ normalize_remote_path("C:/work/proj")
391
+ except SystemExit:
392
+ pass
393
+ else:
394
+ raise AssertionError("云端路径不应接受 Windows 盘符")
395
+
396
+ # --dry-run:预览清单且不改写工作区 / 不暂存
397
+ dry_local = tempfile.mkdtemp(prefix="cs-sync-dry-local-")
398
+ dry_remote = tempfile.mkdtemp(prefix="cs-sync-dry-remote-")
399
+ try:
400
+ with open(os.path.join(dry_local, "same.txt"), "w", encoding="utf-8") as handle:
401
+ handle.write("same")
402
+ with open(os.path.join(dry_remote, "same.txt"), "w", encoding="utf-8") as handle:
403
+ handle.write("same")
404
+ with open(os.path.join(dry_local, "newer.txt"), "w", encoding="utf-8") as handle:
405
+ handle.write("local-new")
406
+ with open(os.path.join(dry_remote, "cloud-only.txt"), "w", encoding="utf-8") as handle:
407
+ handle.write("cloud")
408
+ remote_map = {
409
+ "same.txt": sha256_file(os.path.join(dry_remote, "same.txt")),
410
+ "cloud-only.txt": sha256_file(os.path.join(dry_remote, "cloud-only.txt")),
411
+ }
412
+ up, sk, fl = preview_copy_local_to_cloud(dry_local, set(DEFAULT_IGNORE_DIRS), remote_map)
413
+ assert fl == 0 and up == 1 and sk == 1, (up, sk, fl)
414
+ down, sk2, fl2 = preview_copy_cloud_to_local(dry_local, remote_map)
415
+ assert fl2 == 0 and down == 1 and sk2 == 1, (down, sk2, fl2)
416
+ assert open(os.path.join(dry_local, "newer.txt"), encoding="utf-8").read() == "local-new"
417
+ assert not os.path.exists(os.path.join(dry_local, "cloud-only.txt"))
418
+ assert open(os.path.join(dry_remote, "cloud-only.txt"), encoding="utf-8").read() == "cloud"
419
+
420
+ line_dir = os.path.join(dry_local, "dry-lines")
421
+ make_repo(line_dir, "a.txt", "line1\nline2\nline3\n")
422
+ with open(os.path.join(line_dir, "a.txt"), "w", encoding="utf-8") as handle:
423
+ handle.write("line1\nchanged\nline3\n")
424
+ with open(os.path.join(line_dir, "brand.txt"), "w", encoding="utf-8") as handle:
425
+ handle.write("x\n")
426
+ found, repo_count = collect_changes(line_dir, set(DEFAULT_IGNORE_DIRS))
427
+ assert repo_count == 1 and found
428
+
429
+ def read_ok(rel):
430
+ if rel == "a.txt":
431
+ return True, b"line1\nline2\nline3\n", "644"
432
+ return False, None, "644"
433
+
434
+ would, failed = preview_line_changes(found, read_ok)
435
+ assert failed == 0 and would == 2, (would, failed)
436
+ assert open(os.path.join(line_dir, "a.txt"), encoding="utf-8").read() == "line1\nchanged\nline3\n"
437
+
438
+ def read_conflict(rel):
439
+ return True, b"line1\nother\nline3\n", "644"
440
+
441
+ modify_only = [item for item in found if item["path"] == "a.txt"]
442
+ would, failed = preview_line_changes(modify_only, read_conflict)
443
+ assert would == 0 and failed == 1, (would, failed)
444
+
445
+ preview_sync_divergence(dry_local, set(DEFAULT_IGNORE_DIRS), remote_map, True)
446
+ assert not os.path.exists(os.path.join(dry_local, "cloud-only.txt"))
447
+
448
+ dry_parser = build_parser()
449
+ for command in ("to-cloud", "to-local", "diff-to-cloud", "diff-to-local", "sync"):
450
+ ns = dry_parser.parse_args([command, "--dry-run"])
451
+ assert ns.dry_run is True
452
+ assert ns.command == command
453
+ finally:
454
+ shutil.rmtree(dry_local, ignore_errors=True)
455
+ shutil.rmtree(dry_remote, ignore_errors=True)
456
+
457
+ # 边界:空文件保留、云端删除不复活、插入间隙不重复、无结尾换行补丁可 apply
458
+ empty_change = change_from_contents("e.txt", "e.txt", b"hello\n", b"", "644")
459
+ assert empty_change and empty_change["kind"] == "modify", empty_change
460
+ assert decode_bytes(empty_change["content"]) == b""
461
+ del_change = change_from_contents("e.txt", "e.txt", b"hello\n", b"", "644", deleted=True)
462
+ assert del_change and del_change["kind"] == "delete", del_change
463
+ empty_add = change_from_contents("e.txt", "e.txt", b"", b"hi\n", "644")
464
+ assert empty_add and empty_add["kind"] == "modify", empty_add
465
+
466
+ saved_empty = {}
467
+
468
+ def read_empty(rel):
469
+ return True, b"hello\n", "644"
470
+
471
+ def write_empty(rel, payload, _mode):
472
+ saved_empty[rel] = payload
473
+ return True
474
+
475
+ def delete_empty(_rel):
476
+ raise AssertionError("空内容不应 delete_dest")
477
+
478
+ applied, failed = transfer_line_changes(
479
+ [empty_change], read_empty, write_empty, delete_empty, success_verb="本地改动"
480
+ )
481
+ assert failed == 0 and applied == 1 and saved_empty.get("e.txt") == b"", (applied, failed, saved_empty)
482
+
483
+ nnl = change_from_contents("n.txt", "n.txt", b"hello", b"hello!", "644")
484
+ assert nnl and nnl["kind"] == "modify", nnl
485
+ saved_nnl = {}
486
+
487
+ def read_nnl(rel):
488
+ return True, b"hello", "644"
489
+
490
+ def write_nnl(rel, payload, _mode):
491
+ saved_nnl[rel] = payload
492
+ return True
493
+
494
+ applied, failed = transfer_line_changes(
495
+ [nnl], read_nnl, write_nnl, delete_empty, success_verb="本地改动"
496
+ )
497
+ assert failed == 0 and applied == 1 and saved_nnl.get("n.txt") == b"hello!", (applied, failed, saved_nnl)
498
+
499
+ base = b"alpha\nbeta\n"
500
+ local_delta = b"alpha\nSAME\nextra\nbeta\n"
501
+ cloud_delta = b"alpha\nSAME\nbeta\n"
502
+ assert apply_line_delta_onto(base, local_delta, cloud_delta) is None
503
+ # 间隙已含完整插入段:跳过,不重复
504
+ cloud_more = b"alpha\nSAME\nextra\nbeta\n"
505
+ local_same = b"alpha\nSAME\nbeta\n"
506
+ assert apply_line_delta_onto(base, local_same, cloud_more) == cloud_more
507
+ local_ins = b"alpha\nLocal1\nbeta\n"
508
+ assert apply_line_delta_onto(base, local_ins, cloud_delta) == b"alpha\nSAME\nLocal1\nbeta\n"
509
+
510
+ status, _payload, reason = push_watch_edit_to_cloud(
511
+ "gone.txt",
512
+ local,
513
+ b"local-edit\n",
514
+ {"gone.txt": b"old\n"},
515
+ lambda rel: (False, None, "644"),
516
+ lambda rel, payload, mode: (_ for _ in ()).throw(AssertionError("不应复活云端删除")),
517
+ lambda rel: True,
518
+ last_cloud_synced={"gone.txt": b"old\n"},
519
+ )
520
+ assert status == "conflict" and reason == "cloud-deleted", (status, reason)
521
+ wrote = {}
522
+ status, payload, _mode = push_watch_edit_to_cloud(
523
+ "fresh.txt",
524
+ local,
525
+ b"new\n",
526
+ {},
527
+ lambda rel: (False, None, "644"),
528
+ lambda rel, payload, mode: wrote.__setitem__(rel, payload) or True,
529
+ lambda rel: True,
530
+ last_cloud_synced={},
531
+ )
532
+ assert status == "ok" and wrote.get("fresh.txt") == b"new\n", (status, wrote)
533
+
534
+ # 仅末尾换行变化也必须能生成可 apply 的补丁
535
+ nl_only = change_from_contents("nl.txt", "nl.txt", b"hello\n", b"hello", "644")
536
+ assert nl_only and nl_only["kind"] == "modify", nl_only
537
+ patch_nl = decode_bytes(nl_only["patch"])
538
+ assert b"No newline at end of file" in patch_nl, patch_nl
539
+ saved_nl = {}
540
+
541
+ def read_nl(rel):
542
+ return True, b"hello\n", "644"
543
+
544
+ def write_nl(rel, payload, _mode):
545
+ saved_nl[rel] = payload
546
+ return True
547
+
548
+ applied, failed = transfer_line_changes(
549
+ [nl_only], read_nl, write_nl, delete_empty, success_verb="本地改动"
550
+ )
551
+ assert failed == 0 and applied == 1 and saved_nl.get("nl.txt") == b"hello", (applied, failed, saved_nl)
552
+
553
+ # 无结尾换行且改动不在最后一行:标记须跟在最后一行(上下文)之后
554
+ mid = change_from_contents("mid.txt", "mid.txt", b"a\nb\nc", b"a\nB\nc", "644")
555
+ assert mid and mid["kind"] == "modify", mid
556
+ patch_mid = decode_bytes(mid["patch"]).decode("utf-8")
557
+ assert patch_mid.rstrip().endswith("\"), patch_mid
558
+ assert "\n c\n\\ No newline" in patch_mid.replace("\r", ""), repr(patch_mid)
559
+ saved_mid = {}
560
+
561
+ def read_mid(rel):
562
+ return True, b"a\nb\nc", "644"
563
+
564
+ def write_mid(rel, payload, _mode):
565
+ saved_mid[rel] = payload
566
+ return True
567
+
568
+ applied, failed = transfer_line_changes(
569
+ [mid], read_mid, write_mid, delete_empty, success_verb="本地改动"
570
+ )
571
+ assert failed == 0 and applied == 1 and saved_mid.get("mid.txt") == b"a\nB\nc", (applied, failed, saved_mid)
572
+
573
+ # CRLF → LF 不能再被当成空 diff
574
+ crlf = change_from_contents("cr.txt", "cr.txt", b"hello\r\n", b"hello\n", "644")
575
+ assert crlf and crlf["kind"] == "modify", crlf
576
+
577
+ # 对端已删除:delete 应计为已同步,而非失败
578
+ del_gone = change_from_contents("gone2.txt", "gone2.txt", b"x\n", b"", "644", deleted=True)
579
+ assert del_gone and del_gone["kind"] == "delete"
580
+
581
+ def read_gone(rel):
582
+ return False, None, "644"
583
+
584
+ def write_gone(rel, payload, _mode):
585
+ raise AssertionError("不应写入")
586
+
587
+ def delete_gone(_rel):
588
+ raise AssertionError("对端已无文件不应再 delete_dest")
589
+
590
+ applied, failed = transfer_line_changes(
591
+ [del_gone], read_gone, write_gone, delete_gone, success_verb="本地改动"
592
+ )
593
+ assert applied == 1 and failed == 0, (applied, failed)
594
+
595
+ # 本地改过同一行后云端再删:应冲突,不能当成删除已满足
596
+ base_del = b"a\nkeep\nb\n"
597
+ local_chg = b"a\nCHANGED\nb\n"
598
+ cloud_del = b"a\nb\n"
599
+ assert apply_line_delta_onto(base_del, cloud_del, local_chg) is None
600
+ # 目标侧已删掉该行:跳过
601
+ assert apply_line_delta_onto(base_del, cloud_del, cloud_del) == cloud_del
602
+
603
+ # 锚点漂移:目标侧已在末尾有待插入行 → 跳过,不重复追加
604
+ cloud_base_dup = b"alpha\nEXTRA\nbeta\n"
605
+ cloud_new_dup = b"alpha\nEXTRA\nbeta\nNEW\n"
606
+ local_dup = b"alpha\nbeta\nNEW\n"
607
+ assert apply_line_delta_onto(cloud_base_dup, cloud_new_dup, local_dup) == local_dup
608
+
609
+ # 无后继基线锚点且插入点后有对不上的尾部 → 冲突,不插到中间
610
+ assert apply_line_delta_onto(b"alpha\n", b"alpha\nnew\n", b"alpha\nONLY_CLOUD\n") is None
611
+ assert apply_line_delta_onto(
612
+ b"alpha\nONLY_LOCAL\n", b"alpha\nONLY_LOCAL\nnew\n", b"alpha\nONLY_CLOUD\n"
613
+ ) is None
614
+
615
+ # 推送成功后同一轮:云端增量回灌本地,不应把已有 NEW 再插一遍
616
+ pushed = {}
617
+ status, cloud_after, _mode = push_watch_edit_to_cloud(
618
+ "round.txt",
619
+ local,
620
+ b"alpha\nbeta\nNEW\n",
621
+ {"round.txt": b"alpha\nbeta\n"},
622
+ lambda rel: (True, b"alpha\nEXTRA\nbeta\n", "644"),
623
+ lambda rel, payload, mode: pushed.__setitem__(rel, payload) or True,
624
+ lambda rel: True,
625
+ last_cloud_synced={"round.txt": b"alpha\nEXTRA\nbeta\n"},
626
+ )
627
+ assert status == "ok" and cloud_after == b"alpha\nEXTRA\nbeta\nNEW\n", (status, cloud_after)
628
+ local_round = {"round.txt": b"alpha\nbeta\nNEW\n"}
629
+
630
+ def read_round(rel):
631
+ return True, local_round[rel], "644"
632
+
633
+ def write_round(rel, payload, _mode):
634
+ local_round[rel] = payload
635
+ return True
636
+
637
+ ok_m, merged_m = try_merge_cloud_onto_local(
638
+ "round.txt",
639
+ local,
640
+ b"alpha\nEXTRA\nbeta\n",
641
+ cloud_after,
642
+ "644",
643
+ read_round,
644
+ write_round,
645
+ lambda rel: True,
646
+ )
647
+ assert ok_m and local_round["round.txt"] == b"alpha\nbeta\nNEW\n", (ok_m, merged_m, local_round)
648
+
649
+ # 无行级基线但云端已有文件:回退整份上传
650
+ wrote_nb = {}
651
+ status, payload, _mode = push_watch_edit_to_cloud(
652
+ "exist.txt",
653
+ local,
654
+ b"brand-new\n",
655
+ {},
656
+ lambda rel: (True, b"old-cloud\n", "644"),
657
+ lambda rel, payload, mode: wrote_nb.__setitem__(rel, payload) or True,
658
+ lambda rel: True,
659
+ last_cloud_synced={},
660
+ )
661
+ assert status == "ok" and wrote_nb.get("exist.txt") == b"brand-new\n", (status, wrote_nb)
662
+
663
+ # 删除以 -- 开头 / 新增以 ++ 开头的行:不得被当成 unified diff 文件头
664
+ dash_old = b"a\n--dash\nb\n"
665
+ dash_new = b"a\n++plus\nb\n"
666
+ dash_ch = change_from_contents("dash.txt", "dash.txt", dash_old, dash_new, "644")
667
+ assert dash_ch and dash_ch["kind"] == "modify", dash_ch
668
+ dash_patch = decode_bytes(dash_ch["patch"])
669
+ assert b"--- a/dash.txt" in dash_patch and b"+++ b/dash.txt" in dash_patch, dash_patch
670
+ # 正文里的 ---dash / +++plus 必须作为 +/- 行存在
671
+ assert b"---dash\n" in dash_patch or b"--dash" in dash_patch, dash_patch
672
+ outcome, dash_result = apply_saved_patch("dash.txt", dash_old, dash_patch)
673
+ assert outcome == "ok" and dash_result == dash_new, (outcome, dash_result)
674
+
675
+ # 无结尾换行 + 改动远离末尾:末行不在 hunk 内时不得输出 No newline 标记,
676
+ # 否则 git apply 返回 0 却把上下文行换行吃掉(L5\nL6 → L5L6)
677
+ far_old = b"".join(b"L%d\n" % i for i in range(1, 20)) + b"L20"
678
+ far_new = b"L1\nCHANGED\n" + b"".join(b"L%d\n" % i for i in range(3, 20)) + b"L20"
679
+ far_ch = change_from_contents("far.txt", "far.txt", far_old, far_new, "644")
680
+ assert far_ch and far_ch["kind"] == "modify", far_ch
681
+ far_patch = decode_bytes(far_ch["patch"])
682
+ assert b"No newline at end of file" not in far_patch, far_patch
683
+ outcome, far_result = apply_saved_patch("far.txt", far_old, far_patch)
684
+ assert outcome == "ok" and far_result == far_new, (outcome, far_result)
685
+ saved_far = {}
686
+
687
+ def read_far(rel):
688
+ return True, far_old, "644"
689
+
690
+ def write_far(rel, payload, _mode):
691
+ saved_far[rel] = payload
692
+ return True
693
+
694
+ applied, failed = transfer_line_changes(
695
+ [far_ch], read_far, write_far, delete_empty, success_verb="本地改动"
696
+ )
697
+ assert failed == 0 and applied == 1 and saved_far.get("far.txt") == far_new, (
698
+ applied, failed, saved_far.get("far.txt")
699
+ )
700
+
701
+ # 人为错误 No newline 标记:git apply 返回 0 但写坏;transfer 必须拒绝写入
702
+ corrupt_patch = (
703
+ b"--- a/far.txt\n+++ b/far.txt\n@@ -1,5 +1,5 @@\n L1\n-L2\n+CHANGED\n L3\n L4\n L5\n"
704
+ b"\\n"
705
+ )
706
+ corrupt_ch = {
707
+ "path": "far.txt",
708
+ "repoPath": "far.txt",
709
+ "kind": "modify",
710
+ "mode": "644",
711
+ "patch": encode_bytes(corrupt_patch),
712
+ "content": encode_bytes(far_new),
713
+ }
714
+ saved_bad = {}
715
+
716
+ def write_bad(rel, payload, _mode):
717
+ saved_bad[rel] = payload
718
+ return True
719
+
720
+ applied, failed = transfer_line_changes(
721
+ [corrupt_ch], read_far, write_bad, delete_empty, success_verb="本地改动"
722
+ )
723
+ assert failed == 1 and applied == 0 and "far.txt" not in saved_bad, (
724
+ applied, failed, saved_bad
725
+ )
726
+
727
+ print("self-test 通过")
728
+ finally:
729
+ shutil.rmtree(local, ignore_errors=True)
730
+ shutil.rmtree(remote, ignore_errors=True)