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.
- superqode/__init__.py +33 -0
- superqode/acp/__init__.py +23 -0
- superqode/acp/client.py +913 -0
- superqode/acp/permission_screen.py +457 -0
- superqode/acp/types.py +480 -0
- superqode/acp_discovery.py +856 -0
- superqode/agent/__init__.py +22 -0
- superqode/agent/edit_strategies.py +334 -0
- superqode/agent/loop.py +892 -0
- superqode/agent/qe_report_templates.py +39 -0
- superqode/agent/system_prompts.py +353 -0
- superqode/agent_output.py +721 -0
- superqode/agent_stream.py +953 -0
- superqode/agents/__init__.py +59 -0
- superqode/agents/acp_registry.py +305 -0
- superqode/agents/client.py +249 -0
- superqode/agents/data/augmentcode.com.toml +51 -0
- superqode/agents/data/cagent.dev.toml +51 -0
- superqode/agents/data/claude.com.toml +60 -0
- superqode/agents/data/codeassistant.dev.toml +51 -0
- superqode/agents/data/codex.openai.com.toml +57 -0
- superqode/agents/data/fastagent.ai.toml +66 -0
- superqode/agents/data/geminicli.com.toml +77 -0
- superqode/agents/data/goose.block.xyz.toml +54 -0
- superqode/agents/data/junie.jetbrains.com.toml +56 -0
- superqode/agents/data/kimi.moonshot.cn.toml +57 -0
- superqode/agents/data/llmlingagent.dev.toml +51 -0
- superqode/agents/data/molt.bot.toml +49 -0
- superqode/agents/data/opencode.ai.toml +60 -0
- superqode/agents/data/stakpak.dev.toml +51 -0
- superqode/agents/data/vtcode.dev.toml +51 -0
- superqode/agents/discovery.py +266 -0
- superqode/agents/messaging.py +160 -0
- superqode/agents/persona.py +166 -0
- superqode/agents/registry.py +421 -0
- superqode/agents/schema.py +72 -0
- superqode/agents/unified.py +367 -0
- superqode/app/__init__.py +111 -0
- superqode/app/constants.py +314 -0
- superqode/app/css.py +366 -0
- superqode/app/models.py +118 -0
- superqode/app/suggester.py +125 -0
- superqode/app/widgets.py +1591 -0
- superqode/app_enhanced.py +399 -0
- superqode/app_main.py +17187 -0
- superqode/approval.py +312 -0
- superqode/atomic.py +296 -0
- superqode/commands/__init__.py +1 -0
- superqode/commands/acp.py +965 -0
- superqode/commands/agents.py +180 -0
- superqode/commands/auth.py +278 -0
- superqode/commands/config.py +374 -0
- superqode/commands/init.py +826 -0
- superqode/commands/providers.py +819 -0
- superqode/commands/qe.py +1145 -0
- superqode/commands/roles.py +380 -0
- superqode/commands/serve.py +172 -0
- superqode/commands/suggestions.py +127 -0
- superqode/commands/superqe.py +460 -0
- superqode/config/__init__.py +51 -0
- superqode/config/loader.py +812 -0
- superqode/config/schema.py +498 -0
- superqode/core/__init__.py +111 -0
- superqode/core/roles.py +281 -0
- superqode/danger.py +386 -0
- superqode/data/superqode-template.yaml +1522 -0
- superqode/design_system.py +1080 -0
- superqode/dialogs/__init__.py +6 -0
- superqode/dialogs/base.py +39 -0
- superqode/dialogs/model.py +130 -0
- superqode/dialogs/provider.py +870 -0
- superqode/diff_view.py +919 -0
- superqode/enterprise.py +21 -0
- superqode/evaluation/__init__.py +25 -0
- superqode/evaluation/adapters.py +93 -0
- superqode/evaluation/behaviors.py +89 -0
- superqode/evaluation/engine.py +209 -0
- superqode/evaluation/scenarios.py +96 -0
- superqode/execution/__init__.py +36 -0
- superqode/execution/linter.py +538 -0
- superqode/execution/modes.py +347 -0
- superqode/execution/resolver.py +283 -0
- superqode/execution/runner.py +642 -0
- superqode/file_explorer.py +811 -0
- superqode/file_viewer.py +471 -0
- superqode/flash.py +183 -0
- superqode/guidance/__init__.py +58 -0
- superqode/guidance/config.py +203 -0
- superqode/guidance/prompts.py +71 -0
- superqode/harness/__init__.py +54 -0
- superqode/harness/accelerator.py +291 -0
- superqode/harness/config.py +319 -0
- superqode/harness/validator.py +147 -0
- superqode/history.py +279 -0
- superqode/integrations/superopt_runner.py +124 -0
- superqode/logging/__init__.py +49 -0
- superqode/logging/adapters.py +219 -0
- superqode/logging/formatter.py +923 -0
- superqode/logging/integration.py +341 -0
- superqode/logging/sinks.py +170 -0
- superqode/logging/unified_log.py +417 -0
- superqode/lsp/__init__.py +26 -0
- superqode/lsp/client.py +544 -0
- superqode/main.py +1069 -0
- superqode/mcp/__init__.py +89 -0
- superqode/mcp/auth_storage.py +380 -0
- superqode/mcp/client.py +1236 -0
- superqode/mcp/config.py +319 -0
- superqode/mcp/integration.py +337 -0
- superqode/mcp/oauth.py +436 -0
- superqode/mcp/oauth_callback.py +385 -0
- superqode/mcp/types.py +290 -0
- superqode/memory/__init__.py +31 -0
- superqode/memory/feedback.py +342 -0
- superqode/memory/store.py +522 -0
- superqode/notifications.py +369 -0
- superqode/optimization/__init__.py +5 -0
- superqode/optimization/config.py +33 -0
- superqode/permissions/__init__.py +25 -0
- superqode/permissions/rules.py +488 -0
- superqode/plan.py +323 -0
- superqode/providers/__init__.py +33 -0
- superqode/providers/gateway/__init__.py +165 -0
- superqode/providers/gateway/base.py +228 -0
- superqode/providers/gateway/litellm_gateway.py +1170 -0
- superqode/providers/gateway/openresponses_gateway.py +436 -0
- superqode/providers/health.py +297 -0
- superqode/providers/huggingface/__init__.py +74 -0
- superqode/providers/huggingface/downloader.py +472 -0
- superqode/providers/huggingface/endpoints.py +442 -0
- superqode/providers/huggingface/hub.py +531 -0
- superqode/providers/huggingface/inference.py +394 -0
- superqode/providers/huggingface/transformers_runner.py +516 -0
- superqode/providers/local/__init__.py +100 -0
- superqode/providers/local/base.py +438 -0
- superqode/providers/local/discovery.py +418 -0
- superqode/providers/local/lmstudio.py +256 -0
- superqode/providers/local/mlx.py +457 -0
- superqode/providers/local/ollama.py +486 -0
- superqode/providers/local/sglang.py +268 -0
- superqode/providers/local/tgi.py +260 -0
- superqode/providers/local/tool_support.py +477 -0
- superqode/providers/local/vllm.py +258 -0
- superqode/providers/manager.py +1338 -0
- superqode/providers/models.py +1016 -0
- superqode/providers/models_dev.py +578 -0
- superqode/providers/openresponses/__init__.py +87 -0
- superqode/providers/openresponses/converters/__init__.py +17 -0
- superqode/providers/openresponses/converters/messages.py +343 -0
- superqode/providers/openresponses/converters/tools.py +268 -0
- superqode/providers/openresponses/schema/__init__.py +56 -0
- superqode/providers/openresponses/schema/models.py +585 -0
- superqode/providers/openresponses/streaming/__init__.py +5 -0
- superqode/providers/openresponses/streaming/parser.py +338 -0
- superqode/providers/openresponses/tools/__init__.py +21 -0
- superqode/providers/openresponses/tools/apply_patch.py +352 -0
- superqode/providers/openresponses/tools/code_interpreter.py +290 -0
- superqode/providers/openresponses/tools/file_search.py +333 -0
- superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
- superqode/providers/registry.py +716 -0
- superqode/providers/usage.py +332 -0
- superqode/pure_mode.py +384 -0
- superqode/qr/__init__.py +23 -0
- superqode/qr/dashboard.py +781 -0
- superqode/qr/generator.py +1018 -0
- superqode/qr/templates.py +135 -0
- superqode/safety/__init__.py +41 -0
- superqode/safety/sandbox.py +413 -0
- superqode/safety/warnings.py +256 -0
- superqode/server/__init__.py +33 -0
- superqode/server/lsp_server.py +775 -0
- superqode/server/web.py +250 -0
- superqode/session/__init__.py +25 -0
- superqode/session/persistence.py +580 -0
- superqode/session/sharing.py +477 -0
- superqode/session.py +475 -0
- superqode/sidebar.py +2991 -0
- superqode/stream_view.py +648 -0
- superqode/styles/__init__.py +3 -0
- superqode/superqe/__init__.py +184 -0
- superqode/superqe/acp_runner.py +1064 -0
- superqode/superqe/constitution/__init__.py +62 -0
- superqode/superqe/constitution/evaluator.py +308 -0
- superqode/superqe/constitution/loader.py +432 -0
- superqode/superqe/constitution/schema.py +250 -0
- superqode/superqe/events.py +591 -0
- superqode/superqe/frameworks/__init__.py +65 -0
- superqode/superqe/frameworks/base.py +234 -0
- superqode/superqe/frameworks/e2e.py +263 -0
- superqode/superqe/frameworks/executor.py +237 -0
- superqode/superqe/frameworks/javascript.py +409 -0
- superqode/superqe/frameworks/python.py +373 -0
- superqode/superqe/frameworks/registry.py +92 -0
- superqode/superqe/mcp_tools/__init__.py +47 -0
- superqode/superqe/mcp_tools/core_tools.py +418 -0
- superqode/superqe/mcp_tools/registry.py +230 -0
- superqode/superqe/mcp_tools/testing_tools.py +167 -0
- superqode/superqe/noise.py +89 -0
- superqode/superqe/orchestrator.py +778 -0
- superqode/superqe/roles.py +609 -0
- superqode/superqe/session.py +713 -0
- superqode/superqe/skills/__init__.py +57 -0
- superqode/superqe/skills/base.py +106 -0
- superqode/superqe/skills/core_skills.py +899 -0
- superqode/superqe/skills/registry.py +90 -0
- superqode/superqe/verifier.py +101 -0
- superqode/superqe_cli.py +76 -0
- superqode/tool_call.py +358 -0
- superqode/tools/__init__.py +93 -0
- superqode/tools/agent_tools.py +496 -0
- superqode/tools/base.py +324 -0
- superqode/tools/batch_tool.py +133 -0
- superqode/tools/diagnostics.py +311 -0
- superqode/tools/edit_tools.py +653 -0
- superqode/tools/enhanced_base.py +515 -0
- superqode/tools/file_tools.py +269 -0
- superqode/tools/file_tracking.py +45 -0
- superqode/tools/lsp_tools.py +610 -0
- superqode/tools/network_tools.py +350 -0
- superqode/tools/permissions.py +400 -0
- superqode/tools/question_tool.py +324 -0
- superqode/tools/search_tools.py +598 -0
- superqode/tools/shell_tools.py +259 -0
- superqode/tools/todo_tools.py +121 -0
- superqode/tools/validation.py +80 -0
- superqode/tools/web_tools.py +639 -0
- superqode/tui.py +1152 -0
- superqode/tui_integration.py +875 -0
- superqode/tui_widgets/__init__.py +27 -0
- superqode/tui_widgets/widgets/__init__.py +18 -0
- superqode/tui_widgets/widgets/progress.py +185 -0
- superqode/tui_widgets/widgets/tool_display.py +188 -0
- superqode/undo_manager.py +574 -0
- superqode/utils/__init__.py +5 -0
- superqode/utils/error_handling.py +323 -0
- superqode/utils/fuzzy.py +257 -0
- superqode/widgets/__init__.py +477 -0
- superqode/widgets/agent_collab.py +390 -0
- superqode/widgets/agent_store.py +936 -0
- superqode/widgets/agent_switcher.py +395 -0
- superqode/widgets/animation_manager.py +284 -0
- superqode/widgets/code_context.py +356 -0
- superqode/widgets/command_palette.py +412 -0
- superqode/widgets/connection_status.py +537 -0
- superqode/widgets/conversation_history.py +470 -0
- superqode/widgets/diff_indicator.py +155 -0
- superqode/widgets/enhanced_status_bar.py +385 -0
- superqode/widgets/enhanced_toast.py +476 -0
- superqode/widgets/file_browser.py +809 -0
- superqode/widgets/file_reference.py +585 -0
- superqode/widgets/issue_timeline.py +340 -0
- superqode/widgets/leader_key.py +264 -0
- superqode/widgets/mode_switcher.py +445 -0
- superqode/widgets/model_picker.py +234 -0
- superqode/widgets/permission_preview.py +1205 -0
- superqode/widgets/prompt.py +358 -0
- superqode/widgets/provider_connect.py +725 -0
- superqode/widgets/pty_shell.py +587 -0
- superqode/widgets/qe_dashboard.py +321 -0
- superqode/widgets/resizable_sidebar.py +377 -0
- superqode/widgets/response_changes.py +218 -0
- superqode/widgets/response_display.py +528 -0
- superqode/widgets/rich_tool_display.py +613 -0
- superqode/widgets/sidebar_panels.py +1180 -0
- superqode/widgets/slash_complete.py +356 -0
- superqode/widgets/split_view.py +612 -0
- superqode/widgets/status_bar.py +273 -0
- superqode/widgets/superqode_display.py +786 -0
- superqode/widgets/thinking_display.py +815 -0
- superqode/widgets/throbber.py +87 -0
- superqode/widgets/toast.py +206 -0
- superqode/widgets/unified_output.py +1073 -0
- superqode/workspace/__init__.py +75 -0
- superqode/workspace/artifacts.py +472 -0
- superqode/workspace/coordinator.py +353 -0
- superqode/workspace/diff_tracker.py +429 -0
- superqode/workspace/git_guard.py +373 -0
- superqode/workspace/git_snapshot.py +526 -0
- superqode/workspace/manager.py +750 -0
- superqode/workspace/snapshot.py +357 -0
- superqode/workspace/watcher.py +535 -0
- superqode/workspace/worktree.py +440 -0
- superqode-0.1.5.dist-info/METADATA +204 -0
- superqode-0.1.5.dist-info/RECORD +288 -0
- superqode-0.1.5.dist-info/WHEEL +5 -0
- superqode-0.1.5.dist-info/entry_points.txt +3 -0
- superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
- 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)
|