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