superqode 0.1.5__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.
Files changed (288) hide show
  1. superqode/__init__.py +33 -0
  2. superqode/acp/__init__.py +23 -0
  3. superqode/acp/client.py +913 -0
  4. superqode/acp/permission_screen.py +457 -0
  5. superqode/acp/types.py +480 -0
  6. superqode/acp_discovery.py +856 -0
  7. superqode/agent/__init__.py +22 -0
  8. superqode/agent/edit_strategies.py +334 -0
  9. superqode/agent/loop.py +892 -0
  10. superqode/agent/qe_report_templates.py +39 -0
  11. superqode/agent/system_prompts.py +353 -0
  12. superqode/agent_output.py +721 -0
  13. superqode/agent_stream.py +953 -0
  14. superqode/agents/__init__.py +59 -0
  15. superqode/agents/acp_registry.py +305 -0
  16. superqode/agents/client.py +249 -0
  17. superqode/agents/data/augmentcode.com.toml +51 -0
  18. superqode/agents/data/cagent.dev.toml +51 -0
  19. superqode/agents/data/claude.com.toml +60 -0
  20. superqode/agents/data/codeassistant.dev.toml +51 -0
  21. superqode/agents/data/codex.openai.com.toml +57 -0
  22. superqode/agents/data/fastagent.ai.toml +66 -0
  23. superqode/agents/data/geminicli.com.toml +77 -0
  24. superqode/agents/data/goose.block.xyz.toml +54 -0
  25. superqode/agents/data/junie.jetbrains.com.toml +56 -0
  26. superqode/agents/data/kimi.moonshot.cn.toml +57 -0
  27. superqode/agents/data/llmlingagent.dev.toml +51 -0
  28. superqode/agents/data/molt.bot.toml +49 -0
  29. superqode/agents/data/opencode.ai.toml +60 -0
  30. superqode/agents/data/stakpak.dev.toml +51 -0
  31. superqode/agents/data/vtcode.dev.toml +51 -0
  32. superqode/agents/discovery.py +266 -0
  33. superqode/agents/messaging.py +160 -0
  34. superqode/agents/persona.py +166 -0
  35. superqode/agents/registry.py +421 -0
  36. superqode/agents/schema.py +72 -0
  37. superqode/agents/unified.py +367 -0
  38. superqode/app/__init__.py +111 -0
  39. superqode/app/constants.py +314 -0
  40. superqode/app/css.py +366 -0
  41. superqode/app/models.py +118 -0
  42. superqode/app/suggester.py +125 -0
  43. superqode/app/widgets.py +1591 -0
  44. superqode/app_enhanced.py +399 -0
  45. superqode/app_main.py +17187 -0
  46. superqode/approval.py +312 -0
  47. superqode/atomic.py +296 -0
  48. superqode/commands/__init__.py +1 -0
  49. superqode/commands/acp.py +965 -0
  50. superqode/commands/agents.py +180 -0
  51. superqode/commands/auth.py +278 -0
  52. superqode/commands/config.py +374 -0
  53. superqode/commands/init.py +826 -0
  54. superqode/commands/providers.py +819 -0
  55. superqode/commands/qe.py +1145 -0
  56. superqode/commands/roles.py +380 -0
  57. superqode/commands/serve.py +172 -0
  58. superqode/commands/suggestions.py +127 -0
  59. superqode/commands/superqe.py +460 -0
  60. superqode/config/__init__.py +51 -0
  61. superqode/config/loader.py +812 -0
  62. superqode/config/schema.py +498 -0
  63. superqode/core/__init__.py +111 -0
  64. superqode/core/roles.py +281 -0
  65. superqode/danger.py +386 -0
  66. superqode/data/superqode-template.yaml +1522 -0
  67. superqode/design_system.py +1080 -0
  68. superqode/dialogs/__init__.py +6 -0
  69. superqode/dialogs/base.py +39 -0
  70. superqode/dialogs/model.py +130 -0
  71. superqode/dialogs/provider.py +870 -0
  72. superqode/diff_view.py +919 -0
  73. superqode/enterprise.py +21 -0
  74. superqode/evaluation/__init__.py +25 -0
  75. superqode/evaluation/adapters.py +93 -0
  76. superqode/evaluation/behaviors.py +89 -0
  77. superqode/evaluation/engine.py +209 -0
  78. superqode/evaluation/scenarios.py +96 -0
  79. superqode/execution/__init__.py +36 -0
  80. superqode/execution/linter.py +538 -0
  81. superqode/execution/modes.py +347 -0
  82. superqode/execution/resolver.py +283 -0
  83. superqode/execution/runner.py +642 -0
  84. superqode/file_explorer.py +811 -0
  85. superqode/file_viewer.py +471 -0
  86. superqode/flash.py +183 -0
  87. superqode/guidance/__init__.py +58 -0
  88. superqode/guidance/config.py +203 -0
  89. superqode/guidance/prompts.py +71 -0
  90. superqode/harness/__init__.py +54 -0
  91. superqode/harness/accelerator.py +291 -0
  92. superqode/harness/config.py +319 -0
  93. superqode/harness/validator.py +147 -0
  94. superqode/history.py +279 -0
  95. superqode/integrations/superopt_runner.py +124 -0
  96. superqode/logging/__init__.py +49 -0
  97. superqode/logging/adapters.py +219 -0
  98. superqode/logging/formatter.py +923 -0
  99. superqode/logging/integration.py +341 -0
  100. superqode/logging/sinks.py +170 -0
  101. superqode/logging/unified_log.py +417 -0
  102. superqode/lsp/__init__.py +26 -0
  103. superqode/lsp/client.py +544 -0
  104. superqode/main.py +1069 -0
  105. superqode/mcp/__init__.py +89 -0
  106. superqode/mcp/auth_storage.py +380 -0
  107. superqode/mcp/client.py +1236 -0
  108. superqode/mcp/config.py +319 -0
  109. superqode/mcp/integration.py +337 -0
  110. superqode/mcp/oauth.py +436 -0
  111. superqode/mcp/oauth_callback.py +385 -0
  112. superqode/mcp/types.py +290 -0
  113. superqode/memory/__init__.py +31 -0
  114. superqode/memory/feedback.py +342 -0
  115. superqode/memory/store.py +522 -0
  116. superqode/notifications.py +369 -0
  117. superqode/optimization/__init__.py +5 -0
  118. superqode/optimization/config.py +33 -0
  119. superqode/permissions/__init__.py +25 -0
  120. superqode/permissions/rules.py +488 -0
  121. superqode/plan.py +323 -0
  122. superqode/providers/__init__.py +33 -0
  123. superqode/providers/gateway/__init__.py +165 -0
  124. superqode/providers/gateway/base.py +228 -0
  125. superqode/providers/gateway/litellm_gateway.py +1170 -0
  126. superqode/providers/gateway/openresponses_gateway.py +436 -0
  127. superqode/providers/health.py +297 -0
  128. superqode/providers/huggingface/__init__.py +74 -0
  129. superqode/providers/huggingface/downloader.py +472 -0
  130. superqode/providers/huggingface/endpoints.py +442 -0
  131. superqode/providers/huggingface/hub.py +531 -0
  132. superqode/providers/huggingface/inference.py +394 -0
  133. superqode/providers/huggingface/transformers_runner.py +516 -0
  134. superqode/providers/local/__init__.py +100 -0
  135. superqode/providers/local/base.py +438 -0
  136. superqode/providers/local/discovery.py +418 -0
  137. superqode/providers/local/lmstudio.py +256 -0
  138. superqode/providers/local/mlx.py +457 -0
  139. superqode/providers/local/ollama.py +486 -0
  140. superqode/providers/local/sglang.py +268 -0
  141. superqode/providers/local/tgi.py +260 -0
  142. superqode/providers/local/tool_support.py +477 -0
  143. superqode/providers/local/vllm.py +258 -0
  144. superqode/providers/manager.py +1338 -0
  145. superqode/providers/models.py +1016 -0
  146. superqode/providers/models_dev.py +578 -0
  147. superqode/providers/openresponses/__init__.py +87 -0
  148. superqode/providers/openresponses/converters/__init__.py +17 -0
  149. superqode/providers/openresponses/converters/messages.py +343 -0
  150. superqode/providers/openresponses/converters/tools.py +268 -0
  151. superqode/providers/openresponses/schema/__init__.py +56 -0
  152. superqode/providers/openresponses/schema/models.py +585 -0
  153. superqode/providers/openresponses/streaming/__init__.py +5 -0
  154. superqode/providers/openresponses/streaming/parser.py +338 -0
  155. superqode/providers/openresponses/tools/__init__.py +21 -0
  156. superqode/providers/openresponses/tools/apply_patch.py +352 -0
  157. superqode/providers/openresponses/tools/code_interpreter.py +290 -0
  158. superqode/providers/openresponses/tools/file_search.py +333 -0
  159. superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
  160. superqode/providers/registry.py +716 -0
  161. superqode/providers/usage.py +332 -0
  162. superqode/pure_mode.py +384 -0
  163. superqode/qr/__init__.py +23 -0
  164. superqode/qr/dashboard.py +781 -0
  165. superqode/qr/generator.py +1018 -0
  166. superqode/qr/templates.py +135 -0
  167. superqode/safety/__init__.py +41 -0
  168. superqode/safety/sandbox.py +413 -0
  169. superqode/safety/warnings.py +256 -0
  170. superqode/server/__init__.py +33 -0
  171. superqode/server/lsp_server.py +775 -0
  172. superqode/server/web.py +250 -0
  173. superqode/session/__init__.py +25 -0
  174. superqode/session/persistence.py +580 -0
  175. superqode/session/sharing.py +477 -0
  176. superqode/session.py +475 -0
  177. superqode/sidebar.py +2991 -0
  178. superqode/stream_view.py +648 -0
  179. superqode/styles/__init__.py +3 -0
  180. superqode/superqe/__init__.py +184 -0
  181. superqode/superqe/acp_runner.py +1064 -0
  182. superqode/superqe/constitution/__init__.py +62 -0
  183. superqode/superqe/constitution/evaluator.py +308 -0
  184. superqode/superqe/constitution/loader.py +432 -0
  185. superqode/superqe/constitution/schema.py +250 -0
  186. superqode/superqe/events.py +591 -0
  187. superqode/superqe/frameworks/__init__.py +65 -0
  188. superqode/superqe/frameworks/base.py +234 -0
  189. superqode/superqe/frameworks/e2e.py +263 -0
  190. superqode/superqe/frameworks/executor.py +237 -0
  191. superqode/superqe/frameworks/javascript.py +409 -0
  192. superqode/superqe/frameworks/python.py +373 -0
  193. superqode/superqe/frameworks/registry.py +92 -0
  194. superqode/superqe/mcp_tools/__init__.py +47 -0
  195. superqode/superqe/mcp_tools/core_tools.py +418 -0
  196. superqode/superqe/mcp_tools/registry.py +230 -0
  197. superqode/superqe/mcp_tools/testing_tools.py +167 -0
  198. superqode/superqe/noise.py +89 -0
  199. superqode/superqe/orchestrator.py +778 -0
  200. superqode/superqe/roles.py +609 -0
  201. superqode/superqe/session.py +713 -0
  202. superqode/superqe/skills/__init__.py +57 -0
  203. superqode/superqe/skills/base.py +106 -0
  204. superqode/superqe/skills/core_skills.py +899 -0
  205. superqode/superqe/skills/registry.py +90 -0
  206. superqode/superqe/verifier.py +101 -0
  207. superqode/superqe_cli.py +76 -0
  208. superqode/tool_call.py +358 -0
  209. superqode/tools/__init__.py +93 -0
  210. superqode/tools/agent_tools.py +496 -0
  211. superqode/tools/base.py +324 -0
  212. superqode/tools/batch_tool.py +133 -0
  213. superqode/tools/diagnostics.py +311 -0
  214. superqode/tools/edit_tools.py +653 -0
  215. superqode/tools/enhanced_base.py +515 -0
  216. superqode/tools/file_tools.py +269 -0
  217. superqode/tools/file_tracking.py +45 -0
  218. superqode/tools/lsp_tools.py +610 -0
  219. superqode/tools/network_tools.py +350 -0
  220. superqode/tools/permissions.py +400 -0
  221. superqode/tools/question_tool.py +324 -0
  222. superqode/tools/search_tools.py +598 -0
  223. superqode/tools/shell_tools.py +259 -0
  224. superqode/tools/todo_tools.py +121 -0
  225. superqode/tools/validation.py +80 -0
  226. superqode/tools/web_tools.py +639 -0
  227. superqode/tui.py +1152 -0
  228. superqode/tui_integration.py +875 -0
  229. superqode/tui_widgets/__init__.py +27 -0
  230. superqode/tui_widgets/widgets/__init__.py +18 -0
  231. superqode/tui_widgets/widgets/progress.py +185 -0
  232. superqode/tui_widgets/widgets/tool_display.py +188 -0
  233. superqode/undo_manager.py +574 -0
  234. superqode/utils/__init__.py +5 -0
  235. superqode/utils/error_handling.py +323 -0
  236. superqode/utils/fuzzy.py +257 -0
  237. superqode/widgets/__init__.py +477 -0
  238. superqode/widgets/agent_collab.py +390 -0
  239. superqode/widgets/agent_store.py +936 -0
  240. superqode/widgets/agent_switcher.py +395 -0
  241. superqode/widgets/animation_manager.py +284 -0
  242. superqode/widgets/code_context.py +356 -0
  243. superqode/widgets/command_palette.py +412 -0
  244. superqode/widgets/connection_status.py +537 -0
  245. superqode/widgets/conversation_history.py +470 -0
  246. superqode/widgets/diff_indicator.py +155 -0
  247. superqode/widgets/enhanced_status_bar.py +385 -0
  248. superqode/widgets/enhanced_toast.py +476 -0
  249. superqode/widgets/file_browser.py +809 -0
  250. superqode/widgets/file_reference.py +585 -0
  251. superqode/widgets/issue_timeline.py +340 -0
  252. superqode/widgets/leader_key.py +264 -0
  253. superqode/widgets/mode_switcher.py +445 -0
  254. superqode/widgets/model_picker.py +234 -0
  255. superqode/widgets/permission_preview.py +1205 -0
  256. superqode/widgets/prompt.py +358 -0
  257. superqode/widgets/provider_connect.py +725 -0
  258. superqode/widgets/pty_shell.py +587 -0
  259. superqode/widgets/qe_dashboard.py +321 -0
  260. superqode/widgets/resizable_sidebar.py +377 -0
  261. superqode/widgets/response_changes.py +218 -0
  262. superqode/widgets/response_display.py +528 -0
  263. superqode/widgets/rich_tool_display.py +613 -0
  264. superqode/widgets/sidebar_panels.py +1180 -0
  265. superqode/widgets/slash_complete.py +356 -0
  266. superqode/widgets/split_view.py +612 -0
  267. superqode/widgets/status_bar.py +273 -0
  268. superqode/widgets/superqode_display.py +786 -0
  269. superqode/widgets/thinking_display.py +815 -0
  270. superqode/widgets/throbber.py +87 -0
  271. superqode/widgets/toast.py +206 -0
  272. superqode/widgets/unified_output.py +1073 -0
  273. superqode/workspace/__init__.py +75 -0
  274. superqode/workspace/artifacts.py +472 -0
  275. superqode/workspace/coordinator.py +353 -0
  276. superqode/workspace/diff_tracker.py +429 -0
  277. superqode/workspace/git_guard.py +373 -0
  278. superqode/workspace/git_snapshot.py +526 -0
  279. superqode/workspace/manager.py +750 -0
  280. superqode/workspace/snapshot.py +357 -0
  281. superqode/workspace/watcher.py +535 -0
  282. superqode/workspace/worktree.py +440 -0
  283. superqode-0.1.5.dist-info/METADATA +204 -0
  284. superqode-0.1.5.dist-info/RECORD +288 -0
  285. superqode-0.1.5.dist-info/WHEEL +5 -0
  286. superqode-0.1.5.dist-info/entry_points.txt +3 -0
  287. superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
  288. superqode-0.1.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,526 @@
1
+ """
2
+ Git-Based Snapshot Manager.
3
+
4
+ Uses Git's object database for robust file state tracking and reversion.
5
+ Much more reliable than in-memory/tempfile approach:
6
+ - Atomic operations
7
+ - Efficient storage (Git's delta compression)
8
+ - Full history and diffing capabilities
9
+ - Works with existing Git workflows
10
+ - Adapted for SuperQode's QE needs
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import hashlib
17
+ import os
18
+ import subprocess
19
+ from dataclasses import dataclass, field
20
+ from datetime import datetime
21
+ from enum import Enum
22
+ from pathlib import Path
23
+ from typing import Dict, List, Optional, Set, Tuple
24
+ import json
25
+
26
+
27
+ class SnapshotError(Exception):
28
+ """Error during snapshot operations."""
29
+
30
+ pass
31
+
32
+
33
+ class FileStatus(Enum):
34
+ """Status of a file relative to snapshot."""
35
+
36
+ UNCHANGED = "unchanged"
37
+ MODIFIED = "modified"
38
+ ADDED = "added"
39
+ DELETED = "deleted"
40
+ RENAMED = "renamed"
41
+
42
+
43
+ @dataclass
44
+ class FileChange:
45
+ """Represents a change to a file."""
46
+
47
+ path: Path
48
+ status: FileStatus
49
+ original_hash: Optional[str] = None
50
+ current_hash: Optional[str] = None
51
+ original_path: Optional[Path] = None # For renames
52
+
53
+
54
+ @dataclass
55
+ class Snapshot:
56
+ """A point-in-time snapshot of file states."""
57
+
58
+ id: str
59
+ timestamp: datetime
60
+ message: str
61
+ file_hashes: Dict[str, str] # path -> git object hash
62
+ parent_id: Optional[str] = None
63
+
64
+ def to_dict(self) -> dict:
65
+ return {
66
+ "id": self.id,
67
+ "timestamp": self.timestamp.isoformat(),
68
+ "message": self.message,
69
+ "file_hashes": self.file_hashes,
70
+ "parent_id": self.parent_id,
71
+ }
72
+
73
+ @classmethod
74
+ def from_dict(cls, data: dict) -> "Snapshot":
75
+ return cls(
76
+ id=data["id"],
77
+ timestamp=datetime.fromisoformat(data["timestamp"]),
78
+ message=data["message"],
79
+ file_hashes=data["file_hashes"],
80
+ parent_id=data.get("parent_id"),
81
+ )
82
+
83
+
84
+ class GitSnapshotManager:
85
+ """
86
+ Git-based snapshot manager for robust file state tracking.
87
+
88
+ Uses Git's object database to store file states efficiently.
89
+ All operations are atomic and can be safely interrupted.
90
+
91
+ Usage:
92
+ manager = GitSnapshotManager(project_root)
93
+
94
+ # Create initial snapshot before QE session
95
+ snapshot_id = await manager.create_snapshot("Before QE session")
96
+
97
+ # ... agent modifies files ...
98
+
99
+ # Get changes since snapshot
100
+ changes = await manager.get_changes(snapshot_id)
101
+
102
+ # Revert to snapshot
103
+ await manager.restore_snapshot(snapshot_id)
104
+ """
105
+
106
+ SUPERQODE_REF = "refs/superqode/snapshots"
107
+
108
+ def __init__(self, project_root: Path):
109
+ self.project_root = project_root.resolve()
110
+ self._git_dir = self.project_root / ".git"
111
+ self._snapshots_dir = self.project_root / ".superqode" / "snapshots"
112
+ self._current_snapshot: Optional[str] = None
113
+ self._tracked_files: Set[Path] = set()
114
+
115
+ # Verify Git repo exists
116
+ if not self._git_dir.exists():
117
+ raise SnapshotError(f"Not a Git repository: {self.project_root}")
118
+
119
+ async def _run_git(
120
+ self,
121
+ *args: str,
122
+ capture_output: bool = True,
123
+ check: bool = True,
124
+ ) -> subprocess.CompletedProcess:
125
+ """Run a git command."""
126
+ cmd = ["git", "-C", str(self.project_root), *args]
127
+
128
+ proc = await asyncio.create_subprocess_exec(
129
+ *cmd,
130
+ stdout=asyncio.subprocess.PIPE if capture_output else None,
131
+ stderr=asyncio.subprocess.PIPE if capture_output else None,
132
+ )
133
+ stdout, stderr = await proc.communicate()
134
+
135
+ result = subprocess.CompletedProcess(
136
+ cmd,
137
+ proc.returncode,
138
+ stdout=stdout.decode() if stdout else "",
139
+ stderr=stderr.decode() if stderr else "",
140
+ )
141
+
142
+ if check and result.returncode != 0:
143
+ raise SnapshotError(f"Git command failed: {' '.join(cmd)}\n{result.stderr}")
144
+
145
+ return result
146
+
147
+ def _run_git_sync(
148
+ self,
149
+ *args: str,
150
+ capture_output: bool = True,
151
+ check: bool = True,
152
+ ) -> subprocess.CompletedProcess:
153
+ """Run a git command synchronously."""
154
+ cmd = ["git", "-C", str(self.project_root), *args]
155
+
156
+ result = subprocess.run(
157
+ cmd,
158
+ capture_output=capture_output,
159
+ text=True,
160
+ )
161
+
162
+ if check and result.returncode != 0:
163
+ raise SnapshotError(f"Git command failed: {' '.join(cmd)}\n{result.stderr}")
164
+
165
+ return result
166
+
167
+ async def _hash_object(self, content: bytes) -> str:
168
+ """Store content in Git object database and return hash."""
169
+ proc = await asyncio.create_subprocess_exec(
170
+ "git",
171
+ "-C",
172
+ str(self.project_root),
173
+ "hash-object",
174
+ "-w",
175
+ "--stdin",
176
+ stdin=asyncio.subprocess.PIPE,
177
+ stdout=asyncio.subprocess.PIPE,
178
+ )
179
+ stdout, _ = await proc.communicate(content)
180
+ return stdout.decode().strip()
181
+
182
+ async def _get_object(self, obj_hash: str) -> bytes:
183
+ """Retrieve content from Git object database."""
184
+ proc = await asyncio.create_subprocess_exec(
185
+ "git",
186
+ "-C",
187
+ str(self.project_root),
188
+ "cat-file",
189
+ "blob",
190
+ obj_hash,
191
+ stdout=asyncio.subprocess.PIPE,
192
+ stderr=asyncio.subprocess.PIPE,
193
+ )
194
+ stdout, stderr = await proc.communicate()
195
+
196
+ if proc.returncode != 0:
197
+ raise SnapshotError(f"Object not found: {obj_hash}")
198
+
199
+ return stdout
200
+
201
+ async def _get_file_hash(self, file_path: Path) -> Optional[str]:
202
+ """Get the Git hash for a file's current content."""
203
+ abs_path = self.project_root / file_path
204
+
205
+ if not abs_path.exists() or not abs_path.is_file():
206
+ return None
207
+
208
+ try:
209
+ content = abs_path.read_bytes()
210
+ return await self._hash_object(content)
211
+ except (IOError, OSError):
212
+ return None
213
+
214
+ def _generate_snapshot_id(self) -> str:
215
+ """Generate a unique snapshot ID."""
216
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
217
+ random_suffix = hashlib.sha256(os.urandom(8)).hexdigest()[:8]
218
+ return f"snap-{timestamp}-{random_suffix}"
219
+
220
+ async def create_snapshot(
221
+ self,
222
+ message: str = "Snapshot",
223
+ files: Optional[List[Path]] = None,
224
+ ) -> str:
225
+ """
226
+ Create a snapshot of the current file state.
227
+
228
+ Args:
229
+ message: Description of the snapshot
230
+ files: Specific files to snapshot (None = all tracked files)
231
+
232
+ Returns:
233
+ Snapshot ID
234
+ """
235
+ snapshot_id = self._generate_snapshot_id()
236
+
237
+ # Get list of files to snapshot
238
+ if files:
239
+ target_files = [Path(f) for f in files]
240
+ else:
241
+ # Get all tracked files from Git
242
+ result = await self._run_git("ls-files")
243
+ target_files = [Path(f) for f in result.stdout.strip().split("\n") if f]
244
+
245
+ # Capture file hashes
246
+ file_hashes = {}
247
+ for file_path in target_files:
248
+ hash_val = await self._get_file_hash(file_path)
249
+ if hash_val:
250
+ file_hashes[str(file_path)] = hash_val
251
+ self._tracked_files.add(file_path)
252
+
253
+ # Create snapshot object
254
+ snapshot = Snapshot(
255
+ id=snapshot_id,
256
+ timestamp=datetime.now(),
257
+ message=message,
258
+ file_hashes=file_hashes,
259
+ parent_id=self._current_snapshot,
260
+ )
261
+
262
+ # Save snapshot metadata
263
+ self._snapshots_dir.mkdir(parents=True, exist_ok=True)
264
+ snapshot_file = self._snapshots_dir / f"{snapshot_id}.json"
265
+ snapshot_file.write_text(json.dumps(snapshot.to_dict(), indent=2))
266
+
267
+ self._current_snapshot = snapshot_id
268
+
269
+ return snapshot_id
270
+
271
+ async def get_snapshot(self, snapshot_id: str) -> Optional[Snapshot]:
272
+ """Get a snapshot by ID."""
273
+ snapshot_file = self._snapshots_dir / f"{snapshot_id}.json"
274
+
275
+ if not snapshot_file.exists():
276
+ return None
277
+
278
+ data = json.loads(snapshot_file.read_text())
279
+ return Snapshot.from_dict(data)
280
+
281
+ async def list_snapshots(self) -> List[Snapshot]:
282
+ """List all available snapshots."""
283
+ if not self._snapshots_dir.exists():
284
+ return []
285
+
286
+ snapshots = []
287
+ for file_path in self._snapshots_dir.glob("snap-*.json"):
288
+ try:
289
+ data = json.loads(file_path.read_text())
290
+ snapshots.append(Snapshot.from_dict(data))
291
+ except (json.JSONDecodeError, KeyError):
292
+ continue
293
+
294
+ # Sort by timestamp, newest first
295
+ snapshots.sort(key=lambda s: s.timestamp, reverse=True)
296
+ return snapshots
297
+
298
+ async def get_changes(
299
+ self,
300
+ snapshot_id: str,
301
+ files: Optional[List[Path]] = None,
302
+ ) -> List[FileChange]:
303
+ """
304
+ Get changes since a snapshot.
305
+
306
+ Args:
307
+ snapshot_id: ID of the snapshot to compare against
308
+ files: Specific files to check (None = all tracked files)
309
+
310
+ Returns:
311
+ List of file changes
312
+ """
313
+ snapshot = await self.get_snapshot(snapshot_id)
314
+ if not snapshot:
315
+ raise SnapshotError(f"Snapshot not found: {snapshot_id}")
316
+
317
+ changes = []
318
+
319
+ # Files to check
320
+ check_files = set(Path(f) for f in files) if files else self._tracked_files
321
+
322
+ # Also check files that were in the snapshot
323
+ for path_str in snapshot.file_hashes:
324
+ check_files.add(Path(path_str))
325
+
326
+ for file_path in check_files:
327
+ path_str = str(file_path)
328
+ original_hash = snapshot.file_hashes.get(path_str)
329
+ current_hash = await self._get_file_hash(file_path)
330
+
331
+ if original_hash == current_hash:
332
+ status = FileStatus.UNCHANGED
333
+ elif original_hash is None and current_hash is not None:
334
+ status = FileStatus.ADDED
335
+ elif original_hash is not None and current_hash is None:
336
+ status = FileStatus.DELETED
337
+ else:
338
+ status = FileStatus.MODIFIED
339
+
340
+ if status != FileStatus.UNCHANGED:
341
+ changes.append(
342
+ FileChange(
343
+ path=file_path,
344
+ status=status,
345
+ original_hash=original_hash,
346
+ current_hash=current_hash,
347
+ )
348
+ )
349
+
350
+ return changes
351
+
352
+ async def restore_snapshot(
353
+ self,
354
+ snapshot_id: str,
355
+ files: Optional[List[Path]] = None,
356
+ ) -> Dict[str, List[str]]:
357
+ """
358
+ Restore files to their state at a snapshot.
359
+
360
+ Args:
361
+ snapshot_id: ID of the snapshot to restore
362
+ files: Specific files to restore (None = all files in snapshot)
363
+
364
+ Returns:
365
+ Summary of restored files
366
+ """
367
+ snapshot = await self.get_snapshot(snapshot_id)
368
+ if not snapshot:
369
+ raise SnapshotError(f"Snapshot not found: {snapshot_id}")
370
+
371
+ result = {
372
+ "restored": [],
373
+ "deleted": [],
374
+ "errors": [],
375
+ }
376
+
377
+ # Files to restore
378
+ if files:
379
+ target_files = {str(f) for f in files}
380
+ else:
381
+ target_files = set(snapshot.file_hashes.keys())
382
+
383
+ # Get current file list to detect additions
384
+ current_files = set()
385
+ for file_path in self._tracked_files:
386
+ if (self.project_root / file_path).exists():
387
+ current_files.add(str(file_path))
388
+
389
+ # Restore files from snapshot
390
+ for path_str in target_files:
391
+ if path_str not in snapshot.file_hashes:
392
+ continue
393
+
394
+ abs_path = self.project_root / path_str
395
+ obj_hash = snapshot.file_hashes[path_str]
396
+
397
+ try:
398
+ content = await self._get_object(obj_hash)
399
+ abs_path.parent.mkdir(parents=True, exist_ok=True)
400
+ abs_path.write_bytes(content)
401
+ result["restored"].append(path_str)
402
+ except Exception as e:
403
+ result["errors"].append(f"{path_str}: {e}")
404
+
405
+ # Delete files that were added after the snapshot
406
+ files_to_delete = current_files - target_files
407
+ for path_str in files_to_delete:
408
+ abs_path = self.project_root / path_str
409
+
410
+ try:
411
+ if abs_path.exists():
412
+ abs_path.unlink()
413
+ result["deleted"].append(path_str)
414
+ except Exception as e:
415
+ result["errors"].append(f"delete {path_str}: {e}")
416
+
417
+ return result
418
+
419
+ async def get_file_at_snapshot(
420
+ self,
421
+ snapshot_id: str,
422
+ file_path: Path,
423
+ ) -> Optional[bytes]:
424
+ """Get file content at a specific snapshot."""
425
+ snapshot = await self.get_snapshot(snapshot_id)
426
+ if not snapshot:
427
+ return None
428
+
429
+ obj_hash = snapshot.file_hashes.get(str(file_path))
430
+ if not obj_hash:
431
+ return None
432
+
433
+ try:
434
+ return await self._get_object(obj_hash)
435
+ except SnapshotError:
436
+ return None
437
+
438
+ async def get_diff(
439
+ self,
440
+ snapshot_id: str,
441
+ file_path: Path,
442
+ ) -> Optional[str]:
443
+ """Get unified diff for a file since snapshot."""
444
+ original = await self.get_file_at_snapshot(snapshot_id, file_path)
445
+
446
+ abs_path = self.project_root / file_path
447
+ if not abs_path.exists():
448
+ if original:
449
+ return (
450
+ f"--- a/{file_path}\n+++ /dev/null\n@@ -1,{original.count(b'\\n') + 1} +0,0 @@\n"
451
+ + "\n".join(
452
+ f"-{line}" for line in original.decode(errors="replace").splitlines()
453
+ )
454
+ )
455
+ return None
456
+
457
+ current = abs_path.read_bytes()
458
+
459
+ if original == current:
460
+ return None
461
+
462
+ # Use Git diff for proper formatting
463
+ if original:
464
+ # Create temp objects for diffing
465
+ orig_hash = await self._hash_object(original)
466
+ curr_hash = await self._hash_object(current)
467
+
468
+ result = await self._run_git(
469
+ "diff",
470
+ "--no-index",
471
+ f"--src-prefix=a/",
472
+ f"--dst-prefix=b/",
473
+ orig_hash,
474
+ curr_hash,
475
+ check=False, # diff returns 1 if files differ
476
+ )
477
+ return result.stdout
478
+ else:
479
+ # New file
480
+ lines = current.decode(errors="replace").splitlines()
481
+ return f"--- /dev/null\n+++ b/{file_path}\n@@ -0,0 +1,{len(lines)} @@\n" + "\n".join(
482
+ f"+{line}" for line in lines
483
+ )
484
+
485
+ async def delete_snapshot(self, snapshot_id: str) -> bool:
486
+ """Delete a snapshot."""
487
+ snapshot_file = self._snapshots_dir / f"{snapshot_id}.json"
488
+
489
+ if snapshot_file.exists():
490
+ snapshot_file.unlink()
491
+ return True
492
+
493
+ return False
494
+
495
+ async def cleanup_old_snapshots(self, keep_count: int = 10) -> int:
496
+ """Delete old snapshots, keeping the most recent ones."""
497
+ snapshots = await self.list_snapshots()
498
+
499
+ if len(snapshots) <= keep_count:
500
+ return 0
501
+
502
+ deleted = 0
503
+ for snapshot in snapshots[keep_count:]:
504
+ if await self.delete_snapshot(snapshot.id):
505
+ deleted += 1
506
+
507
+ return deleted
508
+
509
+ def track_file(self, file_path: Path) -> None:
510
+ """Add a file to the tracked set."""
511
+ self._tracked_files.add(Path(file_path))
512
+
513
+ def untrack_file(self, file_path: Path) -> None:
514
+ """Remove a file from the tracked set."""
515
+ self._tracked_files.discard(Path(file_path))
516
+
517
+ @property
518
+ def current_snapshot_id(self) -> Optional[str]:
519
+ """Get the current snapshot ID."""
520
+ return self._current_snapshot
521
+
522
+
523
+ # Convenience function for creating a snapshot manager
524
+ def create_git_snapshot_manager(project_root: Path) -> GitSnapshotManager:
525
+ """Create a GitSnapshotManager for the given project."""
526
+ return GitSnapshotManager(project_root)