pyquipu-engine 0.1.0__tar.gz

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.
Files changed (51) hide show
  1. pyquipu_engine-0.1.0/.gitignore +75 -0
  2. pyquipu_engine-0.1.0/PKG-INFO +13 -0
  3. pyquipu_engine-0.1.0/pyproject.toml +29 -0
  4. pyquipu_engine-0.1.0/src/quipu/__init__.py +3 -0
  5. pyquipu_engine-0.1.0/src/quipu/engine/config.py +88 -0
  6. pyquipu_engine-0.1.0/src/quipu/engine/config.stitcher.yaml +18 -0
  7. pyquipu_engine-0.1.0/src/quipu/engine/git_db.py +418 -0
  8. pyquipu_engine-0.1.0/src/quipu/engine/git_db.stitcher.yaml +74 -0
  9. pyquipu_engine-0.1.0/src/quipu/engine/git_object_storage.py +475 -0
  10. pyquipu_engine-0.1.0/src/quipu/engine/git_object_storage.stitcher.yaml +46 -0
  11. pyquipu_engine-0.1.0/src/quipu/engine/hydrator.py +163 -0
  12. pyquipu_engine-0.1.0/src/quipu/engine/hydrator.stitcher.yaml +10 -0
  13. pyquipu_engine-0.1.0/src/quipu/engine/sqlite_db.py +132 -0
  14. pyquipu_engine-0.1.0/src/quipu/engine/sqlite_db.stitcher.yaml +19 -0
  15. pyquipu_engine-0.1.0/src/quipu/engine/sqlite_storage.py +425 -0
  16. pyquipu_engine-0.1.0/src/quipu/engine/sqlite_storage.stitcher.yaml +32 -0
  17. pyquipu_engine-0.1.0/src/quipu/engine/state_machine.py +372 -0
  18. pyquipu_engine-0.1.0/src/quipu/engine/state_machine.stitcher.yaml +16 -0
  19. pyquipu_engine-0.1.0/stitcher.lock +1966 -0
  20. pyquipu_engine-0.1.0/tests/integration/sqlite/test_hydrator.py +98 -0
  21. pyquipu_engine-0.1.0/tests/integration/sqlite/test_hydrator.stitcher.yaml +8 -0
  22. pyquipu_engine-0.1.0/tests/integration/sqlite/test_reader.py +199 -0
  23. pyquipu_engine-0.1.0/tests/integration/sqlite/test_reader.stitcher.yaml +10 -0
  24. pyquipu_engine-0.1.0/tests/integration/sqlite/test_writer.py +90 -0
  25. pyquipu_engine-0.1.0/tests/integration/sqlite/test_writer.stitcher.yaml +5 -0
  26. pyquipu_engine-0.1.0/tests/integration/sqlite/test_writer_idempotency.py +78 -0
  27. pyquipu_engine-0.1.0/tests/integration/sqlite/test_writer_idempotency.stitcher.yaml +9 -0
  28. pyquipu_engine-0.1.0/tests/integration/test_branching.py +60 -0
  29. pyquipu_engine-0.1.0/tests/integration/test_branching.stitcher.yaml +8 -0
  30. pyquipu_engine-0.1.0/tests/integration/test_checkout_behavior.py +105 -0
  31. pyquipu_engine-0.1.0/tests/integration/test_checkout_behavior.stitcher.yaml +8 -0
  32. pyquipu_engine-0.1.0/tests/integration/test_engine.py +193 -0
  33. pyquipu_engine-0.1.0/tests/integration/test_engine.stitcher.yaml +17 -0
  34. pyquipu_engine-0.1.0/tests/integration/test_git_db.py +276 -0
  35. pyquipu_engine-0.1.0/tests/integration/test_git_db.stitcher.yaml +35 -0
  36. pyquipu_engine-0.1.0/tests/integration/test_git_reader.py +184 -0
  37. pyquipu_engine-0.1.0/tests/integration/test_git_reader.stitcher.yaml +12 -0
  38. pyquipu_engine-0.1.0/tests/integration/test_git_writer.py +103 -0
  39. pyquipu_engine-0.1.0/tests/integration/test_git_writer.stitcher.yaml +6 -0
  40. pyquipu_engine-0.1.0/tests/integration/test_head_tracking.py +123 -0
  41. pyquipu_engine-0.1.0/tests/integration/test_head_tracking.stitcher.yaml +9 -0
  42. pyquipu_engine-0.1.0/tests/unit/sqlite/test_reader_integrity.py +104 -0
  43. pyquipu_engine-0.1.0/tests/unit/sqlite/test_reader_integrity.stitcher.yaml +15 -0
  44. pyquipu_engine-0.1.0/tests/unit/test_config.py +93 -0
  45. pyquipu_engine-0.1.0/tests/unit/test_config.stitcher.yaml +12 -0
  46. pyquipu_engine-0.1.0/tests/unit/test_deduplication.py +44 -0
  47. pyquipu_engine-0.1.0/tests/unit/test_deduplication.stitcher.yaml +3 -0
  48. pyquipu_engine-0.1.0/tests/unit/test_engine_memory.py +62 -0
  49. pyquipu_engine-0.1.0/tests/unit/test_engine_memory.stitcher.yaml +8 -0
  50. pyquipu_engine-0.1.0/tests/unit/test_navigation.py +95 -0
  51. pyquipu_engine-0.1.0/tests/unit/test_navigation.stitcher.yaml +2 -0
@@ -0,0 +1,75 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # tmpfiles
7
+ o.md
8
+ a.md
9
+ a
10
+ o
11
+ t
12
+ tmpfile
13
+
14
+ # Scaffoldings
15
+ context_builder.yml
16
+ context_builder.py
17
+ current_prompts
18
+
19
+ # Distribution / Packaging
20
+ .Python
21
+ build/
22
+ develop-eggs/
23
+ dist/
24
+ downloads/
25
+ eggs/
26
+ .eggs/
27
+ lib/
28
+ lib64/
29
+ parts/
30
+ sdist/
31
+ var/
32
+ wheels/
33
+ *.egg-info/
34
+ .installed.cfg
35
+ *.egg
36
+
37
+ # Virtual Environments
38
+ venv/
39
+ env/
40
+ .env
41
+ .venv/
42
+
43
+ # Testing
44
+ .tox/
45
+ .coverage
46
+ .coverage.*
47
+ .cache
48
+ nosetests.xml
49
+ coverage.xml
50
+ *.cover
51
+ .hypothesis/
52
+ .pytest_cache/
53
+
54
+ # IDEs
55
+ .idea/
56
+ .vscode/
57
+ *.swp
58
+ *.swo
59
+
60
+ # Project Specific
61
+ *.log
62
+ axon.log
63
+ # Ignore test vaults or scratchpads if created in root
64
+ vault/
65
+ temp/
66
+ scratch/
67
+ # --- Quipu Dev Infra ---
68
+ .envs/
69
+ .uv/
70
+ sandbox/
71
+ *.egg-info/
72
+ __pycache__/
73
+ .pytest_cache/
74
+ .coverage
75
+ htmlcov/
@@ -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,29 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pyquipu-engine"
7
+ version = "0.1.0"
8
+ authors = [{ name="doucx", email="doucxldh@gmail.com" }]
9
+ description = "The state and history engine for Quipu, powered by Git plumbing."
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "Operating System :: OS Independent",
15
+ ]
16
+ dependencies = [
17
+ "pyquipu-interfaces ~= 0.1.0",
18
+ "pyquipu-common ~= 0.1.0",
19
+ "pyquipu-bus ~= 0.1.0",
20
+ "pyyaml >= 6.0",
21
+ ]
22
+
23
+ [tool.uv.sources]
24
+ pyquipu-interfaces = { workspace = true }
25
+ pyquipu-common = { workspace = true }
26
+ pyquipu-bus = { workspace = true }
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/quipu"]
@@ -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__)
@@ -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
+ 如果中间路径的字典不存在,会自动创建。
@@ -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)