differix 0.1.0a1__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.
- agents/editor.py +455 -0
- agents/researcher.py +405 -0
- agents/reviewer.py +374 -0
- cli/model_cli.py +430 -0
- config.py +557 -0
- differix-0.1.0a1.dist-info/METADATA +123 -0
- differix-0.1.0a1.dist-info/RECORD +62 -0
- differix-0.1.0a1.dist-info/WHEEL +5 -0
- differix-0.1.0a1.dist-info/entry_points.txt +2 -0
- differix-0.1.0a1.dist-info/top_level.txt +6 -0
- main.py +2796 -0
- memory/__init__.py +59 -0
- memory/embeddings/__init__.py +29 -0
- memory/embeddings/factory.py +178 -0
- memory/embeddings/providers.py +259 -0
- memory/manager.py +186 -0
- memory/migrator.py +253 -0
- memory/ranker.py +203 -0
- memory/schemas.py +127 -0
- memory/storage.py +552 -0
- tools/agents_chat.py +152 -0
- tools/asset_extractor.py +545 -0
- tools/asset_manager.py +115 -0
- tools/atomic_changeset.py +264 -0
- tools/backup_manager.py +122 -0
- tools/changelog_engine.py +261 -0
- tools/coherence_checker.py +771 -0
- tools/compiler.py +531 -0
- tools/component_normalizer.py +130 -0
- tools/context_assembler.py +631 -0
- tools/contract_validator.py +478 -0
- tools/cost_predictor.py +141 -0
- tools/drift_guard.py +196 -0
- tools/file_manager.py +119 -0
- tools/handshake.py +351 -0
- tools/lesson_manager.py +570 -0
- tools/lesson_memory_bridge.py +398 -0
- tools/llm_client.py +103 -0
- tools/llm_selector.py +362 -0
- tools/model_ranker.py +535 -0
- tools/palette_extractor.py +1043 -0
- tools/plugin_manager.py +204 -0
- tools/project_context_gatherer.py +404 -0
- tools/project_scanner.py +41 -0
- tools/project_structure.py +244 -0
- tools/reasoning_logger.py +78 -0
- tools/reflection.py +452 -0
- tools/sandbox_utils.py +56 -0
- tools/security/__init__.py +0 -0
- tools/security/behavior_guard.py +83 -0
- tools/security/verification_firewall.py +113 -0
- tools/security_filter.py +213 -0
- tools/session_adr.py +311 -0
- tools/skill_manager.py +129 -0
- tools/state_manager.py +383 -0
- tools/structure_index.py +570 -0
- tools/test_schema_consistency.py +19 -0
- tools/verification_firewall.py +113 -0
- tools/visual_capture.py +65 -0
- tools/visual_diff.py +220 -0
- tools/visual_reviewer.py +79 -0
- tools/web_search.py +371 -0
tools/asset_manager.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import httpx
|
|
4
|
+
from ipaddress import ip_address
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
class AssetManager:
|
|
9
|
+
"""Manages downloading and indexing web assets with security checks."""
|
|
10
|
+
|
|
11
|
+
EXTENSION_MAP = {
|
|
12
|
+
"image": [".png", ".jpg", ".jpeg", ".webp", ".svg"],
|
|
13
|
+
"shader": [".glsl", ".frag", ".vert"],
|
|
14
|
+
"html": [".html"],
|
|
15
|
+
"3d": [".glb", ".gltf"]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
|
19
|
+
|
|
20
|
+
def __init__(self, project_dir: str):
|
|
21
|
+
self.project_dir = os.path.abspath(project_dir)
|
|
22
|
+
self.assets_dir = os.path.join(self.project_dir, ".differix", "assets")
|
|
23
|
+
os.makedirs(self.assets_dir, exist_ok=True)
|
|
24
|
+
|
|
25
|
+
def _validate_extension(self, filename: str, asset_type: str) -> bool:
|
|
26
|
+
"""Check if file extension matches allowed types for the asset type."""
|
|
27
|
+
ext = os.path.splitext(filename)[1].lower()
|
|
28
|
+
return ext in self.EXTENSION_MAP.get(asset_type, [])
|
|
29
|
+
|
|
30
|
+
def _is_private_ip(self, host: str) -> bool:
|
|
31
|
+
"""Check if host resolves to a private IP address."""
|
|
32
|
+
try:
|
|
33
|
+
ip = ip_address(host)
|
|
34
|
+
return ip.is_private or ip.is_loopback
|
|
35
|
+
except ValueError:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
def _validate_url(self, url: str) -> bool:
|
|
39
|
+
"""Validate URL security - reject private IPs and invalid schemes."""
|
|
40
|
+
parsed = urlparse(url)
|
|
41
|
+
if parsed.scheme not in ["http", "https"]:
|
|
42
|
+
return False
|
|
43
|
+
if self._is_private_ip(parsed.hostname):
|
|
44
|
+
return False
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
def download_asset(self, url: str, asset_type: str, filename: str) -> str:
|
|
48
|
+
"""Download an asset with security checks and save to assets directory.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
url: Source URL to download from (must pass SSRF checks)
|
|
52
|
+
asset_type: Type of asset (image/shader/html/3d)
|
|
53
|
+
filename: Target filename (must have correct extension for type)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Absolute path to saved asset
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
ValueError: For security violations, invalid types, or download failures
|
|
60
|
+
"""
|
|
61
|
+
if not self._validate_url(url):
|
|
62
|
+
raise ValueError("Invalid or insecure URL")
|
|
63
|
+
|
|
64
|
+
if not self._validate_extension(filename, asset_type):
|
|
65
|
+
raise ValueError(f"Invalid extension for asset type '{asset_type}'")
|
|
66
|
+
|
|
67
|
+
# Sanitize filename to prevent path traversal
|
|
68
|
+
filename = os.path.basename(filename)
|
|
69
|
+
if not filename or filename.startswith('.'):
|
|
70
|
+
raise ValueError("Invalid filename")
|
|
71
|
+
|
|
72
|
+
target_dir = os.path.join(self.assets_dir, asset_type)
|
|
73
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
74
|
+
target_path = os.path.join(target_dir, filename)
|
|
75
|
+
|
|
76
|
+
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
|
|
77
|
+
try:
|
|
78
|
+
with client.stream("GET", url) as response:
|
|
79
|
+
# Recheck SSRF after following redirects
|
|
80
|
+
final_url = str(response.url)
|
|
81
|
+
parsed = urlparse(final_url)
|
|
82
|
+
if self._is_private_ip(parsed.hostname):
|
|
83
|
+
raise ValueError("Redirect leads to private IP - SSRF blocked")
|
|
84
|
+
|
|
85
|
+
if response.status_code != 200:
|
|
86
|
+
raise ValueError(f"Failed to download asset: HTTP {response.status_code}")
|
|
87
|
+
|
|
88
|
+
# Stream download with size cap
|
|
89
|
+
total_bytes = 0
|
|
90
|
+
chunks = []
|
|
91
|
+
for chunk in response.iter_bytes():
|
|
92
|
+
total_bytes += len(chunk)
|
|
93
|
+
if total_bytes > self.MAX_FILE_SIZE:
|
|
94
|
+
raise ValueError(f"File exceeds {self.MAX_FILE_SIZE//(1024*1024)}MB limit")
|
|
95
|
+
chunks.append(chunk)
|
|
96
|
+
|
|
97
|
+
content = b''.join(chunks)
|
|
98
|
+
|
|
99
|
+
# Verify content type matches asset type
|
|
100
|
+
content_type = response.headers.get('content-type', '')
|
|
101
|
+
if asset_type == 'image' and not content_type.startswith('image/'):
|
|
102
|
+
raise ValueError(f"Expected image content type, got {content_type}")
|
|
103
|
+
elif asset_type == '3d' and not any(x in content_type for x in ['model/gltf', 'model/gltf-binary']):
|
|
104
|
+
raise ValueError(f"Expected 3D model content type, got {content_type}")
|
|
105
|
+
|
|
106
|
+
# Write file atomically
|
|
107
|
+
temp_path = target_path + '.tmp'
|
|
108
|
+
with open(temp_path, 'wb') as f:
|
|
109
|
+
f.write(content)
|
|
110
|
+
os.replace(temp_path, target_path)
|
|
111
|
+
|
|
112
|
+
except httpx.RequestError as e:
|
|
113
|
+
raise ValueError(f"Download failed: {str(e)}")
|
|
114
|
+
|
|
115
|
+
return os.path.abspath(target_path)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tools/atomic_changeset.py
|
|
3
|
+
--------------------------
|
|
4
|
+
Atomic multi-file change sets for the VegeX editor pipeline.
|
|
5
|
+
|
|
6
|
+
Root problem this solves: the RSI crash you hit was caused by multi-file
|
|
7
|
+
edits applied non-atomically — file #1 written, file #3 fails sandbox
|
|
8
|
+
validation, repo left in a half-edited state. The editor then re-reads the
|
|
9
|
+
half-applied tree and hallucinates a fix.
|
|
10
|
+
|
|
11
|
+
This module makes N-file edits behave as either fully-applied or
|
|
12
|
+
fully-rolled-back. On POSIX we can't rename N files in a single syscall,
|
|
13
|
+
but we can:
|
|
14
|
+
1. Stage all content to .vegex/staging/<uuid>/
|
|
15
|
+
2. Snapshot backups of every file to be touched
|
|
16
|
+
3. Apply replacements one by one
|
|
17
|
+
4. On any failure, restore from the backups
|
|
18
|
+
|
|
19
|
+
That gives "best-effort atomicity" — the window between rename #1 and
|
|
20
|
+
rename #N is microseconds, no I/O, no LLM calls. Not bulletproof against
|
|
21
|
+
SIGKILL at the exact wrong moment, but eliminates the failure mode that
|
|
22
|
+
actually hurt.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import errno
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import shutil
|
|
31
|
+
import tempfile
|
|
32
|
+
import time
|
|
33
|
+
import uuid
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Dict, Iterable, List, Optional
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ChangeSetError(Exception):
|
|
42
|
+
"""Raised when a change set cannot be applied."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ChangeOp:
|
|
47
|
+
"""A single file operation inside a ChangeSet."""
|
|
48
|
+
|
|
49
|
+
kind: str # "write" | "delete"
|
|
50
|
+
path: str # relative to project_root, forward slashes
|
|
51
|
+
content: Optional[str] = None # required for kind="write"
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
if self.kind not in ("write", "delete"):
|
|
55
|
+
raise ValueError(f"Invalid kind: {self.kind!r}")
|
|
56
|
+
if self.kind == "write" and self.content is None:
|
|
57
|
+
raise ValueError(f"ChangeOp write requires content (path={self.path!r})")
|
|
58
|
+
if not self.path or self.path.startswith("/") or ".." in Path(self.path).parts:
|
|
59
|
+
raise ValueError(f"Path must be project-relative, no traversal: {self.path!r}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ApplyReport:
|
|
64
|
+
ok: bool = False
|
|
65
|
+
applied: List[str] = field(default_factory=list)
|
|
66
|
+
rolled_back: List[str] = field(default_factory=list)
|
|
67
|
+
error: Optional[str] = None
|
|
68
|
+
elapsed_ms: float = 0.0
|
|
69
|
+
|
|
70
|
+
def as_dict(self) -> Dict:
|
|
71
|
+
return {
|
|
72
|
+
"ok": self.ok,
|
|
73
|
+
"applied": list(self.applied),
|
|
74
|
+
"rolled_back": list(self.rolled_back),
|
|
75
|
+
"error": self.error,
|
|
76
|
+
"elapsed_ms": round(self.elapsed_ms, 3),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ChangeSet:
|
|
81
|
+
"""A list of file operations applied atomically (best-effort)."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, ops: Optional[Iterable[ChangeOp]] = None) -> None:
|
|
84
|
+
self.ops: List[ChangeOp] = list(ops or [])
|
|
85
|
+
|
|
86
|
+
def add_write(self, path: str, content: str) -> "ChangeSet":
|
|
87
|
+
self.ops.append(ChangeOp(kind="write", path=path, content=content))
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def add_delete(self, path: str) -> "ChangeSet":
|
|
91
|
+
self.ops.append(ChangeOp(kind="delete", path=path))
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def __bool__(self) -> bool:
|
|
95
|
+
return bool(self.ops)
|
|
96
|
+
|
|
97
|
+
def __len__(self) -> int:
|
|
98
|
+
return len(self.ops)
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------ #
|
|
101
|
+
# Pre-flight
|
|
102
|
+
# ------------------------------------------------------------------ #
|
|
103
|
+
def validate(self, project_root: str) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Raise ChangeSetError if any op violates invariants:
|
|
106
|
+
- path escapes project_root
|
|
107
|
+
- duplicate path within the set
|
|
108
|
+
- write content exceeds size cap (2 MB default)
|
|
109
|
+
"""
|
|
110
|
+
root = Path(project_root).resolve()
|
|
111
|
+
seen: set = set()
|
|
112
|
+
for op in self.ops:
|
|
113
|
+
target = (root / op.path).resolve()
|
|
114
|
+
try:
|
|
115
|
+
target.relative_to(root)
|
|
116
|
+
except ValueError:
|
|
117
|
+
raise ChangeSetError(
|
|
118
|
+
f"Path {op.path!r} escapes project root {root}"
|
|
119
|
+
)
|
|
120
|
+
if op.path in seen:
|
|
121
|
+
raise ChangeSetError(f"Duplicate path in change set: {op.path!r}")
|
|
122
|
+
seen.add(op.path)
|
|
123
|
+
if op.kind == "write" and op.content is not None and len(op.content) > 2_000_000:
|
|
124
|
+
raise ChangeSetError(
|
|
125
|
+
f"Write content for {op.path!r} exceeds 2MB cap"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------ #
|
|
129
|
+
# Dry run — returns what the tree would look like post-apply
|
|
130
|
+
# ------------------------------------------------------------------ #
|
|
131
|
+
def dry_apply(self, project_root: str) -> Dict[str, str]:
|
|
132
|
+
"""
|
|
133
|
+
Return {path: hypothetical_content} for every file the set touches.
|
|
134
|
+
Used by the contract validator to check the *intended* post-apply
|
|
135
|
+
state without writing anything to disk.
|
|
136
|
+
"""
|
|
137
|
+
root = Path(project_root).resolve()
|
|
138
|
+
result: Dict[str, str] = {}
|
|
139
|
+
for op in self.ops:
|
|
140
|
+
if op.kind == "write":
|
|
141
|
+
result[op.path] = op.content or ""
|
|
142
|
+
else: # delete
|
|
143
|
+
result[op.path] = "" # validators treat empty file as no-op
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------ #
|
|
147
|
+
# Apply
|
|
148
|
+
# ------------------------------------------------------------------ #
|
|
149
|
+
def apply(self, project_root: str) -> ApplyReport:
|
|
150
|
+
"""Apply all ops. On any failure, rollback previously applied ops."""
|
|
151
|
+
t0 = time.perf_counter()
|
|
152
|
+
report = ApplyReport()
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
self.validate(project_root)
|
|
156
|
+
except ChangeSetError as exc:
|
|
157
|
+
report.error = f"Pre-flight failed: {exc}"
|
|
158
|
+
report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
|
|
159
|
+
return report
|
|
160
|
+
|
|
161
|
+
if not self.ops:
|
|
162
|
+
report.ok = True
|
|
163
|
+
report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
|
|
164
|
+
return report
|
|
165
|
+
|
|
166
|
+
root = Path(project_root).resolve()
|
|
167
|
+
staging_dir = root / ".vegex" / "staging" / uuid.uuid4().hex
|
|
168
|
+
staging_dir.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
|
|
170
|
+
# back up existing content for every target path
|
|
171
|
+
backups: Dict[str, Optional[str]] = {}
|
|
172
|
+
try:
|
|
173
|
+
for op in self.ops:
|
|
174
|
+
target = root / op.path
|
|
175
|
+
if target.exists() and target.is_file():
|
|
176
|
+
try:
|
|
177
|
+
backups[op.path] = target.read_text(encoding="utf-8")
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
raise ChangeSetError(
|
|
180
|
+
f"Cannot read existing {op.path!r} for backup: {exc}"
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
backups[op.path] = None
|
|
184
|
+
except ChangeSetError as exc:
|
|
185
|
+
report.error = str(exc)
|
|
186
|
+
_safe_rmtree(staging_dir)
|
|
187
|
+
report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
|
|
188
|
+
return report
|
|
189
|
+
|
|
190
|
+
# stage all new content to disk first
|
|
191
|
+
staged_paths: Dict[str, Path] = {}
|
|
192
|
+
try:
|
|
193
|
+
for op in self.ops:
|
|
194
|
+
if op.kind != "write":
|
|
195
|
+
continue
|
|
196
|
+
stage_target = staging_dir / op.path
|
|
197
|
+
stage_target.parent.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
stage_target.write_text(op.content or "", encoding="utf-8")
|
|
199
|
+
staged_paths[op.path] = stage_target
|
|
200
|
+
except Exception as exc:
|
|
201
|
+
report.error = f"Staging failed: {exc}"
|
|
202
|
+
_safe_rmtree(staging_dir)
|
|
203
|
+
report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
|
|
204
|
+
return report
|
|
205
|
+
|
|
206
|
+
# now do the renames in rapid succession
|
|
207
|
+
applied: List[str] = []
|
|
208
|
+
try:
|
|
209
|
+
for op in self.ops:
|
|
210
|
+
target = root / op.path
|
|
211
|
+
if op.kind == "write":
|
|
212
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
os.replace(staged_paths[op.path], target)
|
|
214
|
+
applied.append(op.path)
|
|
215
|
+
else: # delete
|
|
216
|
+
try:
|
|
217
|
+
target.unlink()
|
|
218
|
+
except FileNotFoundError:
|
|
219
|
+
pass
|
|
220
|
+
applied.append(op.path)
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
# Roll back every file we've already touched
|
|
223
|
+
rolled_back: List[str] = []
|
|
224
|
+
for path in applied:
|
|
225
|
+
backup = backups.get(path)
|
|
226
|
+
try:
|
|
227
|
+
target = root / path
|
|
228
|
+
if backup is None:
|
|
229
|
+
# didn't exist before → delete what we wrote
|
|
230
|
+
try:
|
|
231
|
+
target.unlink()
|
|
232
|
+
except FileNotFoundError:
|
|
233
|
+
pass
|
|
234
|
+
else:
|
|
235
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
target.write_text(backup, encoding="utf-8")
|
|
237
|
+
rolled_back.append(path)
|
|
238
|
+
except Exception as rb_exc:
|
|
239
|
+
logger.error("Rollback failed for %s: %s", path, rb_exc)
|
|
240
|
+
|
|
241
|
+
report.ok = False
|
|
242
|
+
report.applied = applied
|
|
243
|
+
report.rolled_back = rolled_back
|
|
244
|
+
report.error = f"Apply failed after {len(applied)} ops: {exc}"
|
|
245
|
+
_safe_rmtree(staging_dir)
|
|
246
|
+
report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
|
|
247
|
+
return report
|
|
248
|
+
|
|
249
|
+
_safe_rmtree(staging_dir)
|
|
250
|
+
report.ok = True
|
|
251
|
+
report.applied = applied
|
|
252
|
+
report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
|
|
253
|
+
return report
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _safe_rmtree(path: Path) -> None:
|
|
257
|
+
try:
|
|
258
|
+
if path.exists():
|
|
259
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
260
|
+
except Exception:
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
__all__ = ["ChangeOp", "ChangeSet", "ChangeSetError", "ApplyReport"]
|
tools/backup_manager.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import git
|
|
2
|
+
import os
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
REPO_PATH = '.'
|
|
7
|
+
IGNORE_PATTERNS = [
|
|
8
|
+
'.env',
|
|
9
|
+
'*.log',
|
|
10
|
+
'pycache/',
|
|
11
|
+
'*.tmp',
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
def get_repo(path_or_repo=None):
|
|
15
|
+
"""Repo initialisation with path safety check."""
|
|
16
|
+
target = path_or_repo or REPO_PATH
|
|
17
|
+
if isinstance(target, git.Repo):
|
|
18
|
+
return target
|
|
19
|
+
try:
|
|
20
|
+
return git.Repo(target)
|
|
21
|
+
except git.exc.InvalidGitRepositoryError:
|
|
22
|
+
print(f"Error: path '{target}' is not a valid git repository.")
|
|
23
|
+
print("Run 'git init' inside target project folder.")
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
def should_ignore(file_path):
|
|
27
|
+
"""Checking whether file is ignored"""
|
|
28
|
+
file_name = os.path.basename(file_path)
|
|
29
|
+
for pattern in IGNORE_PATTERNS:
|
|
30
|
+
if pattern.startswith('*'):
|
|
31
|
+
if file_name.endswith(pattern[1:]):
|
|
32
|
+
return True
|
|
33
|
+
elif pattern.endswith('/'):
|
|
34
|
+
if pattern[:-1] in file_path:
|
|
35
|
+
return True
|
|
36
|
+
else:
|
|
37
|
+
if file_name == pattern:
|
|
38
|
+
return True
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
def make_auto_commit(repo_or_path, agent_name="system", comment=""):
|
|
42
|
+
"""Creating commits with file filtration and error processing:"""
|
|
43
|
+
repo = get_repo(repo_or_path)
|
|
44
|
+
if not repo:
|
|
45
|
+
return
|
|
46
|
+
try:
|
|
47
|
+
changed_files = [item.a_path for item in repo.index.diff(None)]
|
|
48
|
+
untracked_files = repo.untracked_files
|
|
49
|
+
all_candidates = changed_files + untracked_files
|
|
50
|
+
|
|
51
|
+
files_to_add = [f for f in all_candidates if not should_ignore(f)]
|
|
52
|
+
|
|
53
|
+
if not files_to_add:
|
|
54
|
+
if not repo.is_dirty() and not untracked_files:
|
|
55
|
+
print("No updates")
|
|
56
|
+
return
|
|
57
|
+
else:
|
|
58
|
+
print("All updated files are ignored.")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
repo.git.add(files_to_add)
|
|
62
|
+
|
|
63
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
64
|
+
message = f"Auto-save [{agent_name}]: {timestamp}"
|
|
65
|
+
if comment:
|
|
66
|
+
message += f" — {comment[:80]}"
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
commit = repo.index.commit(message)
|
|
70
|
+
print(f"Commit created: {message} (SHA: {commit.hexsha[:7]})")
|
|
71
|
+
except git.exc.GitCommandError as e:
|
|
72
|
+
print(f"Commit error: {e}")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
except Exception as e:
|
|
76
|
+
print(f"Fatal error while auto-saving: {e}")
|
|
77
|
+
|
|
78
|
+
def safe_rollback(repo_or_path, target_hash):
|
|
79
|
+
"""Safe rollback to target commit."""
|
|
80
|
+
repo = get_repo(repo_or_path)
|
|
81
|
+
if not repo:
|
|
82
|
+
return
|
|
83
|
+
branch_name = f"rollback-to-{target_hash[:7]}"
|
|
84
|
+
try:
|
|
85
|
+
commit = repo.commit(target_hash)
|
|
86
|
+
new_branch = repo.create_head(branch_name, commit)
|
|
87
|
+
repo.heads[branch_name].checkout()
|
|
88
|
+
print(f"Rollbacked successfully: '{branch_name}'.")
|
|
89
|
+
except git.exc.GitError as e:
|
|
90
|
+
print(f"Git error occurred: {e}")
|
|
91
|
+
|
|
92
|
+
def show_all_commits(repo_or_path, limit=15):
|
|
93
|
+
"""Display git commit list."""
|
|
94
|
+
repo = get_repo(repo_or_path)
|
|
95
|
+
if not repo:
|
|
96
|
+
return []
|
|
97
|
+
try:
|
|
98
|
+
commits = list(repo.iter_commits())
|
|
99
|
+
if limit is not None:
|
|
100
|
+
commits = commits[:limit]
|
|
101
|
+
|
|
102
|
+
print(f"\n--- Git Commits ({len(commits)}) ---")
|
|
103
|
+
for i, c in enumerate(commits):
|
|
104
|
+
short = c.hexsha[:7]
|
|
105
|
+
msg = c.message.strip().split('\n')[0][:70]
|
|
106
|
+
date = c.committed_datetime.strftime("%Y-%m-%d %H:%M")
|
|
107
|
+
print(f"{i:3}. {short} | {date} | {msg}")
|
|
108
|
+
return commits
|
|
109
|
+
except Exception as e:
|
|
110
|
+
print(f"Error reading commits: {e}")
|
|
111
|
+
return []
|
|
112
|
+
|
|
113
|
+
def create_success_tag(repo_or_path):
|
|
114
|
+
repo = get_repo(repo_or_path)
|
|
115
|
+
if not repo:
|
|
116
|
+
return
|
|
117
|
+
try:
|
|
118
|
+
tag_name = f"success-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
|
119
|
+
repo.create_tag(tag_name)
|
|
120
|
+
print(f"Created success tag: {tag_name}")
|
|
121
|
+
except Exception as e:
|
|
122
|
+
print(f"Cannot create tag: {e}")
|