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.
Files changed (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. 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