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,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
DEFAULT_IGNORE_DIRS = {
|
|
7
|
+
".agents",
|
|
8
|
+
".git",
|
|
9
|
+
"node_modules",
|
|
10
|
+
"dist",
|
|
11
|
+
"build",
|
|
12
|
+
"coverage",
|
|
13
|
+
"tmp",
|
|
14
|
+
".idea",
|
|
15
|
+
".venv",
|
|
16
|
+
"venv",
|
|
17
|
+
"__pycache__",
|
|
18
|
+
".next",
|
|
19
|
+
".nuxt",
|
|
20
|
+
".turbo",
|
|
21
|
+
".cache",
|
|
22
|
+
"vendor",
|
|
23
|
+
}
|
|
24
|
+
IGNORE_FILE_NAMES = {".DS_Store"}
|
|
25
|
+
IGNORE_PREFIXES = (".cursor-tmp", ".tmp-")
|
|
26
|
+
CS_SYNCIGNORE_NAME = ".cs-syncignore"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def ignore_dirs(config: dict) -> set:
|
|
30
|
+
raw = config.get("ignoreDirs") or sorted(DEFAULT_IGNORE_DIRS)
|
|
31
|
+
return set(raw)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def builtin_should_skip(rel: str, ignored: set) -> bool:
|
|
35
|
+
parts = rel.replace("\\", "/").split("/")
|
|
36
|
+
if any(part in ignored for part in parts):
|
|
37
|
+
return True
|
|
38
|
+
name = parts[-1] if parts else ""
|
|
39
|
+
if name in IGNORE_FILE_NAMES or name.startswith(IGNORE_PREFIXES):
|
|
40
|
+
return True
|
|
41
|
+
if name.endswith(".cs-sync-tmp"):
|
|
42
|
+
return True
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fnmatch_path(path: str, pattern: str) -> bool:
|
|
47
|
+
"""类 gitignore:* / ? 不跨段,** 可跨段。"""
|
|
48
|
+
path = path.replace("\\", "/").strip("/")
|
|
49
|
+
pattern = pattern.replace("\\", "/").strip("/")
|
|
50
|
+
if pattern == "**":
|
|
51
|
+
return True
|
|
52
|
+
i = 0
|
|
53
|
+
parts = []
|
|
54
|
+
while i < len(pattern):
|
|
55
|
+
if pattern.startswith("**/", i):
|
|
56
|
+
parts.append("(?:.*/)?")
|
|
57
|
+
i += 3
|
|
58
|
+
continue
|
|
59
|
+
if i + 2 == len(pattern) and pattern.endswith("**") and pattern[i:i + 2] == "**":
|
|
60
|
+
parts.append(".*")
|
|
61
|
+
i += 2
|
|
62
|
+
continue
|
|
63
|
+
ch = pattern[i]
|
|
64
|
+
if ch == "*":
|
|
65
|
+
parts.append("[^/]*")
|
|
66
|
+
elif ch == "?":
|
|
67
|
+
parts.append("[^/]")
|
|
68
|
+
elif ch in ".^$+{}[]|()\\":
|
|
69
|
+
parts.append("\\" + ch)
|
|
70
|
+
else:
|
|
71
|
+
parts.append(ch)
|
|
72
|
+
i += 1
|
|
73
|
+
return re.match("^" + "".join(parts) + "$", path) is not None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _pattern_matches(rel: str, pattern: str, dir_only: bool, is_dir: bool) -> bool:
|
|
77
|
+
rel = rel.replace("\\", "/").strip("/")
|
|
78
|
+
pattern = pattern.replace("\\", "/")
|
|
79
|
+
if dir_only and not is_dir:
|
|
80
|
+
return False
|
|
81
|
+
# 必须在 strip 之前判断根锚定,否则 "/secret" 会变成 "secret" 并误走基名匹配
|
|
82
|
+
anchored = pattern.startswith("/")
|
|
83
|
+
if anchored:
|
|
84
|
+
pattern = pattern[1:]
|
|
85
|
+
pattern = pattern.strip("/")
|
|
86
|
+
if not pattern:
|
|
87
|
+
return False
|
|
88
|
+
if anchored:
|
|
89
|
+
return fnmatch_path(rel, pattern)
|
|
90
|
+
if "/" not in pattern:
|
|
91
|
+
base = rel.rsplit("/", 1)[-1]
|
|
92
|
+
if fnmatch_path(base, pattern) or fnmatch_path(rel, pattern):
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
return fnmatch_path(rel, pattern)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _ancestor_dir_matched(rel: str, pattern: str, dir_only: bool) -> bool:
|
|
99
|
+
parts = rel.replace("\\", "/").strip("/").split("/")
|
|
100
|
+
for idx in range(1, len(parts)):
|
|
101
|
+
parent = "/".join(parts[:idx])
|
|
102
|
+
if _pattern_matches(parent, pattern, True, True):
|
|
103
|
+
return True
|
|
104
|
+
if _pattern_matches(parent, pattern, dir_only, True):
|
|
105
|
+
return True
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class PathIgnore:
|
|
110
|
+
"""根目录 .cs-syncignore(类 gitignore)。! 否定只作用于本文件规则,不能抵消内置忽略。"""
|
|
111
|
+
|
|
112
|
+
def __init__(self, rules=None):
|
|
113
|
+
self.rules = list(rules or [])
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def load(cls, root: str) -> "PathIgnore":
|
|
117
|
+
path = os.path.join(root, CS_SYNCIGNORE_NAME)
|
|
118
|
+
rules = []
|
|
119
|
+
try:
|
|
120
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
121
|
+
lines = handle.read().splitlines()
|
|
122
|
+
except (FileNotFoundError, OSError):
|
|
123
|
+
return cls([])
|
|
124
|
+
for raw in lines:
|
|
125
|
+
line = raw.strip()
|
|
126
|
+
if not line or line.startswith("#"):
|
|
127
|
+
continue
|
|
128
|
+
negated = False
|
|
129
|
+
if line.startswith("!"):
|
|
130
|
+
negated = True
|
|
131
|
+
line = line[1:].lstrip()
|
|
132
|
+
dir_only = line.endswith("/")
|
|
133
|
+
if dir_only:
|
|
134
|
+
line = line[:-1]
|
|
135
|
+
if not line:
|
|
136
|
+
continue
|
|
137
|
+
rules.append((negated, line, dir_only))
|
|
138
|
+
return cls(rules)
|
|
139
|
+
|
|
140
|
+
def ignored(self, rel: str, is_dir: bool = False) -> bool:
|
|
141
|
+
if not self.rules:
|
|
142
|
+
return False
|
|
143
|
+
rel = rel.replace("\\", "/").strip("/")
|
|
144
|
+
if not rel:
|
|
145
|
+
return False
|
|
146
|
+
state = None
|
|
147
|
+
for negated, pattern, dir_only in self.rules:
|
|
148
|
+
matched = _pattern_matches(rel, pattern, dir_only, is_dir)
|
|
149
|
+
if not matched and not negated:
|
|
150
|
+
matched = _ancestor_dir_matched(rel, pattern, dir_only)
|
|
151
|
+
if matched:
|
|
152
|
+
state = False if negated else True
|
|
153
|
+
return bool(state)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def load_path_ignore(root: str) -> PathIgnore:
|
|
157
|
+
return PathIgnore.load(root)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def should_skip(rel: str, ignored: set, path_ignore: PathIgnore = None, is_dir: bool = False) -> bool:
|
|
161
|
+
if builtin_should_skip(rel, ignored):
|
|
162
|
+
return True
|
|
163
|
+
if path_ignore is not None and path_ignore.ignored(rel, is_dir=is_dir):
|
|
164
|
+
return True
|
|
165
|
+
return False
|
package/cs_sync/merge.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
from typing import Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from cs_sync.git import change_from_contents, find_repo_for_rel, transfer_line_changes
|
|
9
|
+
|
|
10
|
+
def refresh_local_known(local_root: str, rel: str, known: dict, last_synced: Dict[str, bytes]) -> Optional[bytes]:
|
|
11
|
+
"""写入本地后刷新 known / last_synced,返回当前字节。"""
|
|
12
|
+
full = os.path.join(local_root, *rel.split("/"))
|
|
13
|
+
try:
|
|
14
|
+
if not os.path.isfile(full) or os.path.islink(full):
|
|
15
|
+
known.pop(rel, None)
|
|
16
|
+
last_synced.pop(rel, None)
|
|
17
|
+
return None
|
|
18
|
+
stat = os.stat(full)
|
|
19
|
+
known[rel] = (stat.st_mtime_ns, stat.st_size)
|
|
20
|
+
with open(full, "rb") as handle:
|
|
21
|
+
payload = handle.read()
|
|
22
|
+
last_synced[rel] = payload
|
|
23
|
+
return payload
|
|
24
|
+
except OSError:
|
|
25
|
+
known.pop(rel, None)
|
|
26
|
+
last_synced.pop(rel, None)
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _line_change_ranges(old: list, new: list):
|
|
31
|
+
"""返回 (删除/替换触及的旧行区间列表, 是否仅有插入)。"""
|
|
32
|
+
import difflib
|
|
33
|
+
touched = []
|
|
34
|
+
only_insert = True
|
|
35
|
+
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(a=old, b=new, autojunk=False).get_opcodes():
|
|
36
|
+
if tag == "equal":
|
|
37
|
+
continue
|
|
38
|
+
if tag != "insert":
|
|
39
|
+
only_insert = False
|
|
40
|
+
if i2 > i1:
|
|
41
|
+
touched.append((i1, i2))
|
|
42
|
+
return touched, only_insert
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ranges_overlap(a: list, b: list) -> bool:
|
|
46
|
+
for a1, a2 in a:
|
|
47
|
+
for b1, b2 in b:
|
|
48
|
+
if a1 < b2 and b1 < a2:
|
|
49
|
+
return True
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def apply_line_delta_onto(base: bytes, new: bytes, target: bytes) -> Optional[bytes]:
|
|
54
|
+
"""把 base→new 的行级变更应用到 target,保留 target 相对 base 的独有内容。
|
|
55
|
+
|
|
56
|
+
用于 sync 启动后两边已分叉:只传播启动后增量,不把启动前另一侧独有行并回来。
|
|
57
|
+
对不上则返回 None。
|
|
58
|
+
"""
|
|
59
|
+
if target == base:
|
|
60
|
+
return new
|
|
61
|
+
if target == new:
|
|
62
|
+
return target
|
|
63
|
+
if base == new:
|
|
64
|
+
return target
|
|
65
|
+
try:
|
|
66
|
+
base_l = base.decode("utf-8").splitlines(keepends=True)
|
|
67
|
+
new_l = new.decode("utf-8").splitlines(keepends=True)
|
|
68
|
+
target_l = target.decode("utf-8").splitlines(keepends=True)
|
|
69
|
+
except UnicodeDecodeError:
|
|
70
|
+
return None
|
|
71
|
+
import difflib
|
|
72
|
+
base_to_target = {}
|
|
73
|
+
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(a=base_l, b=target_l, autojunk=False).get_opcodes():
|
|
74
|
+
if tag == "equal":
|
|
75
|
+
for k in range(i2 - i1):
|
|
76
|
+
base_to_target[i1 + k] = j1 + k
|
|
77
|
+
result = list(target_l)
|
|
78
|
+
|
|
79
|
+
def map_before(base_idx: int) -> int:
|
|
80
|
+
"""base[base_idx] 之前的插入点,对应到当前 result 下标。"""
|
|
81
|
+
if base_idx in base_to_target:
|
|
82
|
+
return base_to_target[base_idx]
|
|
83
|
+
for k in range(base_idx - 1, -1, -1):
|
|
84
|
+
if k in base_to_target:
|
|
85
|
+
return base_to_target[k] + 1
|
|
86
|
+
for k in range(base_idx, len(base_l)):
|
|
87
|
+
if k in base_to_target:
|
|
88
|
+
return base_to_target[k]
|
|
89
|
+
return len(result)
|
|
90
|
+
|
|
91
|
+
ops = [
|
|
92
|
+
op for op in difflib.SequenceMatcher(a=base_l, b=new_l, autojunk=False).get_opcodes()
|
|
93
|
+
if op[0] != "equal"
|
|
94
|
+
]
|
|
95
|
+
# 从高到低改,避免前面的插入/删除带动后面的原始下标
|
|
96
|
+
for tag, i1, i2, j1, j2 in sorted(ops, key=lambda op: op[1], reverse=True):
|
|
97
|
+
if tag == "insert":
|
|
98
|
+
pos = map_before(i1)
|
|
99
|
+
chunk = new_l[j1:j2]
|
|
100
|
+
# 间隙 = 上一个已映射基线行之后 → 当前插入点(不含 base[i1])
|
|
101
|
+
gap_start = 0
|
|
102
|
+
for k in range(i1 - 1, -1, -1):
|
|
103
|
+
if k in base_to_target:
|
|
104
|
+
gap_start = base_to_target[k] + 1
|
|
105
|
+
break
|
|
106
|
+
if gap_start > pos:
|
|
107
|
+
return None
|
|
108
|
+
gap = result[gap_start:pos]
|
|
109
|
+
# 间隙前缀已是待插入行:视为已应用,跳过,避免重复
|
|
110
|
+
overlap = 0
|
|
111
|
+
for k in range(min(len(chunk), len(gap)), 0, -1):
|
|
112
|
+
if gap[:k] == chunk[:k]:
|
|
113
|
+
overlap = k
|
|
114
|
+
break
|
|
115
|
+
if overlap == len(chunk):
|
|
116
|
+
continue
|
|
117
|
+
if overlap > 0:
|
|
118
|
+
# 部分重叠对不齐,交给上层冲突处理
|
|
119
|
+
return None
|
|
120
|
+
# 插入点处已是待插入行(锚点漂移后常见):跳过,避免末尾重复
|
|
121
|
+
if result[pos:pos + len(chunk)] == chunk:
|
|
122
|
+
continue
|
|
123
|
+
# 无后继基线锚点,且插入点后还有对不上的尾部:冲突,避免插错位置
|
|
124
|
+
has_next_anchor = any(k >= i1 for k in base_to_target)
|
|
125
|
+
if not has_next_anchor and result[pos:]:
|
|
126
|
+
return None
|
|
127
|
+
result[pos:pos] = chunk
|
|
128
|
+
continue
|
|
129
|
+
mapped = [base_to_target[k] for k in range(i1, i2) if k in base_to_target]
|
|
130
|
+
if tag == "delete":
|
|
131
|
+
if not mapped:
|
|
132
|
+
# 目标侧找不到这些基线行:仅当 base→target 对该区间是「删除」
|
|
133
|
+
# (行已不存在)才视为已满足;若是替换(本地改过同一行)则冲突,
|
|
134
|
+
# 否则会吞掉云端删除并 accept 基线。
|
|
135
|
+
covered_by_delete = False
|
|
136
|
+
for ttag, ti1, ti2, _tj1, _tj2 in difflib.SequenceMatcher(
|
|
137
|
+
a=base_l, b=target_l, autojunk=False
|
|
138
|
+
).get_opcodes():
|
|
139
|
+
if ti2 <= i1 or ti1 >= i2:
|
|
140
|
+
continue
|
|
141
|
+
if ttag == "delete" and ti1 <= i1 and i2 <= ti2:
|
|
142
|
+
covered_by_delete = True
|
|
143
|
+
continue
|
|
144
|
+
return None
|
|
145
|
+
if covered_by_delete:
|
|
146
|
+
continue
|
|
147
|
+
return None
|
|
148
|
+
if len(mapped) != (i2 - i1):
|
|
149
|
+
return None
|
|
150
|
+
for idx in sorted(mapped, reverse=True):
|
|
151
|
+
del result[idx]
|
|
152
|
+
continue
|
|
153
|
+
if tag == "replace":
|
|
154
|
+
if len(mapped) != (i2 - i1):
|
|
155
|
+
return None
|
|
156
|
+
start, end = min(mapped), max(mapped) + 1
|
|
157
|
+
if mapped != list(range(start, end)):
|
|
158
|
+
return None
|
|
159
|
+
result[start:end] = new_l[j1:j2]
|
|
160
|
+
continue
|
|
161
|
+
return None
|
|
162
|
+
return "".join(result).encode("utf-8")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def three_way_merge_bytes(base: bytes, local: bytes, cloud: bytes) -> Optional[bytes]:
|
|
166
|
+
"""以 base 为祖先合并 local 与 cloud。两边改同一行则失败;两侧只是插入可用 union。"""
|
|
167
|
+
if local == cloud:
|
|
168
|
+
return local
|
|
169
|
+
if local == base:
|
|
170
|
+
return cloud
|
|
171
|
+
if cloud == base:
|
|
172
|
+
return local
|
|
173
|
+
try:
|
|
174
|
+
base_l = base.decode("utf-8").splitlines(keepends=True)
|
|
175
|
+
local_l = local.decode("utf-8").splitlines(keepends=True)
|
|
176
|
+
cloud_l = cloud.decode("utf-8").splitlines(keepends=True)
|
|
177
|
+
except UnicodeDecodeError:
|
|
178
|
+
return None
|
|
179
|
+
with tempfile.TemporaryDirectory(prefix="cs-sync-merge-") as tmp:
|
|
180
|
+
base_p = os.path.join(tmp, "base")
|
|
181
|
+
local_p = os.path.join(tmp, "local")
|
|
182
|
+
cloud_p = os.path.join(tmp, "cloud")
|
|
183
|
+
with open(base_p, "wb") as handle:
|
|
184
|
+
handle.write(base)
|
|
185
|
+
with open(local_p, "wb") as handle:
|
|
186
|
+
handle.write(local)
|
|
187
|
+
with open(cloud_p, "wb") as handle:
|
|
188
|
+
handle.write(cloud)
|
|
189
|
+
env = dict(os.environ)
|
|
190
|
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
191
|
+
clean = subprocess.run(
|
|
192
|
+
["git", "merge-file", "-p", "-q", local_p, base_p, cloud_p],
|
|
193
|
+
stdout=subprocess.PIPE,
|
|
194
|
+
stderr=subprocess.PIPE,
|
|
195
|
+
check=False,
|
|
196
|
+
env=env,
|
|
197
|
+
)
|
|
198
|
+
if clean.returncode == 0:
|
|
199
|
+
return clean.stdout
|
|
200
|
+
local_touched, local_insert_only = _line_change_ranges(base_l, local_l)
|
|
201
|
+
cloud_touched, cloud_insert_only = _line_change_ranges(base_l, cloud_l)
|
|
202
|
+
if _ranges_overlap(local_touched, cloud_touched):
|
|
203
|
+
return None
|
|
204
|
+
# 仅当两侧都只是插入时才 union;一侧替换/删除时 union 会把「已被对端换掉的旧行」又并回来
|
|
205
|
+
if not (local_insert_only and cloud_insert_only):
|
|
206
|
+
return None
|
|
207
|
+
union = subprocess.run(
|
|
208
|
+
["git", "merge-file", "-p", "-q", "--union", local_p, base_p, cloud_p],
|
|
209
|
+
stdout=subprocess.PIPE,
|
|
210
|
+
stderr=subprocess.PIPE,
|
|
211
|
+
check=False,
|
|
212
|
+
env=env,
|
|
213
|
+
)
|
|
214
|
+
if union.returncode == 0 and b"<<<<<<<" not in union.stdout:
|
|
215
|
+
return union.stdout
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def try_merge_cloud_onto_local(
|
|
220
|
+
rel: str,
|
|
221
|
+
local_root: str,
|
|
222
|
+
base_bytes: bytes,
|
|
223
|
+
cloud_payload: bytes,
|
|
224
|
+
mode: str,
|
|
225
|
+
local_reader,
|
|
226
|
+
local_writer,
|
|
227
|
+
local_deleter,
|
|
228
|
+
) -> Tuple[bool, Optional[bytes]]:
|
|
229
|
+
"""以 base_bytes 为共同祖先,把云端相对 base 的行变更合到本地。成功返回 (True, 合并后本地字节)。"""
|
|
230
|
+
if base_bytes == cloud_payload:
|
|
231
|
+
return True, None
|
|
232
|
+
try:
|
|
233
|
+
exists, local_bytes, local_mode = local_reader(rel)
|
|
234
|
+
except OSError:
|
|
235
|
+
return False, None
|
|
236
|
+
if not exists or local_bytes is None:
|
|
237
|
+
# 本地没有文件:整份写入云端内容
|
|
238
|
+
if local_writer(rel, cloud_payload, mode or "644"):
|
|
239
|
+
print("本地写入 %s" % rel, flush=True)
|
|
240
|
+
return True, cloud_payload
|
|
241
|
+
return False, None
|
|
242
|
+
if local_bytes == cloud_payload:
|
|
243
|
+
return True, local_bytes
|
|
244
|
+
if local_bytes == base_bytes:
|
|
245
|
+
if local_writer(rel, cloud_payload, local_mode or mode or "644"):
|
|
246
|
+
print("本地写入 %s" % rel, flush=True)
|
|
247
|
+
return True, cloud_payload
|
|
248
|
+
return False, None
|
|
249
|
+
# 先试行级补丁(本地≈基线时最快);失败不打日志
|
|
250
|
+
_repo, _prefix, repo_path = find_repo_for_rel(local_root, rel)
|
|
251
|
+
if not repo_path:
|
|
252
|
+
repo_path = rel
|
|
253
|
+
change = change_from_contents(rel, repo_path, base_bytes, cloud_payload, mode or "644")
|
|
254
|
+
if change is not None and change.get("kind") != "binary":
|
|
255
|
+
applied, failed = transfer_line_changes(
|
|
256
|
+
[change], local_reader, local_writer, local_deleter,
|
|
257
|
+
success_verb="本地写入", quiet=True,
|
|
258
|
+
)
|
|
259
|
+
if applied and not failed:
|
|
260
|
+
full = os.path.join(local_root, *rel.split("/"))
|
|
261
|
+
try:
|
|
262
|
+
with open(full, "rb") as handle:
|
|
263
|
+
return True, handle.read()
|
|
264
|
+
except OSError:
|
|
265
|
+
return True, None
|
|
266
|
+
if change is not None and change.get("kind") == "binary":
|
|
267
|
+
return False, None
|
|
268
|
+
# 本地相对基线已有独有行:只把 base→cloud 增量打到本地,不把启动前本地/云端分叉互相并回来
|
|
269
|
+
merged = apply_line_delta_onto(base_bytes, cloud_payload, local_bytes)
|
|
270
|
+
if merged is None:
|
|
271
|
+
merged = three_way_merge_bytes(base_bytes, local_bytes, cloud_payload)
|
|
272
|
+
if merged is None:
|
|
273
|
+
return False, None
|
|
274
|
+
if merged == local_bytes:
|
|
275
|
+
return True, local_bytes
|
|
276
|
+
if local_writer(rel, merged, local_mode or mode or "644"):
|
|
277
|
+
print("本地写入 %s" % rel, flush=True)
|
|
278
|
+
return True, merged
|
|
279
|
+
return False, None
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import urllib.parse
|
|
5
|
+
|
|
6
|
+
def eprint(message: str) -> None:
|
|
7
|
+
print(message, file=sys.stderr)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_PORT = 27182
|
|
11
|
+
MAX_BYTES = 32 * 1024 * 1024
|
|
12
|
+
ROUTE_FILE = "/v1/file"
|
|
13
|
+
ROUTE_DELETE = "/v1/delete"
|
|
14
|
+
ROUTE_MANIFEST = "/v1/manifest"
|
|
15
|
+
ROUTE_PULL = "/v1/pull"
|
|
16
|
+
ROUTE_IGNORE = "/v1/ignore"
|
|
17
|
+
ROUTE_STAGE = "/v1/stage"
|
|
18
|
+
ROUTE_CHANGES = "/v1/changes"
|
|
19
|
+
ROUTE_HEALTH = "/health"
|
|
20
|
+
ROUTES = (ROUTE_MANIFEST, ROUTE_IGNORE, ROUTE_STAGE, ROUTE_CHANGES, ROUTE_DELETE, ROUTE_PULL, ROUTE_FILE, ROUTE_HEALTH)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def route_of(url_path: str) -> str:
|
|
24
|
+
path = urllib.parse.urlsplit(url_path).path
|
|
25
|
+
for route in ROUTES:
|
|
26
|
+
if path == route or path.endswith(route):
|
|
27
|
+
return route
|
|
28
|
+
return path
|