ata-coder 2.4.2__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.
- ata_coder/__init__.py +1 -0
- ata_coder/agent.py +874 -0
- ata_coder/agent_compact.py +190 -0
- ata_coder/agent_controller.py +218 -0
- ata_coder/agent_extension.py +69 -0
- ata_coder/agent_routing.py +105 -0
- ata_coder/agent_subsystems.py +72 -0
- ata_coder/agent_tools.py +318 -0
- ata_coder/agent_undo.py +63 -0
- ata_coder/anthropic_client.py +465 -0
- ata_coder/change_tracker.py +368 -0
- ata_coder/clawd_integration.py +574 -0
- ata_coder/commands/__init__.py +128 -0
- ata_coder/commands/_core.py +184 -0
- ata_coder/commands/_safety.py +95 -0
- ata_coder/commands/_settings.py +241 -0
- ata_coder/commands/_workflow.py +451 -0
- ata_coder/commands.py +974 -0
- ata_coder/config.py +257 -0
- ata_coder/core/__init__.py +35 -0
- ata_coder/core/events.py +73 -0
- ata_coder/core/queue.py +85 -0
- ata_coder/core/state.py +17 -0
- ata_coder/event_queue.py +5 -0
- ata_coder/extension.py +654 -0
- ata_coder/extensions/__init__.py +1 -0
- ata_coder/extensions/hello_skill.py +47 -0
- ata_coder/fool_proof.py +295 -0
- ata_coder/git_workflow.py +371 -0
- ata_coder/gui.py +511 -0
- ata_coder/llm_client.py +543 -0
- ata_coder/main.py +814 -0
- ata_coder/mcp_client.py +1095 -0
- ata_coder/memory.py +539 -0
- ata_coder/model_registry.py +134 -0
- ata_coder/model_router.py +105 -0
- ata_coder/permissions.py +274 -0
- ata_coder/privilege.py +464 -0
- ata_coder/project.py +273 -0
- ata_coder/prompt_template.py +423 -0
- ata_coder/prompts/auto-mode.md +7 -0
- ata_coder/prompts/coding-rules.md +40 -0
- ata_coder/prompts/execution-guardrails.md +14 -0
- ata_coder/prompts/memory-system.md +24 -0
- ata_coder/prompts/output-style.md +23 -0
- ata_coder/prompts/safety.md +17 -0
- ata_coder/prompts/slash-commands.md +24 -0
- ata_coder/prompts/sub-agents.md +38 -0
- ata_coder/prompts/system-reminders.md +17 -0
- ata_coder/prompts/system.md +105 -0
- ata_coder/prompts/tool-policy.md +46 -0
- ata_coder/repl_theme.py +99 -0
- ata_coder/repl_tracker.py +89 -0
- ata_coder/repl_ui.py +1214 -0
- ata_coder/safety_guard.py +434 -0
- ata_coder/self_correct.py +346 -0
- ata_coder/server.py +882 -0
- ata_coder/server_session.py +159 -0
- ata_coder/server_shell.py +129 -0
- ata_coder/session.py +431 -0
- ata_coder/settings.py +439 -0
- ata_coder/setup_wizard.py +136 -0
- ata_coder/skill_extension.py +92 -0
- ata_coder/skills/architect/SKILL.md +42 -0
- ata_coder/skills/code-reviewer/SKILL.md +37 -0
- ata_coder/skills/codecraft/SKILL.md +452 -0
- ata_coder/skills/debugger/SKILL.md +45 -0
- ata_coder/skills/doc-writer/SKILL.md +36 -0
- ata_coder/skills/general-coder/SKILL.md +76 -0
- ata_coder/skills/math-calculator/README.md +40 -0
- ata_coder/skills/math-calculator/SKILL.md +59 -0
- ata_coder/skills/math-calculator/handler.py +103 -0
- ata_coder/skills/math-calculator/prompts/system.md +8 -0
- ata_coder/skills/math-calculator/requirements.txt +2 -0
- ata_coder/skills/math-calculator/resources/constants.json +8 -0
- ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
- ata_coder/skills/security-auditor/SKILL.md +40 -0
- ata_coder/skills/test-writer/SKILL.md +36 -0
- ata_coder/skills/weather-skill/README.md +45 -0
- ata_coder/skills/weather-skill/handler.py +76 -0
- ata_coder/skills/weather-skill/manifest.json +48 -0
- ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
- ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
- ata_coder/skills/weather-skill/requirements.txt +1 -0
- ata_coder/skills/weather-skill/resources/city_list.json +17 -0
- ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
- ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
- ata_coder/skills/weather-skill/weather_utils.py +50 -0
- ata_coder/skills.py +1014 -0
- ata_coder/sub_agent.py +273 -0
- ata_coder/sub_agent_manager.py +203 -0
- ata_coder/system_prompt_builder.py +146 -0
- ata_coder/task_planner.py +391 -0
- ata_coder/terminal.py +318 -0
- ata_coder/test_runner.py +219 -0
- ata_coder/thread_supervisor.py +195 -0
- ata_coder/tool_defs.py +335 -0
- ata_coder/tools/__init__.py +11 -0
- ata_coder/tools/definitions.py +335 -0
- ata_coder/tools/executor.py +1036 -0
- ata_coder/tools/result.py +26 -0
- ata_coder/tools/subagent.py +332 -0
- ata_coder/tools/web.py +361 -0
- ata_coder/tools.py +1576 -0
- ata_coder/types.py +92 -0
- ata_coder/utils.py +113 -0
- ata_coder/web/css/style.css +180 -0
- ata_coder/web/index.html +84 -0
- ata_coder/web/js/app.js +489 -0
- ata_coder/web/package-lock.json +25 -0
- ata_coder/web/package.json +10 -0
- ata_coder/web/tsconfig.json +13 -0
- ata_coder-2.4.2.dist-info/METADATA +799 -0
- ata_coder-2.4.2.dist-info/RECORD +118 -0
- ata_coder-2.4.2.dist-info/WHEEL +5 -0
- ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
- ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
- ata_coder-2.4.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Change Tracker — undo/redo for all file modifications.
|
|
3
|
+
|
|
4
|
+
Every write_file and edit_file operation is tracked with:
|
|
5
|
+
- Before/after content snapshots
|
|
6
|
+
- Timestamp and tool call context
|
|
7
|
+
- File path and operation type
|
|
8
|
+
|
|
9
|
+
Supports:
|
|
10
|
+
- /undo <n> — Revert the last N changes
|
|
11
|
+
- /undo all — Revert all changes in this session
|
|
12
|
+
- /changes — List all changes with diffs
|
|
13
|
+
- /restore <n> — Re-apply a reverted change
|
|
14
|
+
- Auto-backup — Files are backed up before modification
|
|
15
|
+
|
|
16
|
+
Backups stored in: .ata_coder/changes/<session-id>/
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import difflib
|
|
20
|
+
import logging
|
|
21
|
+
import shutil
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from enum import Enum
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _default_changes_dir() -> Path:
|
|
31
|
+
"""Get the default changes backup directory from settings or fallback."""
|
|
32
|
+
try:
|
|
33
|
+
from .settings import get_settings
|
|
34
|
+
return get_settings().changes_dir
|
|
35
|
+
except Exception:
|
|
36
|
+
return Path.home() / ".ata_coder" / "changes"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
40
|
+
# Data model
|
|
41
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
42
|
+
|
|
43
|
+
class ChangeType(Enum):
|
|
44
|
+
WRITE = "write" # New file created
|
|
45
|
+
EDIT = "edit" # Existing file modified
|
|
46
|
+
DELETE = "delete" # File deleted (via shell)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class FileChange:
|
|
51
|
+
"""A single tracked file change."""
|
|
52
|
+
id: int
|
|
53
|
+
file_path: str
|
|
54
|
+
change_type: ChangeType
|
|
55
|
+
old_content: str | None # None for new files (WRITE)
|
|
56
|
+
new_content: str | None # None for deleted files
|
|
57
|
+
timestamp: str = ""
|
|
58
|
+
reverted: bool = False
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def diff(self) -> str:
|
|
62
|
+
"""Generate a unified diff."""
|
|
63
|
+
old = (self.old_content or "").splitlines(keepends=True)
|
|
64
|
+
new = (self.new_content or "").splitlines(keepends=True)
|
|
65
|
+
if not old and new:
|
|
66
|
+
# New file — show all additions
|
|
67
|
+
return "".join(f"+{line}" for line in new)
|
|
68
|
+
if old and not new:
|
|
69
|
+
# Deleted file — show all deletions
|
|
70
|
+
return "".join(f"-{line}" for line in old)
|
|
71
|
+
diff = difflib.unified_diff(
|
|
72
|
+
old, new,
|
|
73
|
+
fromfile=f"a/{self.file_path}" if self.old_content else "/dev/null",
|
|
74
|
+
tofile=f"b/{self.file_path}" if self.new_content else "/dev/null",
|
|
75
|
+
)
|
|
76
|
+
return "".join(diff)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def summary(self) -> str:
|
|
80
|
+
"""One-line summary."""
|
|
81
|
+
status = "[REVERTED]" if self.reverted else ""
|
|
82
|
+
if self.change_type == ChangeType.WRITE:
|
|
83
|
+
return f"#{self.id} CREATE {self.file_path} {status}"
|
|
84
|
+
elif self.change_type == ChangeType.EDIT:
|
|
85
|
+
old_lines = (self.old_content or "").count("\n") + 1 if self.old_content else 0
|
|
86
|
+
new_lines = (self.new_content or "").count("\n") + 1 if self.new_content else 0
|
|
87
|
+
return f"#{self.id} EDIT {self.file_path} ({old_lines}→{new_lines} lines) {status}"
|
|
88
|
+
else:
|
|
89
|
+
return f"#{self.id} DELETE {self.file_path} {status}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
93
|
+
# Change Tracker
|
|
94
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
95
|
+
|
|
96
|
+
class ChangeTracker:
|
|
97
|
+
"""
|
|
98
|
+
Tracks all file modifications in a session for undo/redo.
|
|
99
|
+
|
|
100
|
+
Also maintains auto-backups of files before modification.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, session_id: str = "", backup_dir: str | Path | None = None):
|
|
104
|
+
self.session_id = session_id or time.strftime("%Y%m%d-%H%M%S")
|
|
105
|
+
self.changes: list[FileChange] = []
|
|
106
|
+
self._next_id = 1
|
|
107
|
+
self._backup_dir = Path(backup_dir) if backup_dir else _default_changes_dir() / self.session_id
|
|
108
|
+
self._backup_dir.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
self._backups: dict[str, str] = {}
|
|
110
|
+
self._dry_run = False
|
|
111
|
+
self._last_active: int = -1
|
|
112
|
+
|
|
113
|
+
# ── Dry run toggle ───────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def dry_run(self) -> bool:
|
|
117
|
+
return self._dry_run
|
|
118
|
+
|
|
119
|
+
@dry_run.setter
|
|
120
|
+
def dry_run(self, enabled: bool):
|
|
121
|
+
self._dry_run = enabled
|
|
122
|
+
if enabled:
|
|
123
|
+
logger.info("DRY RUN MODE enabled — no actual changes will be made")
|
|
124
|
+
|
|
125
|
+
def reset(self) -> None:
|
|
126
|
+
"""Reset tracker state for a new agent run."""
|
|
127
|
+
self.changes.clear()
|
|
128
|
+
self._next_id = 1
|
|
129
|
+
self._last_active = -1
|
|
130
|
+
|
|
131
|
+
# ── Capture changes ──────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def capture_write(self, file_path: str, content: str) -> FileChange | None:
|
|
134
|
+
"""Track a file creation/write. The actual write is done by ToolExecutor."""
|
|
135
|
+
path = Path(file_path)
|
|
136
|
+
exists_before = path.exists()
|
|
137
|
+
|
|
138
|
+
# Backup existing file (for undo)
|
|
139
|
+
old_content = None
|
|
140
|
+
if exists_before:
|
|
141
|
+
try:
|
|
142
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
143
|
+
old_content = f.read()
|
|
144
|
+
self._backup(file_path)
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
change = FileChange(
|
|
149
|
+
id=self._next_id,
|
|
150
|
+
file_path=file_path,
|
|
151
|
+
change_type=ChangeType.WRITE if not exists_before else ChangeType.EDIT,
|
|
152
|
+
old_content=old_content,
|
|
153
|
+
new_content=content,
|
|
154
|
+
timestamp=time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if self._dry_run:
|
|
158
|
+
# Dry-run: only save to backup dir, do NOT write to actual file
|
|
159
|
+
dry_path = self._backup_dir / f"dry_{self._next_id}_{Path(file_path).name}"
|
|
160
|
+
dry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
with open(dry_path, "w", encoding="utf-8", errors="replace") as f:
|
|
162
|
+
f.write(content)
|
|
163
|
+
change.file_path = str(dry_path)
|
|
164
|
+
logger.info("[DRY-RUN] Would write: %s → %s", file_path, dry_path)
|
|
165
|
+
# Normal mode: actual file write is done by ToolExecutor, we just track + backup
|
|
166
|
+
|
|
167
|
+
self.changes.append(change)
|
|
168
|
+
self._next_id += 1
|
|
169
|
+
return change
|
|
170
|
+
|
|
171
|
+
def capture_edit(self, file_path: str, old_content: str, new_content: str) -> FileChange | None:
|
|
172
|
+
"""Track a file edit. The actual edit is done by ToolExecutor."""
|
|
173
|
+
if old_content == new_content:
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
path = Path(file_path)
|
|
177
|
+
# Backup before edit (for undo)
|
|
178
|
+
self._backup(file_path)
|
|
179
|
+
|
|
180
|
+
change = FileChange(
|
|
181
|
+
id=self._next_id,
|
|
182
|
+
file_path=file_path,
|
|
183
|
+
change_type=ChangeType.EDIT,
|
|
184
|
+
old_content=old_content,
|
|
185
|
+
new_content=new_content,
|
|
186
|
+
timestamp=time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if self._dry_run:
|
|
190
|
+
# Dry-run: only save to backup dir, do NOT write to actual file
|
|
191
|
+
dry_path = self._backup_dir / f"dry_{self._next_id}_{Path(file_path).name}"
|
|
192
|
+
dry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
with open(dry_path, "w", encoding="utf-8", errors="replace") as f:
|
|
194
|
+
f.write(new_content)
|
|
195
|
+
change.file_path = str(dry_path)
|
|
196
|
+
logger.info("[DRY-RUN] Would edit: %s → %s", file_path, dry_path)
|
|
197
|
+
# Normal mode: actual file edit is done by ToolExecutor, we just track + backup
|
|
198
|
+
|
|
199
|
+
self.changes.append(change)
|
|
200
|
+
self._next_id += 1
|
|
201
|
+
return change
|
|
202
|
+
|
|
203
|
+
# ── Backup ───────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
def _backup(self, file_path: str) -> str:
|
|
206
|
+
"""Create a timestamped backup of a file."""
|
|
207
|
+
path = Path(file_path)
|
|
208
|
+
if not path.exists():
|
|
209
|
+
return ""
|
|
210
|
+
|
|
211
|
+
# Use nanosecond precision to avoid backup collisions when two
|
|
212
|
+
# operations touch the same file within the same second.
|
|
213
|
+
backup_name = f"{path.name}.{time.time_ns()}.bak"
|
|
214
|
+
backup_path = self._backup_dir / backup_name
|
|
215
|
+
shutil.copy2(str(path), str(backup_path))
|
|
216
|
+
self._backups[file_path] = str(backup_path)
|
|
217
|
+
logger.debug("Backed up: %s → %s", file_path, backup_path.name)
|
|
218
|
+
return str(backup_path)
|
|
219
|
+
|
|
220
|
+
# ── Undo ─────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
def undo(self, count: int = 1) -> list[FileChange]:
|
|
223
|
+
"""Undo the last N changes. O(n) using _last_active index."""
|
|
224
|
+
if self._dry_run:
|
|
225
|
+
return []
|
|
226
|
+
|
|
227
|
+
reverted = []
|
|
228
|
+
for _ in range(count):
|
|
229
|
+
if self._last_active < 0:
|
|
230
|
+
self._last_active = len(self.changes) - 1
|
|
231
|
+
while self._last_active >= 0 and self.changes[self._last_active].reverted:
|
|
232
|
+
self._last_active -= 1
|
|
233
|
+
if self._last_active < 0:
|
|
234
|
+
break
|
|
235
|
+
|
|
236
|
+
c = self.changes[self._last_active]
|
|
237
|
+
self._apply_revert(c)
|
|
238
|
+
c.reverted = True
|
|
239
|
+
reverted.append(c)
|
|
240
|
+
self._last_active -= 1
|
|
241
|
+
logger.info("Undid change #%d: %s", c.id, c.file_path)
|
|
242
|
+
|
|
243
|
+
return reverted
|
|
244
|
+
|
|
245
|
+
def _apply_revert(self, c: FileChange) -> None:
|
|
246
|
+
"""Apply revert for a single change."""
|
|
247
|
+
path = Path(c.file_path)
|
|
248
|
+
if c.change_type == ChangeType.WRITE:
|
|
249
|
+
if c.old_content is None:
|
|
250
|
+
if path.exists():
|
|
251
|
+
path.unlink()
|
|
252
|
+
elif path.exists():
|
|
253
|
+
path.write_text(c.old_content, encoding="utf-8", errors="replace")
|
|
254
|
+
elif c.change_type == ChangeType.EDIT:
|
|
255
|
+
if path.exists() and c.old_content is not None:
|
|
256
|
+
path.write_text(c.old_content, encoding="utf-8", errors="replace")
|
|
257
|
+
elif c.change_type == ChangeType.DELETE:
|
|
258
|
+
if c.new_content is not None:
|
|
259
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
path.write_text(c.new_content, encoding="utf-8", errors="replace")
|
|
261
|
+
|
|
262
|
+
def undo_all(self) -> list[FileChange]:
|
|
263
|
+
"""Undo all changes in this session."""
|
|
264
|
+
active = sum(1 for c in self.changes if not c.reverted)
|
|
265
|
+
return self.undo(active)
|
|
266
|
+
|
|
267
|
+
def restore(self, change_id: int) -> FileChange | None:
|
|
268
|
+
"""Re-apply a previously reverted change."""
|
|
269
|
+
for c in self.changes:
|
|
270
|
+
if c.id == change_id and c.reverted:
|
|
271
|
+
path = Path(c.file_path)
|
|
272
|
+
if c.new_content is not None:
|
|
273
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
with open(path, "w", encoding="utf-8", errors="replace") as f:
|
|
275
|
+
f.write(c.new_content)
|
|
276
|
+
c.reverted = False
|
|
277
|
+
logger.info("Restored change #%d: %s", c.id, c.file_path)
|
|
278
|
+
return c
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
# ── List & summary ───────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
def list_changes(self, include_reverted: bool = False) -> list[FileChange]:
|
|
284
|
+
"""List all changes."""
|
|
285
|
+
if include_reverted:
|
|
286
|
+
return list(self.changes)
|
|
287
|
+
return [c for c in self.changes if not c.reverted]
|
|
288
|
+
|
|
289
|
+
def summary(self) -> str:
|
|
290
|
+
"""Multi-line summary of all changes."""
|
|
291
|
+
active = self.list_changes()
|
|
292
|
+
reverted = [c for c in self.changes if c.reverted]
|
|
293
|
+
|
|
294
|
+
lines = []
|
|
295
|
+
lines.append(f"Session: {self.session_id}")
|
|
296
|
+
lines.append(f"Changes: {len(active)} active, {len(reverted)} reverted")
|
|
297
|
+
lines.append(f"Dry-run: {'ON' if self._dry_run else 'OFF'}")
|
|
298
|
+
lines.append("")
|
|
299
|
+
|
|
300
|
+
if active:
|
|
301
|
+
for c in active:
|
|
302
|
+
lines.append(f" {c.summary}")
|
|
303
|
+
else:
|
|
304
|
+
lines.append(" (no active changes)")
|
|
305
|
+
|
|
306
|
+
if reverted:
|
|
307
|
+
lines.append("\n Reverted:")
|
|
308
|
+
for c in reverted:
|
|
309
|
+
lines.append(f" {c.summary}")
|
|
310
|
+
|
|
311
|
+
return "\n".join(lines)
|
|
312
|
+
|
|
313
|
+
def diff_summary(self, last_n: int = 5) -> str:
|
|
314
|
+
"""Show diffs for the last N changes."""
|
|
315
|
+
recent = [c for c in self.changes if not c.reverted][-last_n:]
|
|
316
|
+
if not recent:
|
|
317
|
+
return "(no changes to show)"
|
|
318
|
+
|
|
319
|
+
parts = []
|
|
320
|
+
for c in recent:
|
|
321
|
+
parts.append(f"--- {c.summary} ---")
|
|
322
|
+
parts.append(c.diff[:2000])
|
|
323
|
+
parts.append("")
|
|
324
|
+
return "\n".join(parts)
|
|
325
|
+
|
|
326
|
+
def count_active(self) -> int:
|
|
327
|
+
return sum(1 for c in self.changes if not c.reverted)
|
|
328
|
+
|
|
329
|
+
def count_all(self) -> int:
|
|
330
|
+
return len(self.changes)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
334
|
+
# Session change tracker (global, manages multiple sessions)
|
|
335
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
336
|
+
|
|
337
|
+
class SessionChangeManager:
|
|
338
|
+
"""Manages change trackers across multiple sessions."""
|
|
339
|
+
|
|
340
|
+
def __init__(self, base_dir: str | Path | None = None):
|
|
341
|
+
self._base = Path(base_dir) if base_dir else _default_changes_dir()
|
|
342
|
+
self._base.mkdir(parents=True, exist_ok=True)
|
|
343
|
+
self._trackers: dict[str, ChangeTracker] = {}
|
|
344
|
+
|
|
345
|
+
def get(self, session_id: str) -> ChangeTracker:
|
|
346
|
+
if session_id not in self._trackers:
|
|
347
|
+
backup_dir = self._base / session_id
|
|
348
|
+
self._trackers[session_id] = ChangeTracker(session_id, backup_dir)
|
|
349
|
+
return self._trackers[session_id]
|
|
350
|
+
|
|
351
|
+
def list_sessions(self) -> list[str]:
|
|
352
|
+
"""List sessions that have changes."""
|
|
353
|
+
sessions = []
|
|
354
|
+
if self._base.exists():
|
|
355
|
+
for d in self._base.iterdir():
|
|
356
|
+
if d.is_dir():
|
|
357
|
+
sessions.append(d.name)
|
|
358
|
+
return sorted(sessions, reverse=True)
|
|
359
|
+
|
|
360
|
+
def cleanup_session(self, session_id: str) -> bool:
|
|
361
|
+
"""Remove change tracker and its backups."""
|
|
362
|
+
if session_id in self._trackers:
|
|
363
|
+
del self._trackers[session_id]
|
|
364
|
+
backup_dir = self._base / session_id
|
|
365
|
+
if backup_dir.exists():
|
|
366
|
+
shutil.rmtree(str(backup_dir))
|
|
367
|
+
return True
|
|
368
|
+
return False
|