pyquipu-engine 0.1.0__py3-none-any.whl

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,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyquipu-engine
3
+ Version: 0.1.0
4
+ Summary: The state and history engine for Quipu, powered by Git plumbing.
5
+ Author-email: doucx <doucxldh@gmail.com>
6
+ License-Expression: Apache-2.0
7
+ Classifier: Operating System :: OS Independent
8
+ Classifier: Programming Language :: Python :: 3
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: pyquipu-bus~=0.1.0
11
+ Requires-Dist: pyquipu-common~=0.1.0
12
+ Requires-Dist: pyquipu-interfaces~=0.1.0
13
+ Requires-Dist: pyyaml>=6.0
@@ -0,0 +1,18 @@
1
+ quipu/__init__.py,sha256=dyHnnj2etUVMEOCjNbEguf3eMOLeylDaY3b4XcC8kFQ,180
2
+ quipu/engine/config.py,sha256=KXxvhhRczoudBDbQO5kgvKm-g_08-1ogFxfD4Zv46ts,2994
3
+ quipu/engine/config.stitcher.yaml,sha256=DOLUnyte8X1MpIJCrmrIDtCunKcaMyuQ09SYhwrTthU,749
4
+ quipu/engine/git_db.py,sha256=lFCPIBB2wVGVLvUIeZ9we8gMfFWBqCUeK7dR08jJF0Y,17286
5
+ quipu/engine/git_db.stitcher.yaml,sha256=GNXTu404seFbjyRYzWNEAqBl8Deq9F5olQsYXUeTVz0,3273
6
+ quipu/engine/git_object_storage.py,sha256=uxJ9Of_WXAppiFP5u4rxCSCCe3CjhhiY2__MJ4dS1m0,18344
7
+ quipu/engine/git_object_storage.stitcher.yaml,sha256=3pWdIAfjMG1XhVLnv4Vca55Ii5nMchkf29cMn7-1ovY,2135
8
+ quipu/engine/hydrator.py,sha256=8NYUNcuNnntrTKmCu1vx0fmMb8mN3ExJR8ImuellbXg,7015
9
+ quipu/engine/hydrator.stitcher.yaml,sha256=Nt7ejPyFuFewM0RVv4_DqKGIVA7zqLTALb5acWZ2yRA,470
10
+ quipu/engine/sqlite_db.py,sha256=5CUYYUZUDZsH1fvOIsBHGRtVX_5ZD0hFDuQWK0uhx4U,5018
11
+ quipu/engine/sqlite_db.stitcher.yaml,sha256=SvQUyv_YjATw_aVNgO1pEKjsFev7q1mmPZgEs98iTg8,714
12
+ quipu/engine/sqlite_storage.py,sha256=QMgTrmeTKtBY_K3xFyws_peGznnqEBn8bLRjIdD2TyQ,17733
13
+ quipu/engine/sqlite_storage.stitcher.yaml,sha256=h9Rz9wlCNGorCDx4WvZWGticlVKet8PzLwnMU6eVWgk,1649
14
+ quipu/engine/state_machine.py,sha256=o958CoDHb4xP2YXlIgHZAowbCfhPQpL0KELd20P0fxY,13768
15
+ quipu/engine/state_machine.stitcher.yaml,sha256=O4aMVJzY1GKdIAU1HGuY3DXjlcF6-6CcOxhvyvRE8dg,683
16
+ pyquipu_engine-0.1.0.dist-info/METADATA,sha256=CN2XRJELauQWMHj70py7Q0WdWHSuw9d8NAi8HKVJeas,462
17
+ pyquipu_engine-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
18
+ pyquipu_engine-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
quipu/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # This allows this package to coexist with other distribution packages
2
+ # that contribute to the 'quipu' namespace.
3
+ __path__ = __import__("pkgutil").extend_path(__path__, __name__)
quipu/engine/config.py ADDED
@@ -0,0 +1,88 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Any, Dict
4
+
5
+ import yaml
6
+ from quipu.bus import bus
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ # 默认配置,为所有可能的设置提供一个基础
11
+ DEFAULTS = {
12
+ "storage": {
13
+ "type": "sqlite", # 可选: "git_object", "sqlite"
14
+ },
15
+ "sync": {
16
+ "remote_name": "origin",
17
+ "persistent_ignores": [".idea", ".vscode", ".envs", "__pycache__", "node_modules", "o.md"],
18
+ "user_id": None,
19
+ "subscriptions": [],
20
+ },
21
+ "list_files": {"ignore_patterns": [".git", "__pycache__", ".idea", ".vscode", "node_modules", ".quipu"]},
22
+ }
23
+
24
+
25
+ class ConfigManager:
26
+ def __init__(self, work_dir: Path):
27
+ self.config_path = work_dir.resolve() / ".quipu" / "config.yml"
28
+ self.user_config: Dict[str, Any] = self._load_config()
29
+
30
+ def _load_config(self) -> Dict[str, Any]:
31
+ if not self.config_path.exists():
32
+ return {}
33
+
34
+ try:
35
+ with open(self.config_path, "r", encoding="utf-8") as f:
36
+ config_data = yaml.safe_load(f)
37
+ if not isinstance(config_data, dict):
38
+ bus.warning("engine.config.warning.invalidFormat", path=self.config_path)
39
+ return {}
40
+ return config_data
41
+ except yaml.YAMLError as e:
42
+ bus.error("engine.config.error.parseFailed", path=self.config_path, error=str(e))
43
+ return {}
44
+ except Exception as e:
45
+ bus.error("engine.config.error.readFailed", error=str(e))
46
+ return {}
47
+
48
+ def get(self, key: str, fallback: Any = None) -> Any:
49
+ # 尝试从用户配置中获取
50
+ user_val = self._get_nested(self.user_config, key)
51
+ if user_val is not None:
52
+ return user_val
53
+
54
+ # 尝试从默认配置中获取
55
+ default_val = self._get_nested(DEFAULTS, key)
56
+ if default_val is not None:
57
+ return default_val
58
+
59
+ # 返回最终的备用值
60
+ return fallback
61
+
62
+ def _get_nested(self, data: Dict, key: str) -> Any:
63
+ keys = key.split(".")
64
+ current = data
65
+ for k in keys:
66
+ if isinstance(current, dict) and k in current:
67
+ current = current[k]
68
+ else:
69
+ return None
70
+ return current
71
+
72
+ def set(self, key: str, value: Any):
73
+ keys = key.split(".")
74
+ d = self.user_config
75
+ for k in keys[:-1]:
76
+ d = d.setdefault(k, {})
77
+ d[keys[-1]] = value
78
+ logger.debug(f"配置已更新: {key} = {value}")
79
+
80
+ def save(self):
81
+ try:
82
+ self.config_path.parent.mkdir(exist_ok=True)
83
+ with open(self.config_path, "w", encoding="utf-8") as f:
84
+ yaml.dump(self.user_config, f, default_flow_style=False, allow_unicode=True)
85
+ bus.success("engine.config.success.saved", path=self.config_path)
86
+ except Exception as e:
87
+ bus.error("engine.config.error.saveFailed", error=str(e))
88
+ raise
@@ -0,0 +1,18 @@
1
+ "ConfigManager": |-
2
+ 负责加载和管理 .quipu/config.yml 文件。
3
+ "ConfigManager._get_nested": |-
4
+ 使用点状符号安全地访问嵌套字典。
5
+ "ConfigManager._load_config": |-
6
+ 加载并解析 YAML 配置文件,如果文件不存在或无效则返回空字典。
7
+ "ConfigManager.get": |-
8
+ 获取一个配置值,支持点状符号进行嵌套访问 (e.g., 'sync.remote_name')。
9
+
10
+ 查找顺序:
11
+ 1. 用户配置 (`config.yml`)
12
+ 2. 内置默认值 (`DEFAULTS`)
13
+ 3. 提供的 `fallback` 值
14
+ "ConfigManager.save": |-
15
+ 将当前的 user_config 写回到 .quipu/config.yml 文件。
16
+ "ConfigManager.set": |-
17
+ 设置一个配置值,支持点状符号进行嵌套访问。
18
+ 如果中间路径的字典不存在,会自动创建。
quipu/engine/git_db.py ADDED
@@ -0,0 +1,418 @@
1
+ import logging
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+ from typing import Dict, List, Optional, Tuple, Union
8
+
9
+ from quipu.bus import bus
10
+ from quipu.interfaces.exceptions import ExecutionError
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class GitDB:
16
+ def __init__(self, root_dir: Path):
17
+ if not shutil.which("git"):
18
+ raise ExecutionError("未找到 'git' 命令。请安装 Git 并确保它在系统的 PATH 中。")
19
+
20
+ self.root = root_dir.resolve()
21
+ self.quipu_dir = self.root / ".quipu"
22
+ self._ensure_git_repo()
23
+
24
+ def _ensure_git_repo(self):
25
+ if not (self.root / ".git").is_dir():
26
+ # 这是一个关键的前置条件检查
27
+ raise ExecutionError(f"工作目录 '{self.root}' 不是一个有效的 Git 仓库。请先运行 'git init'。")
28
+
29
+ def _run(
30
+ self,
31
+ args: list[str],
32
+ env: Optional[Dict] = None,
33
+ check: bool = True,
34
+ log_error: bool = True,
35
+ input_data: Optional[Union[str, bytes]] = None,
36
+ capture_as_text: bool = True,
37
+ ) -> subprocess.CompletedProcess:
38
+ full_env = os.environ.copy()
39
+ if env:
40
+ full_env.update(env)
41
+
42
+ try:
43
+ result = subprocess.run(
44
+ ["git"] + args,
45
+ cwd=self.root,
46
+ env=full_env,
47
+ capture_output=True,
48
+ text=capture_as_text,
49
+ check=check,
50
+ input=input_data,
51
+ )
52
+ return result
53
+ except subprocess.CalledProcessError as e:
54
+ stderr_str = e.stderr
55
+ if isinstance(stderr_str, bytes):
56
+ stderr_str = stderr_str.decode("utf-8", "ignore")
57
+
58
+ if log_error:
59
+ logger.error(f"Git plumbing error: {stderr_str}")
60
+ raise RuntimeError(f"Git command failed: {' '.join(args)}\n{stderr_str}") from e
61
+
62
+ @contextmanager
63
+ def shadow_index(self):
64
+ index_path = self.quipu_dir / "tmp_index"
65
+ self.quipu_dir.mkdir(exist_ok=True)
66
+
67
+ # --- 性能优化:通过复制用户的索引来“预热”影子索引 ---
68
+ # 这避免了从零开始扫描整个仓库的巨大开销。
69
+ # 后续的 `git add -A` 只需要处理未暂存的变更。
70
+ user_index_path = self.root / ".git" / "index"
71
+ if user_index_path.exists():
72
+ try:
73
+ shutil.copy2(user_index_path, index_path)
74
+ except OSError as e:
75
+ bus.warning("engine.git.warning.copyIndexFailed", error=str(e))
76
+
77
+ # 定义隔离的环境变量
78
+ env = {"GIT_INDEX_FILE": str(index_path)}
79
+
80
+ try:
81
+ yield env
82
+ finally:
83
+ # 无论成功失败,必须清理临时索引文件
84
+ if index_path.exists():
85
+ try:
86
+ index_path.unlink()
87
+ except OSError:
88
+ logger.warning(f"Failed to cleanup shadow index: {index_path}")
89
+
90
+ def get_tree_hash(self) -> str:
91
+ with self.shadow_index() as env:
92
+ # 阶段 1: 更新索引以匹配工作区。
93
+ # 由于 shadow_index 上下文已经通过复制预热了索引,
94
+ # 此处的 `git add -A` 只会处理少量未暂存的变更,速度非常快。
95
+ self._run(["add", "-A", "--ignore-errors"], env=env)
96
+
97
+ # 阶段 2: 显式移除 .quipu 目录作为安全网。
98
+ self._run(["rm", "--cached", "-r", ".quipu"], env=env, check=False)
99
+
100
+ # 阶段 3: 将最终的纯净索引写入对象库,返回 Tree Hash。
101
+ result = self._run(["write-tree"], env=env)
102
+ return result.stdout.strip()
103
+
104
+ def hash_object(self, content_bytes: bytes, object_type: str = "blob") -> str:
105
+ try:
106
+ result = subprocess.run(
107
+ ["git", "hash-object", "-w", "-t", object_type, "--stdin"],
108
+ cwd=self.root,
109
+ input=content_bytes,
110
+ capture_output=True,
111
+ check=True,
112
+ )
113
+ return result.stdout.decode("utf-8").strip()
114
+ except subprocess.CalledProcessError as e:
115
+ stderr_str = e.stderr.decode("utf-8") if e.stderr else "No stderr"
116
+ logger.error(f"Git hash-object failed: {stderr_str}")
117
+ raise RuntimeError(f"Git command failed: hash-object\n{stderr_str}") from e
118
+
119
+ def mktree(self, tree_descriptor: str) -> str:
120
+ result = self._run(["mktree"], input_data=tree_descriptor)
121
+ return result.stdout.strip()
122
+
123
+ def commit_tree(self, tree_hash: str, parent_hashes: Optional[List[str]], message: str) -> str:
124
+ cmd = ["commit-tree", tree_hash]
125
+ if parent_hashes:
126
+ for p in parent_hashes:
127
+ cmd.extend(["-p", p])
128
+
129
+ result = self._run(cmd, input_data=message)
130
+ return result.stdout.strip()
131
+
132
+ def update_ref(self, ref_name: str, commit_hash: str):
133
+ self._run(["update-ref", ref_name, commit_hash])
134
+
135
+ def delete_ref(self, ref_name: str):
136
+ self._run(["update-ref", "-d", ref_name], check=False)
137
+
138
+ def get_commit_by_output_tree(self, tree_hash: str) -> Optional[str]:
139
+ # 使用 grep 搜索所有 refs/quipu/ 下的记录
140
+ # 注意:这假设 Output Tree 是唯一的,这在大概率上是成立的,
141
+ # 且即使有重复(如 merge),找到任意一个作为父节点通常也是可接受的起点。
142
+ cmd = ["log", "--all", f"--grep=X-Quipu-Output-Tree: {tree_hash}", "--format=%H", "-n", "1"]
143
+ res = self._run(cmd, check=False)
144
+ if res.returncode == 0 and res.stdout.strip():
145
+ return res.stdout.strip()
146
+ return None
147
+
148
+ def get_head_commit(self) -> Optional[str]:
149
+ try:
150
+ result = self._run(["rev-parse", "HEAD"])
151
+ return result.stdout.strip()
152
+ except RuntimeError:
153
+ return None # 可能是空仓库
154
+
155
+ def is_ancestor(self, ancestor: str, descendant: str) -> bool:
156
+ # merge-base --is-ancestor A B 返回 0 表示真,1 表示假
157
+ # 我们在这里直接调用 subprocess,因为我们关心返回码而不是输出
158
+ result = self._run(
159
+ ["merge-base", "--is-ancestor", ancestor, descendant],
160
+ check=False, # 必须禁用 check,否则非 0 退出码会抛异常
161
+ log_error=False, # 我们不认为这是一个错误
162
+ )
163
+ return result.returncode == 0
164
+
165
+ def get_diff_stat(self, old_tree: str, new_tree: str, count=30) -> str:
166
+ # 使用 --stat=<width>,<name-width>,<count> 格式
167
+ # 我们不关心 width,所以留空,只设置 count
168
+ result = self._run(["diff-tree", f"--stat=,,{count}", old_tree, new_tree])
169
+ return result.stdout.strip()
170
+
171
+ def get_diff_name_status(self, old_tree: str, new_tree: str) -> List[Tuple[str, str]]:
172
+ result = self._run(["diff-tree", "--name-status", "--no-commit-id", "-r", old_tree, new_tree])
173
+ changes = []
174
+ for line in result.stdout.strip().splitlines():
175
+ if not line:
176
+ continue
177
+ parts = line.split("\t", 1)
178
+ if len(parts) == 2:
179
+ status, path = parts
180
+ changes.append((status, path))
181
+ return changes
182
+
183
+ def checkout_tree(self, new_tree_hash: str, old_tree_hash: Optional[str] = None):
184
+ bus.info("engine.git.info.checkoutStarted", short_hash=new_tree_hash[:7])
185
+
186
+ # 1. 高性能检出核心
187
+ # --reset: 类似于 git reset --hard,强制覆盖本地未提交的变更,解决 "not uptodate" 冲突。
188
+ # -u: 更新工作区文件。Git 会自动对比当前索引,只对发生变更的文件执行 I/O (更新 mtime)。
189
+ # 这就是我们要的 "tree-vs-tree" 优化,不需要手动传入 old_tree_hash,因为当前索引就是 old_tree。
190
+ logger.debug(f"执行优化的强制检出: -> {new_tree_hash[:7]}")
191
+ self._run(["read-tree", "--reset", "-u", new_tree_hash])
192
+
193
+ # 2. 清理工作区中多余的文件和目录
194
+ # read-tree -u 会删除旧树中有但新树中没有的文件。
195
+ # 但它不会删除 "未追踪 (Untracked)" 的新文件。我们需要用 clean 来处理它们。
196
+ # -d: 目录, -f: 强制
197
+ # -e .quipu: 排除 .quipu 目录,防止自毁
198
+ self._run(["clean", "-df", "-e", ".quipu"])
199
+
200
+ bus.success("engine.git.success.checkoutComplete")
201
+
202
+ def cat_file(self, object_hash: str, object_type: str) -> bytes:
203
+ cmd = ["cat-file", object_type, object_hash]
204
+ result = self._run(cmd, capture_as_text=False)
205
+ return result.stdout
206
+
207
+ def get_blobs_from_tree(self, tree_hash: str) -> Dict[str, bytes]:
208
+ # 1. 获取 Tree 的内容
209
+ tree_content_bytes = self.cat_file(tree_hash, "tree")
210
+ tree_content = tree_content_bytes.decode("utf-8", "ignore")
211
+
212
+ # 2. 解析 Tree 内容以获取 blob 哈希
213
+ # 格式: <mode> <type> <hash>\t<filename>
214
+ blob_info = {}
215
+ for line in tree_content.strip().splitlines():
216
+ parts = line.split()
217
+ if len(parts) == 4 and parts[1] == "blob":
218
+ blob_hash, filename = parts[2], parts[3]
219
+ blob_info[filename] = blob_hash
220
+
221
+ if not blob_info:
222
+ return {}
223
+
224
+ # 3. 批量获取所有 blob 的内容
225
+ return self.batch_cat_file(list(blob_info.values()))
226
+
227
+ def batch_cat_file(self, object_hashes: List[str]) -> Dict[str, bytes]:
228
+ if not object_hashes:
229
+ return {}
230
+
231
+ # Deduplicate
232
+ unique_hashes = list(set(object_hashes))
233
+
234
+ # Prepare input: <hash>\n
235
+ input_str = "\n".join(unique_hashes) + "\n"
236
+
237
+ results = {}
238
+
239
+ try:
240
+ # git cat-file --batch format:
241
+ # <hash> <type> <size>\n
242
+ # <content>\n
243
+ with subprocess.Popen(
244
+ ["git", "cat-file", "--batch"],
245
+ cwd=self.root,
246
+ stdin=subprocess.PIPE,
247
+ stdout=subprocess.PIPE,
248
+ # bufsize=0 is often recommended for binary streams but careful buffering is usually fine
249
+ ) as proc:
250
+ # Write requests and close stdin to signal EOF
251
+ if proc.stdin:
252
+ proc.stdin.write(input_str.encode("utf-8"))
253
+ proc.stdin.close()
254
+
255
+ if not proc.stdout:
256
+ return {}
257
+
258
+ while True:
259
+ # Read header line
260
+ header_line = proc.stdout.readline()
261
+ if not header_line:
262
+ break
263
+
264
+ header_parts = header_line.strip().split()
265
+ if not header_parts:
266
+ continue
267
+
268
+ obj_hash_bytes = header_parts[0]
269
+ obj_hash = obj_hash_bytes.decode("utf-8")
270
+
271
+ # Check for missing object: "<hash> missing"
272
+ if len(header_parts) == 2 and header_parts[1] == b"missing":
273
+ continue
274
+
275
+ if len(header_parts) < 3:
276
+ logger.warning(f"Unexpected git cat-file header: {header_line}")
277
+ continue
278
+
279
+ # size is at index 2
280
+ try:
281
+ size = int(header_parts[2])
282
+ except ValueError:
283
+ logger.warning(f"Invalid size in header: {header_line}")
284
+ continue
285
+
286
+ # Read content bytes + trailing newline
287
+ content = proc.stdout.read(size)
288
+ proc.stdout.read(1) # Consume the trailing LF
289
+
290
+ results[obj_hash] = content
291
+
292
+ except Exception as e:
293
+ logger.error(f"Batch cat-file failed: {e}")
294
+ raise RuntimeError(f"Git batch operation failed: {e}") from e
295
+
296
+ return results
297
+
298
+ def get_all_ref_heads(self, prefix: str) -> List[Tuple[str, str]]:
299
+ res = self._run(["for-each-ref", "--format=%(objectname) %(refname)", prefix], check=False)
300
+ if res.returncode != 0 or not res.stdout.strip():
301
+ return []
302
+
303
+ results = []
304
+ for line in res.stdout.strip().splitlines():
305
+ parts = line.split(" ", 1)
306
+ if len(parts) == 2:
307
+ results.append((parts[0], parts[1]))
308
+ return results
309
+
310
+ def has_quipu_ref(self) -> bool:
311
+ # We use show-ref and check the exit code. Exit 0 if refs exist, 1 otherwise.
312
+ res = self._run(["show-ref", "--verify", "--quiet", "refs/quipu/"], check=False, log_error=False)
313
+ return res.returncode == 0
314
+
315
+ def log_ref(self, ref_names: Union[str, List[str]]) -> List[Dict[str, str]]:
316
+ # A unique delimiter that's unlikely to appear in commit messages
317
+ DELIMITER = "---QUIPU-LOG-ENTRY---"
318
+ # Format: H=hash, P=parent, T=tree, ct=commit_timestamp, B=body
319
+ log_format = f"%H%n%P%n%T%n%ct%n%B{DELIMITER}"
320
+
321
+ if isinstance(ref_names, str):
322
+ refs_to_log = [ref_names]
323
+ else:
324
+ refs_to_log = ref_names
325
+
326
+ if not refs_to_log:
327
+ return []
328
+
329
+ # Git log on multiple refs will automatically show the union of their histories without duplicates.
330
+ cmd = ["log", f"--format={log_format}"] + refs_to_log
331
+ res = self._run(cmd, check=False, log_error=False)
332
+
333
+ if res.returncode != 0:
334
+ return []
335
+
336
+ entries = res.stdout.strip().split(DELIMITER)
337
+ parsed_logs = []
338
+ for entry in entries:
339
+ if not entry.strip():
340
+ continue
341
+
342
+ parts = entry.strip().split("\n", 4)
343
+ if len(parts) >= 4:
344
+ parsed_logs.append(
345
+ {
346
+ "hash": parts[0],
347
+ "parent": parts[1],
348
+ "tree": parts[2],
349
+ "timestamp": parts[3],
350
+ "body": parts[4] if len(parts) > 4 else "",
351
+ }
352
+ )
353
+ return parsed_logs
354
+
355
+ def push_quipu_refs(self, remote: str, user_id: str, force: bool = False):
356
+ refspec = f"refs/quipu/local/heads/*:refs/quipu/users/{user_id}/heads/*"
357
+ action = "Force-pushing" if force else "Pushing"
358
+ bus.info("engine.git.info.pushing", action=action, remote=remote, user_id=user_id)
359
+
360
+ cmd = ["push", remote, refspec]
361
+ if force:
362
+ cmd.extend(["--force", "--prune"])
363
+ self._run(cmd)
364
+
365
+ def fetch_quipu_refs(self, remote: str, user_id: str):
366
+ refspec = f"refs/quipu/users/{user_id}/heads/*:refs/quipu/remotes/{remote}/{user_id}/heads/*"
367
+ bus.info("engine.git.info.fetching", remote=remote, user_id=user_id)
368
+ self._run(["fetch", remote, "--prune", refspec])
369
+
370
+ def reconcile_local_with_remote(self, remote: str, user_id: str):
371
+ remote_heads_prefix = f"refs/quipu/remotes/{remote}/{user_id}/heads/"
372
+ remote_heads = self.get_all_ref_heads(remote_heads_prefix)
373
+ if not remote_heads:
374
+ logger.debug("No remote refs found to reconcile.")
375
+ return
376
+
377
+ reconciled_count = 0
378
+ for commit_hash, remote_ref in remote_heads:
379
+ # e.g., remote_ref = refs/quipu/remotes/origin/user/heads/abc...
380
+ # local_ref should be refs/quipu/local/heads/abc...
381
+ local_ref_suffix = remote_ref.replace(remote_heads_prefix, "")
382
+ local_ref = f"refs/quipu/local/heads/{local_ref_suffix}"
383
+
384
+ # 使用 rev-parse 检查本地 ref 是否已存在
385
+ # 如果本地已经存在,我们假设它是最新的或用户有意为之,不做任何操作
386
+ check_res = self._run(["rev-parse", "--verify", local_ref], check=False, log_error=False)
387
+ if check_res.returncode != 0:
388
+ # 本地不存在此 ref,从远程镜像创建它
389
+ self.update_ref(local_ref, commit_hash)
390
+ reconciled_count += 1
391
+ bus.info("engine.git.info.reconciledNewBranch", short_hash=commit_hash[:7])
392
+
393
+ if reconciled_count > 0:
394
+ bus.success("engine.git.success.reconciliationComplete", count=reconciled_count)
395
+ else:
396
+ logger.debug("✅ Local history is already up-to-date with remote.")
397
+
398
+ def prune_local_from_remote(self, remote: str, user_id: str):
399
+ local_prefix = "refs/quipu/local/heads/"
400
+ remote_prefix = f"refs/quipu/remotes/{remote}/{user_id}/heads/"
401
+
402
+ local_heads = {ref.replace(local_prefix, "") for _, ref in self.get_all_ref_heads(local_prefix)}
403
+ remote_heads = {ref.replace(remote_prefix, "") for _, ref in self.get_all_ref_heads(remote_prefix)}
404
+
405
+ to_delete = local_heads - remote_heads
406
+ if not to_delete:
407
+ logger.debug("✅ No local refs to prune.")
408
+ return
409
+
410
+ deleted_count = 0
411
+ for ref_suffix in to_delete:
412
+ local_ref_to_delete = local_prefix + ref_suffix
413
+ self.delete_ref(local_ref_to_delete)
414
+ deleted_count += 1
415
+ bus.info("engine.git.info.prunedRef", ref=local_ref_to_delete)
416
+
417
+ if deleted_count > 0:
418
+ bus.success("engine.git.success.pruningComplete", count=deleted_count)
@@ -0,0 +1,74 @@
1
+ "GitDB": |-
2
+ Quipu 的 Git 底层接口 (Plumbing Interface)。
3
+ 负责与 Git 对象数据库交互,维护 Shadow Index 和 Refs。
4
+ "GitDB._ensure_git_repo": |-
5
+ 确保目标是一个 Git 仓库
6
+ "GitDB._run": |-
7
+ 执行 git 命令的底层封装,支持文本和二进制输出。
8
+ "GitDB.batch_cat_file": |-
9
+ 批量读取 Git 对象。
10
+ 解决 N+1 查询性能问题。
11
+
12
+ Args:
13
+ object_hashes: 需要读取的对象哈希列表 (可以重复,内部会自动去重)
14
+
15
+ Returns:
16
+ Dict[hash, content_bytes]: 哈希到内容的映射。
17
+ 如果对象不存在,则不会出现在返回字典中。
18
+ "GitDB.cat_file": |-
19
+ 读取 Git 对象的原始内容,返回字节流。
20
+ 此方法现在以二进制模式运行,以避免数据损坏。
21
+ "GitDB.checkout_tree": |-
22
+ 将工作区强制重置为目标 Tree 的状态。
23
+ 使用 read-tree --reset -u 实现高性能的增量更新。
24
+ "GitDB.commit_tree": |-
25
+ 创建一个 commit 对象并返回其哈希。
26
+ "GitDB.delete_ref": |-
27
+ 删除指定的引用
28
+ "GitDB.fetch_quipu_refs": |-
29
+ 从远程用户专属命名空间拉取 Quipu heads 到本地镜像。
30
+ 遵循 QDPS v1.1 规范。
31
+ "GitDB.get_all_ref_heads": |-
32
+ 查找指定前缀下的所有 ref heads。
33
+ 返回 (commit_hash, ref_name) 元组列表。
34
+ "GitDB.get_blobs_from_tree": |-
35
+ 解析一个 Tree 对象,并返回其包含的所有 blob 文件的 {filename: content_bytes} 字典。
36
+ "GitDB.get_commit_by_output_tree": |-
37
+ 根据 Trailer 中的 X-Quipu-Output-Tree 查找对应的 Commit Hash。
38
+ 用于在创建新节点时定位语义上的父节点。
39
+ "GitDB.get_diff_name_status": |-
40
+ 获取两个 Tree 之间的文件变更状态列表 (M, A, D, etc.)。
41
+ "GitDB.get_diff_stat": |-
42
+ 获取两个 Tree 之间的差异统计 (Human Readable)。
43
+ 默认限制输出为最多 30 行,以避免在有大量文件变更时生成过大的摘要。
44
+ "GitDB.get_head_commit": |-
45
+ 获取当前工作区 HEAD 的 Commit Hash
46
+ "GitDB.get_tree_hash": |-
47
+ 计算当前工作区的 Tree Hash (Snapshot)。
48
+ 实现 'State is Truth' 的核心。
49
+ "GitDB.has_quipu_ref": |-
50
+ 检查是否存在任何 'refs/quipu/' 引用,用于判断存储格式。
51
+ "GitDB.hash_object": |-
52
+ 将内容写入 Git 对象数据库并返回对象哈希。
53
+ "GitDB.is_ancestor": |-
54
+ 判断两个 Commit 是否具有血统关系。
55
+ 用于解决 'Lost Time' 问题。
56
+ "GitDB.log_ref": |-
57
+ 获取指定引用的日志,并解析为结构化数据列表。
58
+ "GitDB.mktree": |-
59
+ 从描述符创建 tree 对象并返回其哈希。
60
+ "GitDB.prune_local_from_remote": |-
61
+ 用远程镜像修剪本地历史。
62
+ 删除本地存在但远程镜像中已不存在的 'local/heads'。
63
+ "GitDB.push_quipu_refs": |-
64
+ 将本地 Quipu heads 推送到远程用户专属的命名空间。
65
+ 遵循 QDPS v1.1 规范。
66
+ "GitDB.reconcile_local_with_remote": |-
67
+ 将远程拉取下来的历史 (remotes) 与本地历史 (local) 进行调和。
68
+ 这是一个安全的操作,只会添加本地不存在的远程引用。
69
+ "GitDB.shadow_index": |-
70
+ 上下文管理器:创建一个隔离的 Shadow Index。
71
+ 在此上下文内的操作不会污染用户的 .git/index。
72
+ "GitDB.update_ref": |-
73
+ 更新引用 (如 refs/quipu/history)。
74
+ 防止 Commit 被 GC 回收。