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.
- pyquipu_engine-0.1.0/.gitignore +75 -0
- pyquipu_engine-0.1.0/PKG-INFO +13 -0
- pyquipu_engine-0.1.0/pyproject.toml +29 -0
- pyquipu_engine-0.1.0/src/quipu/__init__.py +3 -0
- pyquipu_engine-0.1.0/src/quipu/engine/config.py +88 -0
- pyquipu_engine-0.1.0/src/quipu/engine/config.stitcher.yaml +18 -0
- pyquipu_engine-0.1.0/src/quipu/engine/git_db.py +418 -0
- pyquipu_engine-0.1.0/src/quipu/engine/git_db.stitcher.yaml +74 -0
- pyquipu_engine-0.1.0/src/quipu/engine/git_object_storage.py +475 -0
- pyquipu_engine-0.1.0/src/quipu/engine/git_object_storage.stitcher.yaml +46 -0
- pyquipu_engine-0.1.0/src/quipu/engine/hydrator.py +163 -0
- pyquipu_engine-0.1.0/src/quipu/engine/hydrator.stitcher.yaml +10 -0
- pyquipu_engine-0.1.0/src/quipu/engine/sqlite_db.py +132 -0
- pyquipu_engine-0.1.0/src/quipu/engine/sqlite_db.stitcher.yaml +19 -0
- pyquipu_engine-0.1.0/src/quipu/engine/sqlite_storage.py +425 -0
- pyquipu_engine-0.1.0/src/quipu/engine/sqlite_storage.stitcher.yaml +32 -0
- pyquipu_engine-0.1.0/src/quipu/engine/state_machine.py +372 -0
- pyquipu_engine-0.1.0/src/quipu/engine/state_machine.stitcher.yaml +16 -0
- pyquipu_engine-0.1.0/stitcher.lock +1966 -0
- pyquipu_engine-0.1.0/tests/integration/sqlite/test_hydrator.py +98 -0
- pyquipu_engine-0.1.0/tests/integration/sqlite/test_hydrator.stitcher.yaml +8 -0
- pyquipu_engine-0.1.0/tests/integration/sqlite/test_reader.py +199 -0
- pyquipu_engine-0.1.0/tests/integration/sqlite/test_reader.stitcher.yaml +10 -0
- pyquipu_engine-0.1.0/tests/integration/sqlite/test_writer.py +90 -0
- pyquipu_engine-0.1.0/tests/integration/sqlite/test_writer.stitcher.yaml +5 -0
- pyquipu_engine-0.1.0/tests/integration/sqlite/test_writer_idempotency.py +78 -0
- pyquipu_engine-0.1.0/tests/integration/sqlite/test_writer_idempotency.stitcher.yaml +9 -0
- pyquipu_engine-0.1.0/tests/integration/test_branching.py +60 -0
- pyquipu_engine-0.1.0/tests/integration/test_branching.stitcher.yaml +8 -0
- pyquipu_engine-0.1.0/tests/integration/test_checkout_behavior.py +105 -0
- pyquipu_engine-0.1.0/tests/integration/test_checkout_behavior.stitcher.yaml +8 -0
- pyquipu_engine-0.1.0/tests/integration/test_engine.py +193 -0
- pyquipu_engine-0.1.0/tests/integration/test_engine.stitcher.yaml +17 -0
- pyquipu_engine-0.1.0/tests/integration/test_git_db.py +276 -0
- pyquipu_engine-0.1.0/tests/integration/test_git_db.stitcher.yaml +35 -0
- pyquipu_engine-0.1.0/tests/integration/test_git_reader.py +184 -0
- pyquipu_engine-0.1.0/tests/integration/test_git_reader.stitcher.yaml +12 -0
- pyquipu_engine-0.1.0/tests/integration/test_git_writer.py +103 -0
- pyquipu_engine-0.1.0/tests/integration/test_git_writer.stitcher.yaml +6 -0
- pyquipu_engine-0.1.0/tests/integration/test_head_tracking.py +123 -0
- pyquipu_engine-0.1.0/tests/integration/test_head_tracking.stitcher.yaml +9 -0
- pyquipu_engine-0.1.0/tests/unit/sqlite/test_reader_integrity.py +104 -0
- pyquipu_engine-0.1.0/tests/unit/sqlite/test_reader_integrity.stitcher.yaml +15 -0
- pyquipu_engine-0.1.0/tests/unit/test_config.py +93 -0
- pyquipu_engine-0.1.0/tests/unit/test_config.stitcher.yaml +12 -0
- pyquipu_engine-0.1.0/tests/unit/test_deduplication.py +44 -0
- pyquipu_engine-0.1.0/tests/unit/test_deduplication.stitcher.yaml +3 -0
- pyquipu_engine-0.1.0/tests/unit/test_engine_memory.py +62 -0
- pyquipu_engine-0.1.0/tests/unit/test_engine_memory.stitcher.yaml +8 -0
- pyquipu_engine-0.1.0/tests/unit/test_navigation.py +95 -0
- 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,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)
|