crabcode 0.1.6__py3-none-win_amd64.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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""Git-based file system snapshots — track and revert code changes.
|
|
2
|
+
|
|
3
|
+
When the working directory is inside a git repo, snapshots use git internals
|
|
4
|
+
(hash-object, write-tree, update-ref) for lightweight, zero-commit snapshots.
|
|
5
|
+
When there is no git repo, files are copied into a ``.crabcode/snapshots``
|
|
6
|
+
store for tracking.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from crabcode_core.logging_utils import get_logger
|
|
20
|
+
from crabcode_core.subprocess_utils import subprocess_group_options
|
|
21
|
+
|
|
22
|
+
logger = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Data types
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Patch:
|
|
31
|
+
"""A snapshot patch — the hash identifying it and the files it covers."""
|
|
32
|
+
hash: str
|
|
33
|
+
files: list[str] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class FileDiff:
|
|
38
|
+
"""Diff for a single file between two snapshots."""
|
|
39
|
+
file: str
|
|
40
|
+
patch: str
|
|
41
|
+
additions: int = 0
|
|
42
|
+
deletions: int = 0
|
|
43
|
+
status: str = "modified" # "added" | "deleted" | "modified"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Helpers
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
_SNAPSHOT_REFS_PREFIX = "refs/crabcode/"
|
|
51
|
+
_CRABCODE_DIR = ".crabcode"
|
|
52
|
+
_SNAPSHOTS_DIR = "snapshots"
|
|
53
|
+
_PRUNE_DAYS = 7
|
|
54
|
+
_MAX_DIFF_CHARS = 50_000
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _git(*args: str, cwd: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
58
|
+
"""Run a git command and return the result."""
|
|
59
|
+
cmd = ["git", "-c", "core.longpaths=true", "-c", "core.autocrlf=false", *args]
|
|
60
|
+
return subprocess.run(
|
|
61
|
+
cmd,
|
|
62
|
+
capture_output=True,
|
|
63
|
+
text=True,
|
|
64
|
+
encoding="utf-8",
|
|
65
|
+
errors="replace",
|
|
66
|
+
cwd=cwd,
|
|
67
|
+
check=check,
|
|
68
|
+
timeout=30,
|
|
69
|
+
**subprocess_group_options(),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_git_repo(cwd: str) -> bool:
|
|
74
|
+
"""Check whether *cwd* is inside a git working tree."""
|
|
75
|
+
try:
|
|
76
|
+
r = _git("rev-parse", "--is-inside-work-tree", cwd=cwd, check=False)
|
|
77
|
+
return r.stdout.strip() == "true"
|
|
78
|
+
except Exception:
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _short_hash(h: str) -> str:
|
|
83
|
+
return h[:12]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# SnapshotManager
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
class SnapshotManager:
|
|
91
|
+
"""Manages lightweight git-based snapshots of the working directory.
|
|
92
|
+
|
|
93
|
+
Usage::
|
|
94
|
+
|
|
95
|
+
mgr = SnapshotManager(cwd="/path/to/project")
|
|
96
|
+
mgr.init()
|
|
97
|
+
snap_id = mgr.track() # create snapshot
|
|
98
|
+
# ... files are modified ...
|
|
99
|
+
diff_text = mgr.diff(snap_id) # see what changed
|
|
100
|
+
mgr.restore(snap_id) # revert to snapshot
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, cwd: str) -> None:
|
|
104
|
+
self.cwd = os.path.abspath(cwd)
|
|
105
|
+
self._is_git = _is_git_repo(self.cwd)
|
|
106
|
+
self._snapshots_dir = Path(self.cwd) / _CRABCODE_DIR / _SNAPSHOTS_DIR
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
# Lifecycle
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def init(self) -> None:
|
|
113
|
+
"""Ensure snapshot infrastructure exists."""
|
|
114
|
+
if self._is_git:
|
|
115
|
+
# Nothing extra needed — we use git refs
|
|
116
|
+
logger.debug("SnapshotManager: using git repo at %s", self.cwd)
|
|
117
|
+
else:
|
|
118
|
+
self._snapshots_dir.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
logger.debug("SnapshotManager: using file-based snapshots at %s", self._snapshots_dir)
|
|
120
|
+
|
|
121
|
+
def cleanup(self) -> int:
|
|
122
|
+
"""Prune snapshots older than ``_PRUNE_DAYS``. Returns count removed."""
|
|
123
|
+
if self._is_git:
|
|
124
|
+
return self._cleanup_git_refs()
|
|
125
|
+
return self._cleanup_file_snapshots()
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
# Track (create snapshot)
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def track(self) -> str | None:
|
|
132
|
+
"""Create a snapshot of the current working directory state.
|
|
133
|
+
|
|
134
|
+
Returns the snapshot ID (a hash string), or ``None`` on failure.
|
|
135
|
+
"""
|
|
136
|
+
if self._is_git:
|
|
137
|
+
return self._track_git()
|
|
138
|
+
return self._track_file()
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
# Diff
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def diff(self, snapshot_id: str) -> str:
|
|
145
|
+
"""Return a unified diff between the current state and *snapshot_id*."""
|
|
146
|
+
if self._is_git:
|
|
147
|
+
return self._diff_git(snapshot_id)
|
|
148
|
+
return self._diff_file(snapshot_id)
|
|
149
|
+
|
|
150
|
+
def diff_full(self, from_id: str, to_id: str) -> list[FileDiff]:
|
|
151
|
+
"""Return per-file diffs between two snapshots."""
|
|
152
|
+
if self._is_git:
|
|
153
|
+
return self._diff_full_git(from_id, to_id)
|
|
154
|
+
return self._diff_full_file(from_id, to_id)
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
# Restore / Revert
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def restore(self, snapshot_id: str) -> list[str]:
|
|
161
|
+
"""Restore the working directory to the state of *snapshot_id*.
|
|
162
|
+
|
|
163
|
+
Returns a list of files that were restored.
|
|
164
|
+
"""
|
|
165
|
+
if self._is_git:
|
|
166
|
+
return self._restore_git(snapshot_id)
|
|
167
|
+
return self._restore_file(snapshot_id)
|
|
168
|
+
|
|
169
|
+
def revert(self, patches: list[Patch]) -> list[str]:
|
|
170
|
+
"""Revert a list of previously applied patches (snapshot IDs).
|
|
171
|
+
|
|
172
|
+
Returns a list of files that were reverted.
|
|
173
|
+
"""
|
|
174
|
+
all_files: list[str] = []
|
|
175
|
+
for patch in reversed(patches):
|
|
176
|
+
files = self.restore(patch.hash)
|
|
177
|
+
all_files.extend(files)
|
|
178
|
+
return list(dict.fromkeys(all_files)) # deduplicate preserving order
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
# Git-based implementation
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def _track_git(self) -> str | None:
|
|
185
|
+
try:
|
|
186
|
+
# Stage all changes
|
|
187
|
+
_git("add", "-A", cwd=self.cwd)
|
|
188
|
+
# Write tree object — this is the snapshot
|
|
189
|
+
r = _git("write-tree", cwd=self.cwd)
|
|
190
|
+
tree_hash = r.stdout.strip()
|
|
191
|
+
if not tree_hash:
|
|
192
|
+
return None
|
|
193
|
+
# Store a ref so the tree isn't garbage-collected
|
|
194
|
+
ref_name = f"{_SNAPSHOT_REFS_PREFIX}{tree_hash}"
|
|
195
|
+
_git("update-ref", ref_name, tree_hash, cwd=self.cwd, check=False)
|
|
196
|
+
logger.info("Git snapshot created: %s", _short_hash(tree_hash))
|
|
197
|
+
return tree_hash
|
|
198
|
+
except Exception:
|
|
199
|
+
logger.warning("Failed to create git snapshot", exc_info=True)
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
def _diff_git(self, snapshot_id: str) -> str:
|
|
203
|
+
try:
|
|
204
|
+
r = _git("diff", snapshot_id, "--", cwd=self.cwd)
|
|
205
|
+
text = r.stdout
|
|
206
|
+
if len(text) > _MAX_DIFF_CHARS:
|
|
207
|
+
text = text[:_MAX_DIFF_CHARS] + "\n... (diff truncated)"
|
|
208
|
+
return text
|
|
209
|
+
except Exception:
|
|
210
|
+
logger.warning("Failed to diff git snapshot %s", _short_hash(snapshot_id), exc_info=True)
|
|
211
|
+
return ""
|
|
212
|
+
|
|
213
|
+
def _diff_full_git(self, from_id: str, to_id: str) -> list[FileDiff]:
|
|
214
|
+
try:
|
|
215
|
+
r = _git("diff-tree", "--no-commit-id", "-r", from_id, to_id, cwd=self.cwd)
|
|
216
|
+
diffs: list[FileDiff] = []
|
|
217
|
+
for line in r.stdout.strip().splitlines():
|
|
218
|
+
# Format: :old_mode new_mode old_hash new_hash status\tfilename
|
|
219
|
+
if not line.startswith(":"):
|
|
220
|
+
continue
|
|
221
|
+
parts = line.split("\t", 1)
|
|
222
|
+
if len(parts) < 2:
|
|
223
|
+
continue
|
|
224
|
+
meta, filepath = parts
|
|
225
|
+
status_char = meta.rsplit(" ", 1)[-1] if " " in meta else "M"
|
|
226
|
+
status_map = {"A": "added", "D": "deleted", "M": "modified", "R": "modified"}
|
|
227
|
+
status = status_map.get(status_char[0], "modified")
|
|
228
|
+
diffs.append(FileDiff(file=filepath, patch="", status=status))
|
|
229
|
+
return diffs
|
|
230
|
+
except Exception:
|
|
231
|
+
logger.warning("Failed to diff_full git snapshots", exc_info=True)
|
|
232
|
+
return []
|
|
233
|
+
|
|
234
|
+
def _restore_git(self, snapshot_id: str) -> list[str]:
|
|
235
|
+
try:
|
|
236
|
+
# Get list of changed files
|
|
237
|
+
r = _git("diff-tree", "--no-commit-id", "-r", "--name-only", snapshot_id, "HEAD", cwd=self.cwd)
|
|
238
|
+
changed_files = [f for f in r.stdout.strip().splitlines() if f]
|
|
239
|
+
|
|
240
|
+
# Checkout all files from the snapshot tree
|
|
241
|
+
_git("checkout", snapshot_id, "--", ".", cwd=self.cwd)
|
|
242
|
+
|
|
243
|
+
# Unstage so working tree is clean but has the restored content
|
|
244
|
+
_git("reset", "HEAD", "--", ".", cwd=self.cwd, check=False)
|
|
245
|
+
|
|
246
|
+
logger.info("Restored git snapshot %s (%d files)", _short_hash(snapshot_id), len(changed_files))
|
|
247
|
+
return changed_files
|
|
248
|
+
except Exception:
|
|
249
|
+
logger.warning("Failed to restore git snapshot %s", _short_hash(snapshot_id), exc_info=True)
|
|
250
|
+
return []
|
|
251
|
+
|
|
252
|
+
def _cleanup_git_refs(self) -> int:
|
|
253
|
+
# Git refs don't have timestamps, so age-based pruning isn't straightforward.
|
|
254
|
+
# They'll be collected by git gc eventually. Cap at 100 refs.
|
|
255
|
+
return 0
|
|
256
|
+
|
|
257
|
+
# ------------------------------------------------------------------
|
|
258
|
+
# File-based (non-git) implementation
|
|
259
|
+
# ------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def _track_file(self) -> str | None:
|
|
262
|
+
try:
|
|
263
|
+
self._snapshots_dir.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
snap_hash = self._compute_directory_hash()
|
|
265
|
+
snap_dir = self._snapshots_dir / snap_hash
|
|
266
|
+
if snap_dir.exists():
|
|
267
|
+
return snap_hash
|
|
268
|
+
|
|
269
|
+
snap_dir.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
# Copy all files (excluding .crabcode, .git, __pycache__, node_modules)
|
|
271
|
+
ignore_patterns = shutil.ignore_patterns(
|
|
272
|
+
".git", ".crabcode", "__pycache__", "node_modules", ".venv", "venv",
|
|
273
|
+
)
|
|
274
|
+
for item in Path(self.cwd).iterdir():
|
|
275
|
+
if item.name.startswith(".") and item.name in (".git", ".crabcode"):
|
|
276
|
+
continue
|
|
277
|
+
try:
|
|
278
|
+
if item.is_file():
|
|
279
|
+
dest = snap_dir / item.name
|
|
280
|
+
if not dest.exists():
|
|
281
|
+
shutil.copy2(str(item), str(dest))
|
|
282
|
+
elif item.is_dir():
|
|
283
|
+
dest = snap_dir / item.name
|
|
284
|
+
if not dest.exists():
|
|
285
|
+
shutil.copytree(str(item), str(dest), ignore=ignore_patterns)
|
|
286
|
+
except Exception:
|
|
287
|
+
logger.debug("Failed to snapshot %s", item, exc_info=True)
|
|
288
|
+
|
|
289
|
+
logger.info("File snapshot created: %s", _short_hash(snap_hash))
|
|
290
|
+
return snap_hash
|
|
291
|
+
except Exception:
|
|
292
|
+
logger.warning("Failed to create file snapshot", exc_info=True)
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
def _compute_directory_hash(self) -> str:
|
|
296
|
+
"""Compute a content hash of the working directory for snapshot ID."""
|
|
297
|
+
h = hashlib.sha256()
|
|
298
|
+
for root, dirs, files in os.walk(self.cwd):
|
|
299
|
+
# Skip hidden/special directories
|
|
300
|
+
dirs[:] = [d for d in dirs if d not in (".git", ".crabcode", "__pycache__", "node_modules", ".venv", "venv")]
|
|
301
|
+
for fname in sorted(files):
|
|
302
|
+
fpath = os.path.join(root, fname)
|
|
303
|
+
try:
|
|
304
|
+
rel = os.path.relpath(fpath, self.cwd)
|
|
305
|
+
h.update(rel.encode())
|
|
306
|
+
stat = os.stat(fpath)
|
|
307
|
+
h.update(str(stat.st_mtime).encode())
|
|
308
|
+
h.update(str(stat.st_size).encode())
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
return h.hexdigest()[:40]
|
|
312
|
+
|
|
313
|
+
def _diff_file(self, snapshot_id: str) -> str:
|
|
314
|
+
snap_dir = self._snapshots_dir / snapshot_id
|
|
315
|
+
if not snap_dir.exists():
|
|
316
|
+
return ""
|
|
317
|
+
import difflib
|
|
318
|
+
parts: list[str] = []
|
|
319
|
+
for root, _dirs, files in os.walk(snap_dir):
|
|
320
|
+
for fname in sorted(files):
|
|
321
|
+
snap_file = Path(root) / fname
|
|
322
|
+
rel = os.path.relpath(str(snap_file), str(snap_dir))
|
|
323
|
+
current_file = Path(self.cwd) / rel
|
|
324
|
+
try:
|
|
325
|
+
old_lines = snap_file.read_text(
|
|
326
|
+
encoding="utf-8", errors="replace"
|
|
327
|
+
).splitlines(keepends=True)
|
|
328
|
+
except Exception:
|
|
329
|
+
old_lines = []
|
|
330
|
+
try:
|
|
331
|
+
new_lines = (
|
|
332
|
+
current_file.read_text(
|
|
333
|
+
encoding="utf-8", errors="replace"
|
|
334
|
+
).splitlines(keepends=True)
|
|
335
|
+
if current_file.exists()
|
|
336
|
+
else []
|
|
337
|
+
)
|
|
338
|
+
except Exception:
|
|
339
|
+
new_lines = []
|
|
340
|
+
diff = list(difflib.unified_diff(old_lines, new_lines, fromfile=rel, tofile=rel))
|
|
341
|
+
if diff:
|
|
342
|
+
parts.extend(diff)
|
|
343
|
+
text = "\n".join(parts)
|
|
344
|
+
if len(text) > _MAX_DIFF_CHARS:
|
|
345
|
+
text = text[:_MAX_DIFF_CHARS] + "\n... (diff truncated)"
|
|
346
|
+
return text
|
|
347
|
+
|
|
348
|
+
def _diff_full_file(self, from_id: str, to_id: str) -> list[FileDiff]:
|
|
349
|
+
# Simplified: just list files that differ between two snapshot dirs
|
|
350
|
+
from_dir = self._snapshots_dir / from_id
|
|
351
|
+
to_dir = self._snapshots_dir / to_id
|
|
352
|
+
if not from_dir.exists() or not to_dir.exists():
|
|
353
|
+
return []
|
|
354
|
+
diffs: list[FileDiff] = []
|
|
355
|
+
all_files = set()
|
|
356
|
+
for d in (from_dir, to_dir):
|
|
357
|
+
for root, _, files in os.walk(d):
|
|
358
|
+
for f in files:
|
|
359
|
+
rel = os.path.relpath(os.path.join(root, f), str(d))
|
|
360
|
+
all_files.add(rel)
|
|
361
|
+
for rel in sorted(all_files):
|
|
362
|
+
from_file = from_dir / rel
|
|
363
|
+
to_file = to_dir / rel
|
|
364
|
+
if from_file.exists() and not to_file.exists():
|
|
365
|
+
diffs.append(FileDiff(file=rel, patch="", status="deleted"))
|
|
366
|
+
elif not from_file.exists() and to_file.exists():
|
|
367
|
+
diffs.append(FileDiff(file=rel, patch="", status="added"))
|
|
368
|
+
else:
|
|
369
|
+
try:
|
|
370
|
+
if from_file.read_text(
|
|
371
|
+
encoding="utf-8", errors="replace"
|
|
372
|
+
) != to_file.read_text(encoding="utf-8", errors="replace"):
|
|
373
|
+
diffs.append(FileDiff(file=rel, patch="", status="modified"))
|
|
374
|
+
except Exception:
|
|
375
|
+
diffs.append(FileDiff(file=rel, patch="", status="modified"))
|
|
376
|
+
return diffs
|
|
377
|
+
|
|
378
|
+
def _restore_file(self, snapshot_id: str) -> list[str]:
|
|
379
|
+
snap_dir = self._snapshots_dir / snapshot_id
|
|
380
|
+
if not snap_dir.exists():
|
|
381
|
+
logger.warning("File snapshot not found: %s", _short_hash(snapshot_id))
|
|
382
|
+
return []
|
|
383
|
+
restored: list[str] = []
|
|
384
|
+
for root, _dirs, files in os.walk(snap_dir):
|
|
385
|
+
for fname in sorted(files):
|
|
386
|
+
snap_file = Path(root) / fname
|
|
387
|
+
rel = os.path.relpath(str(snap_file), str(snap_dir))
|
|
388
|
+
current_file = Path(self.cwd) / rel
|
|
389
|
+
try:
|
|
390
|
+
current_file.parent.mkdir(parents=True, exist_ok=True)
|
|
391
|
+
shutil.copy2(str(snap_file), str(current_file))
|
|
392
|
+
restored.append(rel)
|
|
393
|
+
except Exception:
|
|
394
|
+
logger.debug("Failed to restore %s", rel, exc_info=True)
|
|
395
|
+
# Delete files that were added after the snapshot (exist in cwd but not in snapshot)
|
|
396
|
+
for root, _dirs, files in os.walk(self.cwd):
|
|
397
|
+
root_path = Path(root)
|
|
398
|
+
if _is_under_special_dir(root_path, self.cwd):
|
|
399
|
+
continue
|
|
400
|
+
for fname in files:
|
|
401
|
+
current_file = root_path / fname
|
|
402
|
+
rel = os.path.relpath(str(current_file), self.cwd)
|
|
403
|
+
snap_file = snap_dir / rel
|
|
404
|
+
if not snap_file.exists():
|
|
405
|
+
try:
|
|
406
|
+
current_file.unlink()
|
|
407
|
+
restored.append(rel)
|
|
408
|
+
except Exception:
|
|
409
|
+
logger.debug("Failed to remove post-snapshot file %s", rel, exc_info=True)
|
|
410
|
+
logger.info("Restored file snapshot %s (%d files)", _short_hash(snapshot_id), len(restored))
|
|
411
|
+
return restored
|
|
412
|
+
|
|
413
|
+
def _cleanup_file_snapshots(self) -> int:
|
|
414
|
+
if not self._snapshots_dir.exists():
|
|
415
|
+
return 0
|
|
416
|
+
count = 0
|
|
417
|
+
cutoff = time.time() - _PRUNE_DAYS * 86400
|
|
418
|
+
for d in self._snapshots_dir.iterdir():
|
|
419
|
+
if d.is_dir() and d.stat().st_mtime < cutoff:
|
|
420
|
+
try:
|
|
421
|
+
shutil.rmtree(d)
|
|
422
|
+
count += 1
|
|
423
|
+
except Exception:
|
|
424
|
+
logger.debug("Failed to prune snapshot %s", d, exc_info=True)
|
|
425
|
+
return count
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _is_under_special_dir(path: Path, cwd: str) -> bool:
|
|
429
|
+
"""Check if *path* is under a directory we should skip (e.g. .git, .crabcode)."""
|
|
430
|
+
try:
|
|
431
|
+
rel = os.path.relpath(str(path), cwd)
|
|
432
|
+
parts = Path(rel).parts
|
|
433
|
+
skip = {".git", ".crabcode", "__pycache__", "node_modules", ".venv", "venv"}
|
|
434
|
+
return any(p in skip for p in parts)
|
|
435
|
+
except Exception:
|
|
436
|
+
return True
|