aiocortex 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.
- aiocortex/__init__.py +48 -0
- aiocortex/_version.py +3 -0
- aiocortex/exceptions.py +25 -0
- aiocortex/files/__init__.py +6 -0
- aiocortex/files/manager.py +196 -0
- aiocortex/files/yaml_editor.py +52 -0
- aiocortex/git/__init__.py +12 -0
- aiocortex/git/cleanup.py +200 -0
- aiocortex/git/filters.py +111 -0
- aiocortex/git/manager.py +561 -0
- aiocortex/git/sync.py +184 -0
- aiocortex/models/__init__.py +19 -0
- aiocortex/models/common.py +15 -0
- aiocortex/models/config.py +45 -0
- aiocortex/models/files.py +24 -0
- aiocortex/models/git.py +36 -0
- aiocortex/py.typed +0 -0
- aiocortex-0.1.0.dist-info/METADATA +123 -0
- aiocortex-0.1.0.dist-info/RECORD +22 -0
- aiocortex-0.1.0.dist-info/WHEEL +5 -0
- aiocortex-0.1.0.dist-info/licenses/LICENSE +21 -0
- aiocortex-0.1.0.dist-info/top_level.txt +1 -0
aiocortex/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""aiocortex — Async Python library for Home Assistant configuration management."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
CortexError,
|
|
6
|
+
FileError,
|
|
7
|
+
GitError,
|
|
8
|
+
GitNotInitializedError,
|
|
9
|
+
PathSecurityError,
|
|
10
|
+
YAMLParseError,
|
|
11
|
+
)
|
|
12
|
+
from .files import AsyncFileManager, YAMLEditor
|
|
13
|
+
from .git import GitManager
|
|
14
|
+
from .models import (
|
|
15
|
+
AutomationConfig,
|
|
16
|
+
CommitInfo,
|
|
17
|
+
CortexResponse,
|
|
18
|
+
FileInfo,
|
|
19
|
+
FileWriteResult,
|
|
20
|
+
HelperSpec,
|
|
21
|
+
PendingChanges,
|
|
22
|
+
PendingChangesSummary,
|
|
23
|
+
ScriptConfig,
|
|
24
|
+
ServiceCallSpec,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"AsyncFileManager",
|
|
29
|
+
"AutomationConfig",
|
|
30
|
+
"CommitInfo",
|
|
31
|
+
"CortexError",
|
|
32
|
+
"CortexResponse",
|
|
33
|
+
"FileError",
|
|
34
|
+
"FileInfo",
|
|
35
|
+
"FileWriteResult",
|
|
36
|
+
"GitError",
|
|
37
|
+
"GitManager",
|
|
38
|
+
"GitNotInitializedError",
|
|
39
|
+
"HelperSpec",
|
|
40
|
+
"PathSecurityError",
|
|
41
|
+
"PendingChanges",
|
|
42
|
+
"PendingChangesSummary",
|
|
43
|
+
"ScriptConfig",
|
|
44
|
+
"ServiceCallSpec",
|
|
45
|
+
"YAMLEditor",
|
|
46
|
+
"YAMLParseError",
|
|
47
|
+
"__version__",
|
|
48
|
+
]
|
aiocortex/_version.py
ADDED
aiocortex/exceptions.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Exception hierarchy for aiocortex."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CortexError(Exception):
|
|
5
|
+
"""Base exception for all aiocortex errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GitError(CortexError):
|
|
9
|
+
"""Error during a git operation."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GitNotInitializedError(GitError):
|
|
13
|
+
"""The shadow git repository has not been initialized."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FileError(CortexError):
|
|
17
|
+
"""Error during a file operation."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PathSecurityError(FileError):
|
|
21
|
+
"""A requested path resolved outside the allowed config directory."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class YAMLParseError(FileError):
|
|
25
|
+
"""Failed to parse a YAML file."""
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Async file manager for Home Assistant configuration files.
|
|
2
|
+
|
|
3
|
+
Ported from ``app/services/file_manager.py`` in the HA Vibecode Agent add-on.
|
|
4
|
+
This version is *HA-independent*: it receives ``config_path`` via its
|
|
5
|
+
constructor and never imports git — the integration layer is responsible for
|
|
6
|
+
orchestrating git commits after file operations.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import aiofiles
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from ..exceptions import FileError, PathSecurityError, YAMLParseError
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AsyncFileManager:
|
|
24
|
+
"""Safe async file operations restricted to a *config_path* directory."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, config_path: Path) -> None:
|
|
27
|
+
self.config_path = config_path.resolve()
|
|
28
|
+
|
|
29
|
+
# ------------------------------------------------------------------
|
|
30
|
+
# Path helpers
|
|
31
|
+
# ------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def _get_full_path(self, relative_path: str) -> Path:
|
|
34
|
+
"""Return the absolute path, ensuring it stays within *config_path*.
|
|
35
|
+
|
|
36
|
+
Raises :class:`PathSecurityError` if the resolved path escapes the
|
|
37
|
+
allowed directory tree.
|
|
38
|
+
"""
|
|
39
|
+
if relative_path in ("", "/"):
|
|
40
|
+
return self.config_path
|
|
41
|
+
|
|
42
|
+
# Strip leading slash — treat as relative
|
|
43
|
+
if relative_path.startswith("/"):
|
|
44
|
+
relative_path = relative_path[1:]
|
|
45
|
+
|
|
46
|
+
full_path = (self.config_path / relative_path).resolve()
|
|
47
|
+
|
|
48
|
+
if not str(full_path).startswith(str(self.config_path)):
|
|
49
|
+
raise PathSecurityError(f"Path outside config directory: {relative_path}")
|
|
50
|
+
|
|
51
|
+
return full_path
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
# Public API
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
async def list_files(
|
|
58
|
+
self,
|
|
59
|
+
directory: str = "",
|
|
60
|
+
pattern: str = "*",
|
|
61
|
+
) -> list[dict[str, object]]:
|
|
62
|
+
"""List files in *directory* matching *pattern* (recursive glob)."""
|
|
63
|
+
try:
|
|
64
|
+
dir_path = self._get_full_path(directory)
|
|
65
|
+
|
|
66
|
+
if not dir_path.exists():
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
files: list[dict[str, object]] = []
|
|
70
|
+
for item in dir_path.rglob(pattern):
|
|
71
|
+
if item.is_file():
|
|
72
|
+
rel_path = item.relative_to(self.config_path)
|
|
73
|
+
stat = item.stat()
|
|
74
|
+
files.append(
|
|
75
|
+
{
|
|
76
|
+
"path": str(rel_path),
|
|
77
|
+
"name": item.name,
|
|
78
|
+
"size": stat.st_size,
|
|
79
|
+
"modified": stat.st_mtime,
|
|
80
|
+
"is_yaml": item.suffix in (".yaml", ".yml"),
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return sorted(files, key=lambda x: str(x["path"]))
|
|
85
|
+
except PathSecurityError:
|
|
86
|
+
raise
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
logger.error("Error listing files: %s", exc)
|
|
89
|
+
raise FileError(str(exc)) from exc
|
|
90
|
+
|
|
91
|
+
async def read_file(self, file_path: str) -> str:
|
|
92
|
+
"""Read and return the UTF-8 contents of *file_path*."""
|
|
93
|
+
try:
|
|
94
|
+
full_path = self._get_full_path(file_path)
|
|
95
|
+
|
|
96
|
+
if not full_path.exists():
|
|
97
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
98
|
+
|
|
99
|
+
async with aiofiles.open(full_path, encoding="utf-8") as fh:
|
|
100
|
+
content = await fh.read()
|
|
101
|
+
|
|
102
|
+
logger.info("Read file: %s (%d bytes)", file_path, len(content))
|
|
103
|
+
return content
|
|
104
|
+
except (FileNotFoundError, PathSecurityError):
|
|
105
|
+
raise
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
logger.error("Error reading file %s: %s", file_path, exc)
|
|
108
|
+
raise FileError(str(exc)) from exc
|
|
109
|
+
|
|
110
|
+
async def write_file(self, file_path: str, content: str) -> dict[str, object]:
|
|
111
|
+
"""Write *content* to *file_path*, creating parent directories as needed.
|
|
112
|
+
|
|
113
|
+
Returns a result dict with *success*, *path*, and *size*.
|
|
114
|
+
|
|
115
|
+
.. note::
|
|
116
|
+
|
|
117
|
+
This method does **not** interact with git. The integration layer
|
|
118
|
+
should call ``GitManager.commit_changes()`` afterwards if desired.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
full_path = self._get_full_path(file_path)
|
|
122
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
|
|
124
|
+
async with aiofiles.open(full_path, "w", encoding="utf-8") as fh:
|
|
125
|
+
await fh.write(content)
|
|
126
|
+
|
|
127
|
+
logger.info("Wrote file: %s (%d bytes)", file_path, len(content))
|
|
128
|
+
|
|
129
|
+
return {"success": True, "path": file_path, "size": len(content)}
|
|
130
|
+
except PathSecurityError:
|
|
131
|
+
raise
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
logger.error("Error writing file %s: %s", file_path, exc)
|
|
134
|
+
raise FileError(str(exc)) from exc
|
|
135
|
+
|
|
136
|
+
async def append_file(self, file_path: str, content: str) -> dict[str, object]:
|
|
137
|
+
"""Append *content* to *file_path*, creating it if it doesn't exist."""
|
|
138
|
+
try:
|
|
139
|
+
full_path = self._get_full_path(file_path)
|
|
140
|
+
|
|
141
|
+
if not full_path.exists():
|
|
142
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
full_path.touch()
|
|
144
|
+
|
|
145
|
+
async with aiofiles.open(full_path, encoding="utf-8") as fh:
|
|
146
|
+
existing = await fh.read()
|
|
147
|
+
|
|
148
|
+
new_content = (existing + "\n" + content) if existing else content
|
|
149
|
+
|
|
150
|
+
async with aiofiles.open(full_path, "w", encoding="utf-8") as fh:
|
|
151
|
+
await fh.write(new_content)
|
|
152
|
+
|
|
153
|
+
logger.info("Appended to file: %s (%d bytes)", file_path, len(content))
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
"success": True,
|
|
157
|
+
"path": file_path,
|
|
158
|
+
"added_bytes": len(content),
|
|
159
|
+
"total_size": len(new_content),
|
|
160
|
+
}
|
|
161
|
+
except PathSecurityError:
|
|
162
|
+
raise
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
logger.error("Error appending to file %s: %s", file_path, exc)
|
|
165
|
+
raise FileError(str(exc)) from exc
|
|
166
|
+
|
|
167
|
+
async def delete_file(self, file_path: str) -> dict[str, object]:
|
|
168
|
+
"""Delete *file_path*.
|
|
169
|
+
|
|
170
|
+
Returns a result dict with *success* and *path*.
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
full_path = self._get_full_path(file_path)
|
|
174
|
+
|
|
175
|
+
if not full_path.exists():
|
|
176
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
177
|
+
|
|
178
|
+
full_path.unlink()
|
|
179
|
+
logger.info("Deleted file: %s", file_path)
|
|
180
|
+
|
|
181
|
+
return {"success": True, "path": file_path}
|
|
182
|
+
except (FileNotFoundError, PathSecurityError):
|
|
183
|
+
raise
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
logger.error("Error deleting file %s: %s", file_path, exc)
|
|
186
|
+
raise FileError(str(exc)) from exc
|
|
187
|
+
|
|
188
|
+
async def parse_yaml(self, file_path: str) -> dict[str, Any]:
|
|
189
|
+
"""Parse a YAML file and return its contents as a dict."""
|
|
190
|
+
try:
|
|
191
|
+
content = await self.read_file(file_path)
|
|
192
|
+
data = yaml.safe_load(content)
|
|
193
|
+
return data or {}
|
|
194
|
+
except yaml.YAMLError as exc:
|
|
195
|
+
logger.error("YAML parse error in %s: %s", file_path, exc)
|
|
196
|
+
raise YAMLParseError(f"Invalid YAML: {exc}") from exc
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""YAML editor utility for safe YAML file modifications.
|
|
2
|
+
|
|
3
|
+
Ported from ``app/utils/yaml_editor.py`` in the HA Vibecode Agent add-on.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class YAMLEditor:
|
|
12
|
+
"""Utility for editing YAML files while preserving structure."""
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def remove_lines_from_end(content: str, num_lines: int) -> str:
|
|
16
|
+
"""Remove *num_lines* from the end of *content*."""
|
|
17
|
+
lines = content.rstrip().split("\n")
|
|
18
|
+
if num_lines >= len(lines):
|
|
19
|
+
return ""
|
|
20
|
+
return "\n".join(lines[:-num_lines]) + "\n"
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def remove_empty_yaml_section(content: str, section_name: str) -> str:
|
|
24
|
+
"""Remove an empty YAML section (e.g. ``lovelace:`` with only empty sub-keys)."""
|
|
25
|
+
# Pattern: comment + section with only empty subsections
|
|
26
|
+
pattern = rf"\n# .*{section_name.title()}.*\n{section_name}:\s*\n\s+\w+:\s*\n(?=\S|\Z)"
|
|
27
|
+
content = re.sub(pattern, "\n", content, flags=re.IGNORECASE)
|
|
28
|
+
|
|
29
|
+
# Also try without a preceding comment
|
|
30
|
+
pattern = rf"\n{section_name}:\s*\n\s+\w+:\s*\n(?=\S|\Z)"
|
|
31
|
+
content = re.sub(pattern, "\n", content, flags=re.IGNORECASE)
|
|
32
|
+
|
|
33
|
+
return content
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def remove_yaml_entry(
|
|
37
|
+
content: str,
|
|
38
|
+
section: str,
|
|
39
|
+
key: str,
|
|
40
|
+
) -> tuple[str, bool]:
|
|
41
|
+
"""Remove a specific entry from a YAML section.
|
|
42
|
+
|
|
43
|
+
Returns ``(modified_content, was_found)``.
|
|
44
|
+
"""
|
|
45
|
+
pattern = rf" {re.escape(key)}:\s*\n(?: .*\n)*"
|
|
46
|
+
|
|
47
|
+
if re.search(pattern, content):
|
|
48
|
+
modified = re.sub(pattern, "", content)
|
|
49
|
+
modified = YAMLEditor.remove_empty_yaml_section(modified, section)
|
|
50
|
+
return modified, True
|
|
51
|
+
|
|
52
|
+
return content, False
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Git versioning with dulwich (no git binary required)."""
|
|
2
|
+
|
|
3
|
+
from .filters import should_include_path
|
|
4
|
+
from .manager import GitManager
|
|
5
|
+
from .sync import sync_config_to_shadow, sync_shadow_to_config
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"GitManager",
|
|
9
|
+
"should_include_path",
|
|
10
|
+
"sync_config_to_shadow",
|
|
11
|
+
"sync_shadow_to_config",
|
|
12
|
+
]
|
aiocortex/git/cleanup.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""History truncation for the shadow git repository.
|
|
2
|
+
|
|
3
|
+
Uses dulwich for commit-chain rewriting where possible. Falls back to
|
|
4
|
+
``git clone --depth`` via subprocess if the ``git`` binary is available.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from dulwich.repo import Repo
|
|
17
|
+
|
|
18
|
+
from ..exceptions import GitError
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _git_binary_available() -> bool:
|
|
24
|
+
"""Return ``True`` if the ``git`` CLI is on ``$PATH``."""
|
|
25
|
+
try:
|
|
26
|
+
result = subprocess.run(
|
|
27
|
+
["git", "--version"],
|
|
28
|
+
capture_output=True,
|
|
29
|
+
timeout=5,
|
|
30
|
+
)
|
|
31
|
+
return result.returncode == 0
|
|
32
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def truncate_history(
|
|
37
|
+
repo_path: Path,
|
|
38
|
+
commits_to_keep: int,
|
|
39
|
+
) -> int:
|
|
40
|
+
"""Truncate the repository at *repo_path* to at most *commits_to_keep* commits.
|
|
41
|
+
|
|
42
|
+
Returns the number of commits after truncation.
|
|
43
|
+
|
|
44
|
+
The strategy is:
|
|
45
|
+
|
|
46
|
+
1. If the ``git`` binary is available, use ``git clone --depth`` into a
|
|
47
|
+
temp dir, then swap ``.git`` directories. This is the most reliable
|
|
48
|
+
approach on HA OS (where git is available in the container).
|
|
49
|
+
2. Otherwise raise :class:`GitError` — pure-dulwich history rewriting is
|
|
50
|
+
a future enhancement.
|
|
51
|
+
"""
|
|
52
|
+
repo_path = repo_path.resolve()
|
|
53
|
+
git_dir = repo_path / ".git"
|
|
54
|
+
|
|
55
|
+
if not git_dir.is_dir():
|
|
56
|
+
raise GitError(f"No .git directory at {repo_path}")
|
|
57
|
+
|
|
58
|
+
# --- Strategy 1: git clone --depth ---
|
|
59
|
+
if _git_binary_available():
|
|
60
|
+
return _truncate_via_clone(repo_path, commits_to_keep)
|
|
61
|
+
|
|
62
|
+
# --- Strategy 2: dulwich-native (basic orphan graft) ---
|
|
63
|
+
return _truncate_via_dulwich(repo_path, commits_to_keep)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _truncate_via_clone(repo_path: Path, commits_to_keep: int) -> int:
|
|
67
|
+
"""Clone with ``--depth`` and swap ``.git`` directories."""
|
|
68
|
+
git_dir = repo_path / ".git"
|
|
69
|
+
|
|
70
|
+
# Detect current branch
|
|
71
|
+
try:
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
["git", "branch", "--show-current"],
|
|
74
|
+
cwd=str(repo_path),
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
timeout=10,
|
|
78
|
+
)
|
|
79
|
+
branch = result.stdout.strip() or "master"
|
|
80
|
+
except Exception:
|
|
81
|
+
branch = "master"
|
|
82
|
+
|
|
83
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
84
|
+
clone_path = os.path.join(tmpdir, "cloned_repo")
|
|
85
|
+
repo_url = f"file://{repo_path}"
|
|
86
|
+
|
|
87
|
+
logger.info("Cloning repository with depth=%d from %s ...", commits_to_keep, repo_url)
|
|
88
|
+
result = subprocess.run(
|
|
89
|
+
[
|
|
90
|
+
"git",
|
|
91
|
+
"clone",
|
|
92
|
+
"--depth",
|
|
93
|
+
str(commits_to_keep),
|
|
94
|
+
"--branch",
|
|
95
|
+
branch,
|
|
96
|
+
"--single-branch",
|
|
97
|
+
repo_url,
|
|
98
|
+
clone_path,
|
|
99
|
+
],
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True,
|
|
102
|
+
timeout=600,
|
|
103
|
+
)
|
|
104
|
+
if result.returncode != 0:
|
|
105
|
+
raise GitError(f"git clone failed: {result.stderr}")
|
|
106
|
+
|
|
107
|
+
cloned_git_dir = os.path.join(clone_path, ".git")
|
|
108
|
+
if not os.path.isdir(cloned_git_dir):
|
|
109
|
+
raise GitError("Cloned .git directory does not exist")
|
|
110
|
+
|
|
111
|
+
# Swap .git
|
|
112
|
+
backup_git = os.path.join(tmpdir, "git_backup")
|
|
113
|
+
shutil.copytree(str(git_dir), backup_git)
|
|
114
|
+
shutil.rmtree(str(git_dir))
|
|
115
|
+
shutil.copytree(cloned_git_dir, str(git_dir))
|
|
116
|
+
|
|
117
|
+
logger.info("Replaced .git directory with shallow clone")
|
|
118
|
+
|
|
119
|
+
# Optional gc
|
|
120
|
+
try:
|
|
121
|
+
subprocess.run(
|
|
122
|
+
["git", "gc", "--prune=now", "--quiet"],
|
|
123
|
+
cwd=str(repo_path),
|
|
124
|
+
capture_output=True,
|
|
125
|
+
timeout=600,
|
|
126
|
+
)
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
logger.warning("git gc after truncation failed: %s", exc)
|
|
129
|
+
|
|
130
|
+
# Return final count
|
|
131
|
+
try:
|
|
132
|
+
result = subprocess.run(
|
|
133
|
+
["git", "rev-list", "--count", "--first-parent", "HEAD"],
|
|
134
|
+
cwd=str(repo_path),
|
|
135
|
+
capture_output=True,
|
|
136
|
+
text=True,
|
|
137
|
+
timeout=10,
|
|
138
|
+
)
|
|
139
|
+
return int(result.stdout.strip())
|
|
140
|
+
except Exception:
|
|
141
|
+
return commits_to_keep
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _truncate_via_dulwich(repo_path: Path, commits_to_keep: int) -> int:
|
|
145
|
+
"""Basic dulwich-native truncation using orphan commit grafting.
|
|
146
|
+
|
|
147
|
+
Walks the commit chain, keeps *commits_to_keep* most recent commits,
|
|
148
|
+
and rewrites the oldest-kept commit to have no parents (orphan root).
|
|
149
|
+
Then packs and prunes unreachable objects.
|
|
150
|
+
"""
|
|
151
|
+
repo = Repo(str(repo_path))
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
head_sha = repo.head()
|
|
155
|
+
except KeyError:
|
|
156
|
+
raise GitError("Repository has no HEAD") from None
|
|
157
|
+
|
|
158
|
+
# Walk the first-parent chain
|
|
159
|
+
chain: list[bytes] = []
|
|
160
|
+
current = head_sha
|
|
161
|
+
while current and len(chain) < commits_to_keep + 1:
|
|
162
|
+
chain.append(current)
|
|
163
|
+
commit = repo.get_object(current)
|
|
164
|
+
current = commit.parents[0] if commit.parents else None
|
|
165
|
+
|
|
166
|
+
if len(chain) <= commits_to_keep:
|
|
167
|
+
# Nothing to truncate
|
|
168
|
+
return len(chain)
|
|
169
|
+
|
|
170
|
+
# Rewrite the oldest kept commit to have no parents
|
|
171
|
+
oldest_kept_sha = chain[commits_to_keep - 1]
|
|
172
|
+
oldest_kept = repo.get_object(oldest_kept_sha).copy()
|
|
173
|
+
oldest_kept.parents = []
|
|
174
|
+
|
|
175
|
+
# Store the rewritten commit
|
|
176
|
+
repo.object_store.add_object(oldest_kept)
|
|
177
|
+
|
|
178
|
+
# Rewrite the chain from oldest-kept to HEAD
|
|
179
|
+
sha_map: dict[bytes, bytes] = {oldest_kept_sha: oldest_kept.id}
|
|
180
|
+
|
|
181
|
+
for i in range(commits_to_keep - 2, -1, -1):
|
|
182
|
+
old_sha = chain[i]
|
|
183
|
+
commit = repo.get_object(old_sha).copy()
|
|
184
|
+
# Replace parent references
|
|
185
|
+
new_parents = []
|
|
186
|
+
for p in commit.parents:
|
|
187
|
+
new_parents.append(sha_map.get(p, p))
|
|
188
|
+
commit.parents = new_parents
|
|
189
|
+
repo.object_store.add_object(commit)
|
|
190
|
+
sha_map[old_sha] = commit.id
|
|
191
|
+
|
|
192
|
+
# Update HEAD/refs to point to new chain
|
|
193
|
+
new_head = sha_map.get(head_sha, head_sha)
|
|
194
|
+
for ref in repo.get_refs():
|
|
195
|
+
if repo.get_refs()[ref] == head_sha:
|
|
196
|
+
repo.refs[ref] = new_head
|
|
197
|
+
|
|
198
|
+
repo.close()
|
|
199
|
+
|
|
200
|
+
return commits_to_keep
|
aiocortex/git/filters.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Path filtering — decide which files should be tracked in the shadow repo.
|
|
2
|
+
|
|
3
|
+
Ported from ``GitManager._should_include_path()`` in the HA Vibecode Agent
|
|
4
|
+
add-on. This module is pure logic with no git or filesystem side-effects.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
# Directories excluded at the top level of /config
|
|
13
|
+
_EXCLUDED_DIRS: frozenset[str] = frozenset(
|
|
14
|
+
{
|
|
15
|
+
".storage",
|
|
16
|
+
".cloud",
|
|
17
|
+
".homeassistant",
|
|
18
|
+
"www",
|
|
19
|
+
"media",
|
|
20
|
+
"storage",
|
|
21
|
+
"tmp",
|
|
22
|
+
"node_modules",
|
|
23
|
+
"__pycache__",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Filename-level exclusion patterns
|
|
28
|
+
_SECRET_FILES: frozenset[str] = frozenset({"secrets.yaml", ".secrets.yaml"})
|
|
29
|
+
_SECRET_EXTS: tuple[str, ...] = ("*.pem", "*.key", "*.crt")
|
|
30
|
+
_DB_PATTERNS: tuple[str, ...] = (
|
|
31
|
+
"*.db",
|
|
32
|
+
"*.db-shm",
|
|
33
|
+
"*.db-wal",
|
|
34
|
+
"*.db-journal",
|
|
35
|
+
"*.sqlite",
|
|
36
|
+
"*.sqlite3",
|
|
37
|
+
"home-assistant_v2.db*",
|
|
38
|
+
)
|
|
39
|
+
_LOG_PATTERNS: tuple[str, ...] = ("*.log", "*.log.*", "home-assistant.log")
|
|
40
|
+
_BACKUP_PATTERNS: tuple[str, ...] = ("*.bak", "*.backup", "*.old", "*.tmp", "*.temp", "*~")
|
|
41
|
+
_DIR_PREFIXES: tuple[str, ...] = (
|
|
42
|
+
".storage/",
|
|
43
|
+
".cloud/",
|
|
44
|
+
".homeassistant/",
|
|
45
|
+
"www/",
|
|
46
|
+
"media/",
|
|
47
|
+
"storage/",
|
|
48
|
+
"tmp/",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def should_include_path(
|
|
53
|
+
rel_path: str,
|
|
54
|
+
is_dir: bool,
|
|
55
|
+
*,
|
|
56
|
+
shadow_dir_name: str = "cortex_git",
|
|
57
|
+
) -> bool:
|
|
58
|
+
"""Return ``True`` if *rel_path* (relative to ``/config``) should be tracked.
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
rel_path:
|
|
63
|
+
Forward-slash normalised path relative to the HA config directory.
|
|
64
|
+
is_dir:
|
|
65
|
+
Whether the path represents a directory.
|
|
66
|
+
shadow_dir_name:
|
|
67
|
+
Name of the shadow-repo directory to exclude (default ``cortex_git``).
|
|
68
|
+
"""
|
|
69
|
+
rel_path = rel_path.replace(os.sep, "/")
|
|
70
|
+
parts = rel_path.split("/")
|
|
71
|
+
|
|
72
|
+
# Skip shadow repo and any .git directories
|
|
73
|
+
if parts[0] in (".git", shadow_dir_name):
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
# Directory-level filtering
|
|
77
|
+
if is_dir:
|
|
78
|
+
return parts[0] not in _EXCLUDED_DIRS
|
|
79
|
+
|
|
80
|
+
filename = parts[-1]
|
|
81
|
+
|
|
82
|
+
# Secrets / keys
|
|
83
|
+
if filename in _SECRET_FILES:
|
|
84
|
+
return False
|
|
85
|
+
if any(fnmatch.fnmatch(filename, pat) for pat in _SECRET_EXTS):
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
# DB files
|
|
89
|
+
if any(
|
|
90
|
+
fnmatch.fnmatch(filename, pat) or fnmatch.fnmatch(rel_path, pat) for pat in _DB_PATTERNS
|
|
91
|
+
):
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
# Logs
|
|
95
|
+
if any(
|
|
96
|
+
fnmatch.fnmatch(filename, pat) or fnmatch.fnmatch(rel_path, pat) for pat in _LOG_PATTERNS
|
|
97
|
+
):
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
# Backup-like files
|
|
101
|
+
if any(
|
|
102
|
+
fnmatch.fnmatch(filename, pat) or fnmatch.fnmatch(rel_path, pat)
|
|
103
|
+
for pat in _BACKUP_PATTERNS
|
|
104
|
+
):
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
# Files inside heavy/internal dirs (if they weren't pruned at dir level)
|
|
108
|
+
if any(rel_path.startswith(prefix) for prefix in _DIR_PREFIXES):
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
return True
|