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