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,429 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Diff Tracker - Track file changes during QE for patch generation.
|
|
3
|
+
|
|
4
|
+
Inspired by EveryCode's turn_diff_tracker.rs implementation.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Capture baseline snapshots before modifications
|
|
8
|
+
- Generate unified diffs comparing baseline to current
|
|
9
|
+
- Support add, delete, update, rename/move operations
|
|
10
|
+
- Git-compatible diff format for easy review
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
tracker = DiffTracker(project_root)
|
|
14
|
+
|
|
15
|
+
# Before modifying a file
|
|
16
|
+
tracker.capture_baseline(Path("src/main.py"))
|
|
17
|
+
|
|
18
|
+
# After QE session
|
|
19
|
+
patch = tracker.get_unified_diff()
|
|
20
|
+
print(patch)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import difflib
|
|
24
|
+
import hashlib
|
|
25
|
+
import os
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from enum import Enum
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
30
|
+
import logging
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ChangeType(Enum):
|
|
36
|
+
"""Type of file change."""
|
|
37
|
+
|
|
38
|
+
ADD = "add"
|
|
39
|
+
DELETE = "delete"
|
|
40
|
+
MODIFY = "modify"
|
|
41
|
+
RENAME = "rename"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class FileBaseline:
|
|
46
|
+
"""Baseline state of a file."""
|
|
47
|
+
|
|
48
|
+
original_path: Path
|
|
49
|
+
content: Optional[bytes] # None = file didn't exist
|
|
50
|
+
mode: int # File mode (permissions)
|
|
51
|
+
oid: str # Content hash (git-style blob SHA)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def exists(self) -> bool:
|
|
55
|
+
return self.content is not None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class FileChange:
|
|
60
|
+
"""Tracked change to a file."""
|
|
61
|
+
|
|
62
|
+
change_type: ChangeType
|
|
63
|
+
original_path: Path
|
|
64
|
+
current_path: Path # May differ for renames
|
|
65
|
+
baseline: FileBaseline
|
|
66
|
+
|
|
67
|
+
# For display
|
|
68
|
+
original_display: str = ""
|
|
69
|
+
current_display: str = ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DiffTracker:
|
|
73
|
+
"""
|
|
74
|
+
Track file changes during a QE session for patch generation.
|
|
75
|
+
|
|
76
|
+
Maintains baseline snapshots of files before first modification,
|
|
77
|
+
then generates unified diffs comparing baseline to current state.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
ZERO_OID = "0" * 40
|
|
81
|
+
DEV_NULL = "/dev/null"
|
|
82
|
+
|
|
83
|
+
def __init__(self, project_root: Path):
|
|
84
|
+
self.project_root = project_root.resolve()
|
|
85
|
+
|
|
86
|
+
# Baseline snapshots: path -> baseline
|
|
87
|
+
self._baselines: Dict[Path, FileBaseline] = {}
|
|
88
|
+
|
|
89
|
+
# Path mappings for renames: original -> current
|
|
90
|
+
self._path_mappings: Dict[Path, Path] = {}
|
|
91
|
+
|
|
92
|
+
# Git root for relative paths
|
|
93
|
+
self._git_root: Optional[Path] = None
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def git_root(self) -> Path:
|
|
97
|
+
"""Find git root for relative path display."""
|
|
98
|
+
if self._git_root is None:
|
|
99
|
+
current = self.project_root
|
|
100
|
+
while current != current.parent:
|
|
101
|
+
if (current / ".git").exists():
|
|
102
|
+
self._git_root = current
|
|
103
|
+
break
|
|
104
|
+
current = current.parent
|
|
105
|
+
|
|
106
|
+
if self._git_root is None:
|
|
107
|
+
self._git_root = self.project_root
|
|
108
|
+
|
|
109
|
+
return self._git_root
|
|
110
|
+
|
|
111
|
+
def capture_baseline(self, file_path: Path) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Capture the baseline state of a file before modification.
|
|
114
|
+
|
|
115
|
+
Call this before any file operation (write, delete, rename).
|
|
116
|
+
"""
|
|
117
|
+
abs_path = self._resolve_path(file_path)
|
|
118
|
+
|
|
119
|
+
# Only capture first time
|
|
120
|
+
if abs_path in self._baselines:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
if abs_path.exists():
|
|
124
|
+
try:
|
|
125
|
+
content = abs_path.read_bytes()
|
|
126
|
+
mode = self._get_file_mode(abs_path)
|
|
127
|
+
oid = self._compute_blob_oid(content)
|
|
128
|
+
except (OSError, IOError) as e:
|
|
129
|
+
logger.warning(f"Failed to capture baseline for {file_path}: {e}")
|
|
130
|
+
content = None
|
|
131
|
+
mode = 0o644
|
|
132
|
+
oid = self.ZERO_OID
|
|
133
|
+
else:
|
|
134
|
+
# File doesn't exist - will be treated as add
|
|
135
|
+
content = None
|
|
136
|
+
mode = 0o644
|
|
137
|
+
oid = self.ZERO_OID
|
|
138
|
+
|
|
139
|
+
self._baselines[abs_path] = FileBaseline(
|
|
140
|
+
original_path=abs_path,
|
|
141
|
+
content=content,
|
|
142
|
+
mode=mode,
|
|
143
|
+
oid=oid,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Initialize path mapping
|
|
147
|
+
self._path_mappings[abs_path] = abs_path
|
|
148
|
+
|
|
149
|
+
def record_rename(self, old_path: Path, new_path: Path) -> None:
|
|
150
|
+
"""Record a file rename/move."""
|
|
151
|
+
old_abs = self._resolve_path(old_path)
|
|
152
|
+
new_abs = self._resolve_path(new_path)
|
|
153
|
+
|
|
154
|
+
# Ensure baseline is captured
|
|
155
|
+
if old_abs not in self._baselines:
|
|
156
|
+
self.capture_baseline(old_path)
|
|
157
|
+
|
|
158
|
+
# Update path mapping
|
|
159
|
+
self._path_mappings[old_abs] = new_abs
|
|
160
|
+
|
|
161
|
+
def get_unified_diff(self) -> Optional[str]:
|
|
162
|
+
"""
|
|
163
|
+
Generate a unified diff of all changes.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Git-format unified diff string, or None if no changes
|
|
167
|
+
"""
|
|
168
|
+
changes = self._compute_changes()
|
|
169
|
+
|
|
170
|
+
if not changes:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
# Sort by path for deterministic output
|
|
174
|
+
changes.sort(key=lambda c: str(c.original_path))
|
|
175
|
+
|
|
176
|
+
diff_parts = []
|
|
177
|
+
for change in changes:
|
|
178
|
+
diff = self._generate_file_diff(change)
|
|
179
|
+
if diff:
|
|
180
|
+
diff_parts.append(diff)
|
|
181
|
+
|
|
182
|
+
if not diff_parts:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
return "\n".join(diff_parts)
|
|
186
|
+
|
|
187
|
+
def get_changes_summary(self) -> Dict[str, Any]:
|
|
188
|
+
"""Get a summary of all tracked changes."""
|
|
189
|
+
changes = self._compute_changes()
|
|
190
|
+
|
|
191
|
+
adds = [c for c in changes if c.change_type == ChangeType.ADD]
|
|
192
|
+
deletes = [c for c in changes if c.change_type == ChangeType.DELETE]
|
|
193
|
+
modifies = [c for c in changes if c.change_type == ChangeType.MODIFY]
|
|
194
|
+
renames = [c for c in changes if c.change_type == ChangeType.RENAME]
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
"total_changes": len(changes),
|
|
198
|
+
"additions": len(adds),
|
|
199
|
+
"deletions": len(deletes),
|
|
200
|
+
"modifications": len(modifies),
|
|
201
|
+
"renames": len(renames),
|
|
202
|
+
"files_added": [str(c.current_path) for c in adds],
|
|
203
|
+
"files_deleted": [str(c.original_path) for c in deletes],
|
|
204
|
+
"files_modified": [str(c.current_path) for c in modifies],
|
|
205
|
+
"files_renamed": [(str(c.original_path), str(c.current_path)) for c in renames],
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
def _resolve_path(self, file_path: Path) -> Path:
|
|
209
|
+
"""Resolve to absolute path."""
|
|
210
|
+
if file_path.is_absolute():
|
|
211
|
+
return file_path
|
|
212
|
+
return self.project_root / file_path
|
|
213
|
+
|
|
214
|
+
def _relative_path(self, abs_path: Path) -> str:
|
|
215
|
+
"""Get path relative to git root for display."""
|
|
216
|
+
try:
|
|
217
|
+
return str(abs_path.relative_to(self.git_root))
|
|
218
|
+
except ValueError:
|
|
219
|
+
return str(abs_path)
|
|
220
|
+
|
|
221
|
+
def _compute_changes(self) -> List[FileChange]:
|
|
222
|
+
"""Compute all file changes from baselines to current state."""
|
|
223
|
+
changes = []
|
|
224
|
+
|
|
225
|
+
for original_path, baseline in self._baselines.items():
|
|
226
|
+
current_path = self._path_mappings.get(original_path, original_path)
|
|
227
|
+
|
|
228
|
+
# Determine change type
|
|
229
|
+
current_exists = current_path.exists()
|
|
230
|
+
baseline_exists = baseline.exists
|
|
231
|
+
is_rename = original_path != current_path
|
|
232
|
+
|
|
233
|
+
if not baseline_exists and current_exists:
|
|
234
|
+
change_type = ChangeType.ADD
|
|
235
|
+
elif baseline_exists and not current_exists:
|
|
236
|
+
change_type = ChangeType.DELETE
|
|
237
|
+
elif is_rename:
|
|
238
|
+
change_type = ChangeType.RENAME
|
|
239
|
+
else:
|
|
240
|
+
# Check if content changed
|
|
241
|
+
if current_exists:
|
|
242
|
+
try:
|
|
243
|
+
current_content = current_path.read_bytes()
|
|
244
|
+
if current_content == baseline.content:
|
|
245
|
+
continue # No change
|
|
246
|
+
except (OSError, IOError):
|
|
247
|
+
continue
|
|
248
|
+
change_type = ChangeType.MODIFY
|
|
249
|
+
|
|
250
|
+
changes.append(
|
|
251
|
+
FileChange(
|
|
252
|
+
change_type=change_type,
|
|
253
|
+
original_path=original_path,
|
|
254
|
+
current_path=current_path,
|
|
255
|
+
baseline=baseline,
|
|
256
|
+
original_display=self._relative_path(original_path),
|
|
257
|
+
current_display=self._relative_path(current_path),
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
return changes
|
|
262
|
+
|
|
263
|
+
def _generate_file_diff(self, change: FileChange) -> str:
|
|
264
|
+
"""Generate unified diff for a single file change."""
|
|
265
|
+
lines = []
|
|
266
|
+
|
|
267
|
+
# Git diff header
|
|
268
|
+
a_path = f"a/{change.original_display}"
|
|
269
|
+
b_path = f"b/{change.current_display}"
|
|
270
|
+
|
|
271
|
+
lines.append(f"diff --git {a_path} {b_path}")
|
|
272
|
+
|
|
273
|
+
# Handle different change types
|
|
274
|
+
if change.change_type == ChangeType.ADD:
|
|
275
|
+
current_mode = self._get_file_mode(change.current_path)
|
|
276
|
+
lines.append(f"new file mode {current_mode:o}")
|
|
277
|
+
|
|
278
|
+
current_content = self._read_file_safe(change.current_path)
|
|
279
|
+
current_oid = (
|
|
280
|
+
self._compute_blob_oid(current_content) if current_content else self.ZERO_OID
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
lines.append(f"index {self.ZERO_OID}..{current_oid}")
|
|
284
|
+
lines.append(f"--- {self.DEV_NULL}")
|
|
285
|
+
lines.append(f"+++ {b_path}")
|
|
286
|
+
|
|
287
|
+
if current_content:
|
|
288
|
+
lines.extend(self._text_diff("", current_content.decode("utf-8", errors="replace")))
|
|
289
|
+
|
|
290
|
+
elif change.change_type == ChangeType.DELETE:
|
|
291
|
+
lines.append(f"deleted file mode {change.baseline.mode:o}")
|
|
292
|
+
lines.append(f"index {change.baseline.oid}..{self.ZERO_OID}")
|
|
293
|
+
lines.append(f"--- {a_path}")
|
|
294
|
+
lines.append(f"+++ {self.DEV_NULL}")
|
|
295
|
+
|
|
296
|
+
if change.baseline.content:
|
|
297
|
+
lines.extend(
|
|
298
|
+
self._text_diff(change.baseline.content.decode("utf-8", errors="replace"), "")
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
else: # MODIFY or RENAME
|
|
302
|
+
current_content = self._read_file_safe(change.current_path)
|
|
303
|
+
current_oid = (
|
|
304
|
+
self._compute_blob_oid(current_content) if current_content else self.ZERO_OID
|
|
305
|
+
)
|
|
306
|
+
current_mode = self._get_file_mode(change.current_path)
|
|
307
|
+
|
|
308
|
+
# Mode change
|
|
309
|
+
if change.baseline.mode != current_mode:
|
|
310
|
+
lines.append(f"old mode {change.baseline.mode:o}")
|
|
311
|
+
lines.append(f"new mode {current_mode:o}")
|
|
312
|
+
|
|
313
|
+
lines.append(f"index {change.baseline.oid}..{current_oid}")
|
|
314
|
+
lines.append(f"--- {a_path}")
|
|
315
|
+
lines.append(f"+++ {b_path}")
|
|
316
|
+
|
|
317
|
+
# Content diff
|
|
318
|
+
old_text = ""
|
|
319
|
+
new_text = ""
|
|
320
|
+
|
|
321
|
+
if change.baseline.content:
|
|
322
|
+
old_text = change.baseline.content.decode("utf-8", errors="replace")
|
|
323
|
+
if current_content:
|
|
324
|
+
new_text = current_content.decode("utf-8", errors="replace")
|
|
325
|
+
|
|
326
|
+
lines.extend(self._text_diff(old_text, new_text))
|
|
327
|
+
|
|
328
|
+
return "\n".join(lines)
|
|
329
|
+
|
|
330
|
+
def _text_diff(self, old_text: str, new_text: str) -> List[str]:
|
|
331
|
+
"""Generate unified diff hunks for text content."""
|
|
332
|
+
old_lines = old_text.splitlines(keepends=True)
|
|
333
|
+
new_lines = new_text.splitlines(keepends=True)
|
|
334
|
+
|
|
335
|
+
diff = difflib.unified_diff(
|
|
336
|
+
old_lines,
|
|
337
|
+
new_lines,
|
|
338
|
+
lineterm="",
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Skip the header lines (--- and +++)
|
|
342
|
+
result = []
|
|
343
|
+
for i, line in enumerate(diff):
|
|
344
|
+
if i < 2: # Skip header
|
|
345
|
+
continue
|
|
346
|
+
# Remove trailing newline for clean output
|
|
347
|
+
result.append(line.rstrip("\n\r"))
|
|
348
|
+
|
|
349
|
+
return result
|
|
350
|
+
|
|
351
|
+
def _read_file_safe(self, file_path: Path) -> Optional[bytes]:
|
|
352
|
+
"""Safely read file content."""
|
|
353
|
+
try:
|
|
354
|
+
if file_path.exists():
|
|
355
|
+
return file_path.read_bytes()
|
|
356
|
+
except (OSError, IOError):
|
|
357
|
+
pass
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
def _get_file_mode(self, file_path: Path) -> int:
|
|
361
|
+
"""Get file mode (permissions)."""
|
|
362
|
+
try:
|
|
363
|
+
stat = file_path.stat()
|
|
364
|
+
# Check if executable
|
|
365
|
+
if stat.st_mode & 0o111:
|
|
366
|
+
return 0o100755
|
|
367
|
+
return 0o100644
|
|
368
|
+
except (OSError, IOError):
|
|
369
|
+
return 0o100644
|
|
370
|
+
|
|
371
|
+
def _compute_blob_oid(self, content: bytes) -> str:
|
|
372
|
+
"""Compute git-style blob SHA-1."""
|
|
373
|
+
# Git blob format: "blob <size>\0<content>"
|
|
374
|
+
header = f"blob {len(content)}\0".encode()
|
|
375
|
+
data = header + content
|
|
376
|
+
return hashlib.sha1(data).hexdigest()
|
|
377
|
+
|
|
378
|
+
def clear(self) -> None:
|
|
379
|
+
"""Clear all tracked baselines."""
|
|
380
|
+
self._baselines.clear()
|
|
381
|
+
self._path_mappings.clear()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class DiffTrackerContext:
|
|
385
|
+
"""Context manager for automatic diff tracking."""
|
|
386
|
+
|
|
387
|
+
def __init__(self, project_root: Path):
|
|
388
|
+
self.tracker = DiffTracker(project_root)
|
|
389
|
+
|
|
390
|
+
def __enter__(self) -> DiffTracker:
|
|
391
|
+
return self.tracker
|
|
392
|
+
|
|
393
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
394
|
+
pass # Tracker is preserved for getting diff after context
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def generate_patch_file(
|
|
398
|
+
project_root: Path,
|
|
399
|
+
tracker: DiffTracker,
|
|
400
|
+
output_path: Optional[Path] = None,
|
|
401
|
+
) -> Optional[Path]:
|
|
402
|
+
"""
|
|
403
|
+
Generate a patch file from tracked changes.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
project_root: Project root directory
|
|
407
|
+
tracker: DiffTracker with captured changes
|
|
408
|
+
output_path: Optional output path for patch file
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
Path to generated patch file, or None if no changes
|
|
412
|
+
"""
|
|
413
|
+
diff = tracker.get_unified_diff()
|
|
414
|
+
|
|
415
|
+
if not diff:
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
if output_path is None:
|
|
419
|
+
from datetime import datetime
|
|
420
|
+
|
|
421
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
422
|
+
output_path = (
|
|
423
|
+
project_root / ".superqode" / "qe-artifacts" / "patches" / f"qe-{timestamp}.patch"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
427
|
+
output_path.write_text(diff)
|
|
428
|
+
|
|
429
|
+
return output_path
|