main
py 1,559 lines 55.3 KB
Raw
1 from __future__ import annotations
2
3 import base64
4 import fnmatch
5 import hashlib
6 import json
7 import os
8 import posixpath
9 import shutil
10 import subprocess
11 import threading
12 import time
13 from dataclasses import dataclass
14 from pathlib import Path
15 from typing import Any, Iterable
16
17 from helpers import files
18 from helpers.localization import Localization
19 from helpers.print_style import PrintStyle
20
21
22 PLUGIN_NAME = "_time_travel"
23 USR_DISPLAY_ROOT = "/a0/usr"
24 SHADOW_DISPLAY_ROOT = "/a0/usr/.time_travel/workspaces"
25 CURRENT_REF = "refs/heads/current"
26 PRESERVED_REF_PREFIX = "refs/a0-time-travel/preserved"
27 METADATA_PREFIX = "A0-Time-Travel-Metadata:"
28 EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
29 MAX_RENDERED_PATCH_BYTES = 1_000_000
30 GIT_TIMEOUT_SECONDS = 20
31 AUTO_SNAPSHOT_DEBOUNCE_SECONDS = 10.0
32 WATCHDOG_ID = "time_travel_usr"
33 WATCHDOG_DEBOUNCE_SECONDS = 1.0
34 SHADOW_REPO_BACKUP_PREFIX = "repo.git.invalid"
35
36 _AUTO_SNAPSHOT_LOCK = threading.RLock()
37 _AUTO_SNAPSHOT_TIMERS: dict[str, threading.Timer] = {}
38 _AUTO_SNAPSHOT_PAYLOADS: dict[str, dict[str, Any]] = {}
39
40 STATUS_LABELS = {
41 "A": "added",
42 "C": "copied",
43 "D": "deleted",
44 "M": "modified",
45 "R": "renamed",
46 "T": "type_changed",
47 "U": "unmerged",
48 "X": "unknown",
49 }
50
51 EXCLUDED_DIR_NAMES = {
52 ".git",
53 ".time_travel",
54 "__pycache__",
55 ".pytest_cache",
56 ".mypy_cache",
57 ".ruff_cache",
58 ".cache",
59 ".tox",
60 ".nox",
61 ".venv",
62 "venv",
63 "env",
64 "node_modules",
65 "bower_components",
66 "dist",
67 "build",
68 ".next",
69 ".nuxt",
70 ".svelte-kit",
71 ".turbo",
72 "coverage",
73 "htmlcov",
74 ".parcel-cache",
75 }
76
77 EXCLUDED_DIR_PATTERNS = {
78 "*.egg-info",
79 }
80
81 EXCLUDED_FILE_PATTERNS = {
82 "*.pyc",
83 "*.pyo",
84 "*.pyd",
85 ".env",
86 ".env.*",
87 "*.class",
88 }
89
90 USR_ROOT_EXCLUDED_DIR_NAMES = {
91 "plugins",
92 }
93
94 SAFE_A0PROJ_FILES = {
95 ".a0proj/project.json",
96 ".a0proj/agents.json",
97 }
98
99 SAFE_A0PROJ_DIRS = {
100 ".a0proj/instructions/",
101 ".a0proj/knowledge/",
102 ".a0proj/skills/",
103 }
104
105 SAFE_PLUGIN_ASSET_NAMES = {
106 "config.json",
107 "presets.yaml",
108 ".toggle-0",
109 ".toggle-1",
110 }
111
112
113 class TimeTravelError(RuntimeError):
114 """Base error for user-visible Time Travel failures."""
115
116
117 class WorkspaceRejectedError(TimeTravelError):
118 """Raised when a workspace is outside the /a0/usr kernel boundary."""
119
120
121 class TimeTravelConflictError(TimeTravelError):
122 """Raised when an operation cannot safely mutate the workspace."""
123
124
125 class GitCommandError(TimeTravelError):
126 def __init__(self, message: str, *, stdout: str = "", stderr: str = "") -> None:
127 super().__init__(message)
128 self.stdout = stdout
129 self.stderr = stderr
130
131
132 @dataclass(frozen=True)
133 class WorkspaceInfo:
134 id: str
135 display_path: str
136 real_path: Path
137 shadow_path: Path
138 repo_git_path: Path
139 context_id: str = ""
140 project_name: str = ""
141
142 def public(self) -> dict[str, Any]:
143 return {
144 "id": self.id,
145 "path": self.display_path,
146 "display_path": self.display_path,
147 "real_path": str(self.real_path),
148 "shadow_path": normalize_display_path(str(self.shadow_path)),
149 "repo_git_path": normalize_display_path(str(self.repo_git_path)),
150 "context_id": self.context_id,
151 "project_name": self.project_name,
152 "available": True,
153 "locked": False,
154 }
155
156
157 @dataclass(frozen=True)
158 class SnapshotResult:
159 created: bool
160 hash: str
161 short_hash: str
162 tree_hash: str
163 message: str
164 files: list[dict[str, Any]]
165 metadata: dict[str, Any]
166
167
168 def now_iso() -> str:
169 return Localization.get().now_iso()
170
171
172 def normalize_display_path(path: str) -> str:
173 raw = str(path or "").strip()
174 if not raw:
175 return ""
176 if raw.startswith("/a0"):
177 normalized = posixpath.normpath(raw.replace("\\", "/"))
178 return "/" if normalized == "." else normalized
179
180 resolved = Path(raw).expanduser().resolve(strict=False)
181 normalized = files.normalize_a0_path(str(resolved))
182 if normalized.startswith("/a0"):
183 return posixpath.normpath(normalized.replace("\\", "/"))
184 return str(resolved)
185
186
187 def is_inside_usr_display(display_path: str) -> bool:
188 normalized = normalize_display_path(display_path)
189 return normalized == USR_DISPLAY_ROOT or normalized.startswith(USR_DISPLAY_ROOT + "/")
190
191
192 def workspace_id_for(display_path: str) -> str:
193 normalized = canonical_workspace_display_path(display_path).rstrip("/")
194 return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:32]
195
196
197 def real_path_for_display(display_path: str) -> Path:
198 normalized = normalize_display_path(display_path)
199 if normalized == "/a0":
200 return Path(files.get_base_dir()).resolve(strict=False)
201 if normalized.startswith("/a0/"):
202 return Path(files.get_base_dir(), normalized.removeprefix("/a0/")).resolve(strict=False)
203 return Path(normalized).expanduser().resolve(strict=False)
204
205
206 def canonical_workspace_display_path(display_path: str) -> str:
207 normalized = normalize_display_path(display_path)
208 real_path = real_path_for_display(normalized)
209 canonical = normalize_display_path(str(real_path))
210 return (canonical if canonical.startswith("/a0") else normalized).rstrip("/") or canonical
211
212
213 def configured_workdir_display_path() -> str:
214 from helpers import settings
215
216 configured = str(settings.get_settings().get("workdir_path") or "")
217 fallback = files.normalize_a0_path(files.get_abs_path("usr/workdir"))
218 return canonical_workspace_display_path(configured or fallback)
219
220
221 def _workspace_option(
222 *,
223 kind: str,
224 display_path: str,
225 label: str,
226 name: str = "",
227 title: str = "",
228 project_name: str = "",
229 color: str = "",
230 ) -> dict[str, Any]:
231 normalized = canonical_workspace_display_path(display_path)
232 available = is_inside_usr_display(normalized)
233 error = (
234 "" if available else "Time Travel is only available for workspaces inside /a0/usr."
235 )
236 return {
237 "id": workspace_id_for(normalized),
238 "kind": kind,
239 "name": name,
240 "title": title or label,
241 "label": label,
242 "display_path": normalized.rstrip("/") or normalized,
243 "path": normalized.rstrip("/") or normalized,
244 "project_name": project_name,
245 "color": color,
246 "available": available,
247 "locked": not available,
248 "error": error,
249 }
250
251
252 def list_selectable_workspaces(
253 context_id: str = "",
254 *,
255 context_loader=None,
256 ) -> dict[str, Any]:
257 from helpers import projects
258
259 context_id = str(context_id or "").strip()
260 workspaces: list[dict[str, Any]] = []
261 seen_ids: set[str] = set()
262
263 def add_workspace(option: dict[str, Any]) -> None:
264 option_id = str(option.get("id") or "")
265 if not option_id or option_id in seen_ids:
266 return
267 seen_ids.add(option_id)
268 workspaces.append(option)
269
270 add_workspace(
271 _workspace_option(
272 kind="workdir",
273 display_path=configured_workdir_display_path(),
274 label="User working directory",
275 name="workdir",
276 title="User working directory",
277 )
278 )
279
280 for project in projects.get_active_projects_list() or []:
281 project_name = str(project.get("name") or "").strip()
282 if not project_name:
283 continue
284 title = str(project.get("title") or project_name)
285 add_workspace(
286 _workspace_option(
287 kind="project",
288 display_path=files.normalize_a0_path(
289 projects.get_project_folder(project_name)
290 ),
291 label=title,
292 name=project_name,
293 title=title,
294 project_name=project_name,
295 color=str(project.get("color") or ""),
296 )
297 )
298
299 default_workspace_id = ""
300 try:
301 default_workspace_id = _resolve_context_workspace(
302 context_id,
303 context_loader=context_loader,
304 ).id
305 except TimeTravelError:
306 default_workspace_id = ""
307
308 if default_workspace_id not in seen_ids:
309 default_workspace_id = ""
310 if not default_workspace_id and workspaces:
311 default_workspace_id = str(workspaces[0].get("id") or "")
312
313 return {
314 "context_id": context_id,
315 "workspaces": workspaces,
316 "default_workspace_id": default_workspace_id,
317 }
318
319
320 def _selectable_workspace_by_id(
321 workspace_id: str,
322 context_id: str = "",
323 *,
324 context_loader=None,
325 ) -> dict[str, Any] | None:
326 wanted = str(workspace_id or "").strip()
327 if not wanted:
328 return None
329 for workspace in list_selectable_workspaces(
330 context_id,
331 context_loader=context_loader,
332 )["workspaces"]:
333 if str(workspace.get("id") or "") == wanted:
334 return workspace
335 return None
336
337
338 def _resolve_context_workspace(context_id: str = "", *, context_loader=None) -> WorkspaceInfo:
339 from helpers import projects, settings
340
341 context_id = str(context_id or "").strip()
342 project_name = ""
343 display_path = ""
344
345 if context_id:
346 context = context_loader(context_id) if context_loader else None
347 if context is not None:
348 project_name = projects.get_context_project_name(context) or ""
349 if project_name:
350 display_path = files.normalize_a0_path(projects.get_project_folder(project_name))
351
352 if not display_path:
353 configured = str(settings.get_settings().get("workdir_path") or "")
354 display_path = configured or files.normalize_a0_path(files.get_abs_path("usr/workdir"))
355
356 normalized = canonical_workspace_display_path(display_path)
357 if not is_inside_usr_display(normalized):
358 raise WorkspaceRejectedError("Time Travel is only available for workspaces inside /a0/usr.")
359
360 workspace_id = workspace_id_for(normalized)
361 shadow_display = f"{SHADOW_DISPLAY_ROOT}/{workspace_id}"
362 shadow_path = real_path_for_display(shadow_display)
363 return WorkspaceInfo(
364 id=workspace_id,
365 display_path=normalized.rstrip("/") or normalized,
366 real_path=real_path_for_display(normalized),
367 shadow_path=shadow_path,
368 repo_git_path=shadow_path / "repo.git",
369 context_id=context_id,
370 project_name=project_name,
371 )
372
373
374 def resolve_workspace(
375 context_id: str = "",
376 *,
377 workspace_id: str = "",
378 context_loader=None,
379 ) -> WorkspaceInfo:
380 workspace_id = str(workspace_id or "").strip()
381 if not workspace_id:
382 return _resolve_context_workspace(context_id, context_loader=context_loader)
383
384 selected = _selectable_workspace_by_id(
385 workspace_id,
386 context_id,
387 context_loader=context_loader,
388 )
389 if not selected:
390 raise WorkspaceRejectedError("Selected Time Travel workspace is not available.")
391 if selected.get("locked") or selected.get("available") is False:
392 raise WorkspaceRejectedError(
393 str(selected.get("error") or "Selected Time Travel workspace is not available.")
394 )
395
396 return _workspace_from_display(
397 str(selected.get("display_path") or selected.get("path") or ""),
398 project_name=str(selected.get("project_name") or ""),
399 context_id=str(context_id or "").strip(),
400 )
401
402
403 def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None:
404 normalized = canonical_workspace_display_path(path_hint)
405 if not is_inside_usr_display(normalized):
406 return None
407
408 parts = [part for part in normalized.split("/") if part]
409 if len(parts) >= 4 and parts[0] == "a0" and parts[1] == "usr" and parts[2] == "projects":
410 project_display = f"/a0/usr/projects/{parts[3]}"
411 return _workspace_from_display(project_display, project_name=parts[3])
412
413 workdir_display = configured_workdir_display_path()
414 if normalized == workdir_display or normalized.startswith(workdir_display.rstrip("/") + "/"):
415 return _workspace_from_display(workdir_display)
416
417 return None
418
419
420 def _workspace_from_display(display_path: str, *, project_name: str = "", context_id: str = "") -> WorkspaceInfo:
421 normalized = canonical_workspace_display_path(display_path)
422 if not is_inside_usr_display(normalized):
423 raise WorkspaceRejectedError("Time Travel is only available for workspaces inside /a0/usr.")
424 workspace_id = workspace_id_for(normalized)
425 shadow_path = real_path_for_display(f"{SHADOW_DISPLAY_ROOT}/{workspace_id}")
426 return WorkspaceInfo(
427 id=workspace_id,
428 display_path=normalized.rstrip("/") or normalized,
429 real_path=real_path_for_display(normalized),
430 shadow_path=shadow_path,
431 repo_git_path=shadow_path / "repo.git",
432 context_id=context_id,
433 project_name=project_name,
434 )
435
436
437 def unavailable_payload(context_id: str, error: str) -> dict[str, Any]:
438 return {
439 "ok": True,
440 "context_id": context_id,
441 "workspace": {
442 "available": False,
443 "locked": True,
444 "path": "",
445 "display_path": "",
446 "error": error,
447 },
448 "current_hash": "",
449 "present": clean_summary(),
450 "commits": [],
451 "has_more": False,
452 }
453
454
455 def clean_summary() -> dict[str, Any]:
456 return {
457 "dirty": False,
458 "files_count": 0,
459 "additions": 0,
460 "deletions": 0,
461 "files": [],
462 }
463
464
465 def snapshot_for_agent(
466 agent: Any,
467 *,
468 trigger: str,
469 metadata: dict[str, Any] | None = None,
470 debounced: bool = True,
471 ) -> SnapshotResult | None:
472 if not agent:
473 return None
474
475 context_id = str(getattr(getattr(agent, "context", None), "id", "") or "")
476 try:
477 workspace = resolve_workspace(context_id, context_loader=lambda _ctxid: agent.context)
478 full_metadata = _agent_metadata(agent, metadata)
479 if debounced:
480 schedule_debounced_snapshot(
481 workspace,
482 trigger=trigger,
483 metadata=full_metadata,
484 changed_path_hints=_extract_changed_path_hints(full_metadata),
485 )
486 return None
487 return TimeTravelService(workspace).snapshot(trigger=trigger, metadata=full_metadata)
488 except WorkspaceRejectedError:
489 return None
490 except Exception as exc:
491 PrintStyle.error(f"Time Travel snapshot failed: {exc}")
492 return None
493
494
495 def snapshot_for_path_hint(
496 path_hint: str,
497 *,
498 trigger: str,
499 metadata: dict[str, Any] | None = None,
500 debounced: bool = True,
501 ) -> SnapshotResult | None:
502 try:
503 workspace = resolve_workspace_for_path_hint(path_hint)
504 if workspace is None:
505 return None
506 full_metadata = metadata or {}
507 if debounced:
508 schedule_debounced_snapshot(
509 workspace,
510 trigger=trigger,
511 metadata=full_metadata,
512 changed_path_hints=_extract_changed_path_hints(full_metadata),
513 )
514 return None
515 return TimeTravelService(workspace).snapshot(trigger=trigger, metadata=full_metadata)
516 except Exception as exc:
517 PrintStyle.error(f"Time Travel file-browser snapshot failed: {exc}")
518 return None
519
520
521 def register_watchdogs() -> None:
522 from helpers import watchdog
523
524 root = real_path_for_display(USR_DISPLAY_ROOT)
525 if not root.exists() or not root.is_dir():
526 return
527
528 watchdog.add_watchdog(
529 id=WATCHDOG_ID,
530 roots=[str(root)],
531 patterns=["**/*"],
532 ignore_patterns=[
533 "**/.git",
534 "**/.git/**",
535 "**/.time_travel",
536 "**/.time_travel/**",
537 "**/__pycache__",
538 "**/__pycache__/**",
539 "**/*.pyc",
540 "**/.pytest_cache/**",
541 "**/.mypy_cache/**",
542 "**/.ruff_cache/**",
543 "**/.cache/**",
544 "**/node_modules/**",
545 "**/.venv/**",
546 "**/venv/**",
547 "**/dist/**",
548 "**/build/**",
549 ],
550 events=["create", "modify", "delete", "move"],
551 debounce=WATCHDOG_DEBOUNCE_SECONDS,
552 handler=_handle_usr_watchdog_events,
553 )
554
555
556 def schedule_debounced_snapshot(
557 workspace: WorkspaceInfo,
558 *,
559 trigger: str,
560 metadata: dict[str, Any] | None = None,
561 changed_path_hints: list[str] | None = None,
562 delay: float | None = None,
563 ) -> None:
564 clean_metadata = dict(metadata or {})
565 metadata_hints = _extract_changed_path_hints(clean_metadata)
566 clean_metadata.pop("changed_path_hints", None)
567 hints = _merge_hints(metadata_hints, changed_path_hints or [])
568 delay_seconds = AUTO_SNAPSHOT_DEBOUNCE_SECONDS if delay is None else max(0.0, float(delay))
569 with _AUTO_SNAPSHOT_LOCK:
570 payload = _AUTO_SNAPSHOT_PAYLOADS.get(workspace.id)
571 if payload is None:
572 payload = {
573 "workspace": workspace,
574 "trigger": trigger,
575 "metadata": clean_metadata,
576 "changed_path_hints": hints,
577 }
578 _AUTO_SNAPSHOT_PAYLOADS[workspace.id] = payload
579 timer = threading.Timer(delay_seconds, _flush_debounced_snapshot, args=(workspace.id,))
580 timer.daemon = True
581 _AUTO_SNAPSHOT_TIMERS[workspace.id] = timer
582 timer.start()
583 return
584
585 payload["trigger"] = trigger
586 payload["metadata"] = {**payload.get("metadata", {}), **clean_metadata}
587 payload["changed_path_hints"] = _merge_hints(
588 payload.get("changed_path_hints", []),
589 hints,
590 )
591
592
593 def flush_debounced_snapshots() -> None:
594 with _AUTO_SNAPSHOT_LOCK:
595 workspace_ids = list(_AUTO_SNAPSHOT_PAYLOADS)
596 for workspace_id in workspace_ids:
597 timer = _AUTO_SNAPSHOT_TIMERS.pop(workspace_id, None)
598 timer and timer.cancel()
599 for workspace_id in workspace_ids:
600 _flush_debounced_snapshot(workspace_id)
601
602
603 def clear_debounced_snapshots() -> None:
604 with _AUTO_SNAPSHOT_LOCK:
605 timers = list(_AUTO_SNAPSHOT_TIMERS.values())
606 _AUTO_SNAPSHOT_TIMERS.clear()
607 _AUTO_SNAPSHOT_PAYLOADS.clear()
608 for timer in timers:
609 timer.cancel()
610
611
612 def _flush_debounced_snapshot(workspace_id: str) -> None:
613 with _AUTO_SNAPSHOT_LOCK:
614 _AUTO_SNAPSHOT_TIMERS.pop(workspace_id, None)
615 payload = _AUTO_SNAPSHOT_PAYLOADS.pop(workspace_id, None)
616 if not payload:
617 return
618
619 try:
620 workspace = payload["workspace"]
621 if not workspace.real_path.is_dir():
622 return
623 TimeTravelService(workspace).snapshot(
624 trigger=str(payload.get("trigger") or "watchdog"),
625 metadata=payload.get("metadata") or {},
626 changed_path_hints=payload.get("changed_path_hints") or None,
627 )
628 except WorkspaceRejectedError:
629 return
630 except Exception as exc:
631 PrintStyle.error(f"Time Travel debounced snapshot failed: {exc}")
632
633
634 def _handle_usr_watchdog_events(items: list[Any]) -> None:
635 by_workspace: dict[str, tuple[WorkspaceInfo, list[str]]] = {}
636 for path, _event in items:
637 display_path = normalize_display_path(str(path or ""))
638 if not _is_watchdog_snapshot_candidate(display_path):
639 continue
640 workspace = resolve_workspace_for_path_hint(display_path)
641 if workspace is None:
642 continue
643 hints = by_workspace.setdefault(workspace.id, (workspace, []))[1]
644 hints.append(display_path)
645
646 for workspace, hints in by_workspace.values():
647 schedule_debounced_snapshot(
648 workspace,
649 trigger="watchdog",
650 metadata={
651 "source": "watchdog",
652 "changed_path_hints": _merge_hints(hints),
653 },
654 changed_path_hints=hints,
655 )
656
657
658 def _agent_metadata(agent: Any, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
659 from helpers import projects
660
661 result = dict(metadata or {})
662 context = getattr(agent, "context", None)
663 if context is not None:
664 result.setdefault("context_id", str(getattr(context, "id", "") or ""))
665 project_name = projects.get_context_project_name(context) or ""
666 if project_name:
667 result.setdefault("project_name", project_name)
668 tool = getattr(getattr(agent, "loop_data", None), "current_tool", None)
669 if tool is not None:
670 result.setdefault("tool_name", str(getattr(tool, "name", "") or ""))
671 args = getattr(tool, "args", None)
672 if isinstance(args, dict):
673 result.setdefault("runtime", str(args.get("runtime") or ""))
674 log = getattr(tool, "log", None)
675 if log is not None:
676 result.setdefault("log_item_id", str(getattr(log, "id", "") or ""))
677 result.setdefault("log_item_no", getattr(log, "no", None))
678 return {key: value for key, value in result.items() if value not in (None, "")}
679
680
681 def _extract_changed_path_hints(metadata: dict[str, Any]) -> list[str]:
682 hints = metadata.get("changed_path_hints")
683 if not isinstance(hints, list):
684 return []
685 return [str(path) for path in hints if path]
686
687
688 def _merge_hints(*groups: list[str]) -> list[str]:
689 merged: list[str] = []
690 seen: set[str] = set()
691 for group in groups:
692 for path in group:
693 normalized = normalize_display_path(str(path or ""))
694 if not normalized or normalized in seen:
695 continue
696 merged.append(normalized)
697 seen.add(normalized)
698 return merged
699
700
701 def _is_watchdog_snapshot_candidate(display_path: str) -> bool:
702 normalized = normalize_display_path(display_path)
703 if not is_inside_usr_display(normalized):
704 return False
705 if normalized == "/a0/usr/plugins" or normalized.startswith("/a0/usr/plugins/"):
706 return False
707 parts = [part for part in normalized.split("/") if part]
708 return ".git" not in parts and ".time_travel" not in parts
709
710
711 class TimeTravelService:
712 def __init__(self, workspace: WorkspaceInfo):
713 self.workspace = workspace
714
715 def ensure_repo(self) -> None:
716 self.workspace.shadow_path.mkdir(parents=True, exist_ok=True)
717 if not self._shadow_repo_valid():
718 self._repair_shadow_repo_head()
719 if not self._shadow_repo_valid():
720 self._initialize_shadow_repo(quarantine_existing=True)
721 self._ensure_current_head_ref()
722
723 self._git("config", "user.name", "Agent Zero Time Travel")
724 self._git("config", "user.email", "time-travel@agent-zero.local")
725 self._git("config", "core.autocrlf", "false")
726 self._git("config", "core.filemode", "true")
727
728 def current_hash(self) -> str:
729 self.ensure_repo()
730 completed = self._git("rev-parse", "--verify", "HEAD", check=False)
731 return completed.stdout.strip() if completed.returncode == 0 else ""
732
733 def current_short_hash(self) -> str:
734 current = self.current_hash()
735 return current[:12] if current else ""
736
737 def snapshot(
738 self,
739 *,
740 trigger: str = "manual",
741 message: str = "",
742 metadata: dict[str, Any] | None = None,
743 changed_path_hints: list[str] | None = None,
744 ) -> SnapshotResult:
745 self._ensure_workspace_dir()
746 self.ensure_repo()
747 previous_hash = self.current_hash()
748 tree_hash, included_paths = self._stage_current_tree()
749
750 if previous_hash and self._commit_tree(previous_hash) == tree_hash:
751 return SnapshotResult(
752 created=False,
753 hash=previous_hash,
754 short_hash=previous_hash[:12],
755 tree_hash=tree_hash,
756 message=message or self._default_snapshot_message(trigger),
757 files=[],
758 metadata=self._metadata(trigger, metadata, changed_path_hints),
759 )
760
761 if not previous_hash and not included_paths:
762 return SnapshotResult(
763 created=False,
764 hash="",
765 short_hash="",
766 tree_hash=tree_hash,
767 message=message or self._default_snapshot_message(trigger),
768 files=[],
769 metadata=self._metadata(trigger, metadata, changed_path_hints),
770 )
771
772 full_metadata = self._metadata(trigger, metadata, changed_path_hints)
773 commit_message = self._commit_message(message or self._default_snapshot_message(trigger), full_metadata)
774 args = ["commit-tree", tree_hash]
775 if previous_hash:
776 args.extend(["-p", previous_hash])
777 args.extend(["-F", "-"])
778 env = self._git_env()
779 if timestamp := str(full_metadata.get("timestamp") or ""):
780 env["GIT_AUTHOR_DATE"] = timestamp
781 env["GIT_COMMITTER_DATE"] = timestamp
782 commit = self._git(*args, input=commit_message, env=env).stdout.strip()
783 self._git("update-ref", "HEAD", commit)
784 diff_base = previous_hash or EMPTY_TREE
785 return SnapshotResult(
786 created=True,
787 hash=commit,
788 short_hash=commit[:12],
789 tree_hash=tree_hash,
790 message=message or self._default_snapshot_message(trigger),
791 files=self.diff_files(diff_base, commit),
792 metadata=full_metadata,
793 )
794
795 def history_list(self, *, limit: int = 100, offset: int = 0, file_filter: str = "") -> dict[str, Any]:
796 self._ensure_workspace_dir()
797 self.ensure_repo()
798 limit = min(max(int(limit or 100), 1), 200)
799 offset = max(int(offset or 0), 0)
800 file_filter = str(file_filter or "").strip().lower()
801 current = self.current_hash()
802 present = self.present_summary()
803
804 all_hashes = self._rev_list_all()
805 if file_filter:
806 all_hashes = [
807 commit_hash
808 for commit_hash in all_hashes
809 if any(
810 file_filter in str(item.get("path") or "").lower()
811 or file_filter in str(item.get("old_path") or "").lower()
812 for item in self.commit_files(commit_hash)
813 )
814 ]
815
816 window = all_hashes[offset : offset + limit + 1]
817 visible = window[:limit]
818 return {
819 "ok": True,
820 "context_id": self.workspace.context_id,
821 "workspace": self.workspace.public(),
822 "current_hash": current,
823 "present": present,
824 "commits": [self.commit_object(commit_hash, current_hash=current) for commit_hash in visible],
825 "has_more": len(window) > limit,
826 }
827
828 def history_diff(self, *, commit_hash: str, path: str, mode: str = "commit") -> dict[str, Any]:
829 self.ensure_repo()
830 path = self._safe_rel_path(path)
831 mode = str(mode or "commit").strip().lower()
832
833 if mode in {"present", "current"}:
834 base = self.current_hash() or EMPTY_TREE
835 target, _paths = self._current_tree()
836 else:
837 commit_hash = self._validate_commit(commit_hash)
838 base = self._first_parent(commit_hash) or EMPTY_TREE
839 target = commit_hash
840
841 return self._patch_payload(base, target, path)
842
843 def preview(self, *, operation: str, commit_hash: str) -> dict[str, Any]:
844 self.ensure_repo()
845 operation = str(operation or "").strip().lower()
846 commit_hash = self._validate_commit(commit_hash)
847 current = self.current_hash() or EMPTY_TREE
848
849 if operation == "travel":
850 base = current
851 target = commit_hash
852 elif operation == "revert":
853 base = commit_hash
854 target = self._first_parent(commit_hash) or EMPTY_TREE
855 else:
856 raise TimeTravelError("Unsupported preview operation.")
857
858 files_changed = self.diff_files(base, target)
859 previews = []
860 for item in files_changed[:12]:
861 rel_path = str(item.get("path") or item.get("old_path") or "")
862 if not rel_path:
863 continue
864 previews.append(self._patch_payload(base, target, rel_path))
865
866 return {
867 "ok": True,
868 "operation": operation,
869 "commit_hash": commit_hash,
870 "short_hash": commit_hash[:12],
871 "files": files_changed,
872 "previews": previews,
873 }
874
875 def travel(self, *, commit_hash: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
876 self._ensure_workspace_dir()
877 self.ensure_repo()
878 target = self._validate_commit(commit_hash)
879 before = self.snapshot(trigger="before_travel", metadata=metadata or {})
880 previous = self.current_hash()
881 if previous:
882 self._preserve_ref(previous, reason="travel")
883 affected = self.diff_files(previous or EMPTY_TREE, target)
884 self._apply_commit_tree(previous or EMPTY_TREE, target, affected)
885 self._git("update-ref", "HEAD", target)
886 return {
887 "ok": True,
888 "operation": "travel",
889 "current_hash": target,
890 "previous_hash": previous,
891 "preserved_hash": previous,
892 "auto_snapshot": _snapshot_public(before),
893 "affected_files": affected,
894 }
895
896 def revert(self, *, commit_hash: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
897 self._ensure_workspace_dir()
898 self.ensure_repo()
899 target = self._validate_commit(commit_hash)
900 before = self.snapshot(trigger="before_revert", metadata=metadata or {})
901 parent = self._first_parent(target) or EMPTY_TREE
902 patch = self._git_bytes("diff", "--binary", parent, target).stdout
903 if patch:
904 checked = self._git_bytes("apply", "--reverse", "--check", "--binary", "--whitespace=nowarn", input=patch, check=False)
905 if checked.returncode != 0:
906 raise TimeTravelConflictError(_compact_git_error(checked.stderr.decode("utf-8", "replace")))
907 applied = self._git_bytes("apply", "--reverse", "--binary", "--whitespace=nowarn", input=patch, check=False)
908 if applied.returncode != 0:
909 raise TimeTravelConflictError(_compact_git_error(applied.stderr.decode("utf-8", "replace")))
910
911 after = self.snapshot(
912 trigger="revert",
913 message=f"Revert {target[:12]}",
914 metadata={
915 **(metadata or {}),
916 "reverted_commit": target,
917 },
918 )
919 return {
920 "ok": True,
921 "operation": "revert",
922 "current_hash": after.hash,
923 "auto_snapshot": _snapshot_public(before),
924 "snapshot": _snapshot_public(after),
925 "affected_files": after.files,
926 }
927
928 def present_summary(self) -> dict[str, Any]:
929 self.ensure_repo()
930 current_tree, _paths = self._current_tree()
931 current = self.current_hash()
932 base = current or EMPTY_TREE
933 base_tree = self._commit_tree(current) if current else EMPTY_TREE
934 if base_tree == current_tree:
935 return clean_summary()
936 changed = self.diff_files(base, current_tree)
937 return {
938 "dirty": bool(changed),
939 "files_count": len(changed),
940 "additions": sum(int(item.get("additions") or 0) for item in changed),
941 "deletions": sum(int(item.get("deletions") or 0) for item in changed),
942 "files": changed,
943 }
944
945 def commit_object(self, commit_hash: str, *, current_hash: str = "") -> dict[str, Any]:
946 commit_hash = self._validate_commit(commit_hash)
947 show = self._git("show", "-s", "--format=%H%x00%h%x00%cI%x00%s%x00%B", commit_hash).stdout
948 parts = show.split("\0", 4)
949 full_hash = parts[0].strip()
950 short_hash = parts[1].strip() if len(parts) > 1 else full_hash[:12]
951 timestamp = parts[2].strip() if len(parts) > 2 else ""
952 subject = parts[3].strip() if len(parts) > 3 else ""
953 body = parts[4] if len(parts) > 4 else ""
954 metadata = self._parse_metadata(body)
955 return {
956 "hash": full_hash,
957 "short_hash": short_hash,
958 "timestamp": timestamp,
959 "message": subject,
960 "is_current": bool(current_hash and full_hash == current_hash),
961 "metadata": metadata,
962 "files": self.commit_files(full_hash),
963 }
964
965 def commit_files(self, commit_hash: str) -> list[dict[str, Any]]:
966 commit_hash = self._validate_commit(commit_hash)
967 parent = self._first_parent(commit_hash) or EMPTY_TREE
968 return self.diff_files(parent, commit_hash)
969
970 def diff_files(self, base: str, target: str, *, path_filter: str = "") -> list[dict[str, Any]]:
971 args = ["diff", "--name-status", "-z", "--find-renames", base, target]
972 path_filter = str(path_filter or "").strip()
973 if path_filter:
974 args.extend(["--", path_filter])
975 output = self._git(*args).stdout
976 entries = _parse_name_status(output)
977 result: list[dict[str, Any]] = []
978 for entry in entries:
979 path = entry["path"]
980 old_path = entry.get("old_path", "")
981 additions, deletions, binary = self._numstat(base, target, [p for p in (old_path, path) if p])
982 action = STATUS_LABELS.get(entry["status"], entry["status"].lower())
983 result.append(
984 {
985 "path": path,
986 "old_path": old_path,
987 "status": action,
988 "action": action,
989 "additions": additions,
990 "deletions": deletions,
991 "binary": binary,
992 }
993 )
994 return result
995
996 def _current_tree(self) -> tuple[str, list[str]]:
997 return self._stage_current_tree()
998
999 def _stage_current_tree(self) -> tuple[str, list[str]]:
1000 self.ensure_repo()
1001 self._git("read-tree", "--empty")
1002 paths = list(iter_snapshot_paths(self.workspace.real_path, display_path=self.workspace.display_path))
1003 if paths:
1004 payload = "\0".join(paths).encode("utf-8") + b"\0"
1005 self._git_bytes(
1006 "add",
1007 "-f",
1008 "-A",
1009 "--pathspec-from-file=-",
1010 "--pathspec-file-nul",
1011 input=payload,
1012 )
1013 tree_hash = self._git("write-tree").stdout.strip()
1014 return tree_hash, paths
1015
1016 def _apply_commit_tree(self, base: str, target: str, affected: list[dict[str, Any]]) -> None:
1017 delete_paths: list[str] = []
1018 write_paths: list[str] = []
1019 for item in affected:
1020 action = str(item.get("action") or item.get("status") or "")
1021 old_path = str(item.get("old_path") or "")
1022 path = str(item.get("path") or "")
1023 if old_path and old_path != path:
1024 delete_paths.append(old_path)
1025 if action == "deleted":
1026 delete_paths.append(path)
1027 else:
1028 write_paths.append(path)
1029
1030 for rel_path in sorted(set(delete_paths), key=lambda value: value.count("/"), reverse=True):
1031 self._delete_workspace_entry(rel_path)
1032 for rel_path in sorted(set(write_paths)):
1033 self._materialize_tree_path(target, rel_path)
1034 self._prune_empty_dirs()
1035
1036 def _materialize_tree_path(self, commit_hash: str, rel_path: str) -> None:
1037 rel_path = self._safe_rel_path(rel_path)
1038 entry = self._tree_entry(commit_hash, rel_path)
1039 if entry is None:
1040 self._delete_workspace_entry(rel_path)
1041 return
1042 mode, obj_type, obj_hash = entry
1043 if obj_type != "blob":
1044 return
1045 target_path = self._workspace_child(rel_path)
1046 data = self._git_bytes("cat-file", "-p", obj_hash).stdout
1047 self._prepare_parent(target_path)
1048 if target_path.exists() or target_path.is_symlink():
1049 self._remove_for_replacement(target_path)
1050 if mode == "120000":
1051 os.symlink(data.decode("utf-8", errors="replace"), target_path)
1052 else:
1053 target_path.write_bytes(data)
1054 if mode == "100755":
1055 target_path.chmod(0o755)
1056
1057 def _delete_workspace_entry(self, rel_path: str) -> None:
1058 rel_path = self._safe_rel_path(rel_path)
1059 target_path = self._workspace_child(rel_path)
1060 if target_path.is_symlink() or target_path.is_file():
1061 target_path.unlink()
1062 elif target_path.exists():
1063 if target_path.is_dir() and not any(target_path.iterdir()):
1064 target_path.rmdir()
1065 else:
1066 raise TimeTravelConflictError(
1067 f"Cannot safely replace non-empty directory: {rel_path}"
1068 )
1069
1070 def _prepare_parent(self, target_path: Path) -> None:
1071 current = self.workspace.real_path
1072 rel_parts = target_path.relative_to(self.workspace.real_path).parts[:-1]
1073 for part in rel_parts:
1074 current = current / part
1075 if current.is_symlink() or current.is_file():
1076 self._remove_for_replacement(current)
1077 current.mkdir(exist_ok=True)
1078
1079 def _remove_for_replacement(self, target_path: Path) -> None:
1080 if target_path.is_symlink() or target_path.is_file():
1081 target_path.unlink()
1082 return
1083 if target_path.is_dir():
1084 if any(target_path.iterdir()):
1085 raise TimeTravelConflictError(
1086 f"Cannot safely replace non-empty directory: {self._rel_from_workspace(target_path)}"
1087 )
1088 target_path.rmdir()
1089
1090 def _prune_empty_dirs(self) -> None:
1091 for root, dirs, _filenames in os.walk(self.workspace.real_path, topdown=False, followlinks=False):
1092 root_path = Path(root)
1093 if root_path == self.workspace.real_path:
1094 continue
1095 if not is_snapshot_candidate(root_path.relative_to(self.workspace.real_path).as_posix(), is_dir=True):
1096 continue
1097 try:
1098 root_path.rmdir()
1099 except OSError:
1100 pass
1101
1102 def _tree_entry(self, commit_hash: str, rel_path: str) -> tuple[str, str, str] | None:
1103 completed = self._git("ls-tree", "-z", commit_hash, "--", rel_path, check=False)
1104 if completed.returncode != 0 or not completed.stdout:
1105 return None
1106 record = completed.stdout.split("\0", 1)[0]
1107 meta, _sep, _name = record.partition("\t")
1108 parts = meta.split()
1109 if len(parts) < 3:
1110 return None
1111 return parts[0], parts[1], parts[2]
1112
1113 def _patch_payload(self, base: str, target: str, path: str) -> dict[str, Any]:
1114 path = self._safe_rel_path(path)
1115 additions, deletions, binary = self._numstat(base, target, [path])
1116 completed = self._git_bytes("diff", "--binary", "--patch", base, target, "--", path, check=False)
1117 data = completed.stdout or b""
1118 too_large = len(data) > MAX_RENDERED_PATCH_BYTES
1119 rendered = data[:MAX_RENDERED_PATCH_BYTES].decode("utf-8", errors="replace")
1120 return {
1121 "ok": completed.returncode == 0,
1122 "path": path,
1123 "patch": "" if binary else rendered,
1124 "binary": binary,
1125 "too_large": too_large,
1126 "additions": additions,
1127 "deletions": deletions,
1128 "error": "" if completed.returncode == 0 else completed.stderr.decode("utf-8", errors="replace"),
1129 }
1130
1131 def _numstat(self, base: str, target: str, paths: list[str]) -> tuple[int, int, bool]:
1132 if not paths:
1133 return 0, 0, False
1134 output = self._git("diff", "--numstat", "--find-renames", base, target, "--", *paths, check=False).stdout
1135 additions = 0
1136 deletions = 0
1137 binary = False
1138 for line in output.splitlines():
1139 if not line.strip():
1140 continue
1141 parts = line.split("\t")
1142 if len(parts) < 2:
1143 continue
1144 if parts[0] == "-" or parts[1] == "-":
1145 binary = True
1146 continue
1147 additions += _safe_int(parts[0])
1148 deletions += _safe_int(parts[1])
1149 return additions, deletions, binary
1150
1151 def _rev_list_all(self) -> list[str]:
1152 completed = self._git("rev-list", "--date-order", "--all", check=False)
1153 if completed.returncode != 0:
1154 return []
1155 seen: set[str] = set()
1156 result: list[str] = []
1157 for line in completed.stdout.splitlines():
1158 commit = line.strip()
1159 if commit and commit not in seen:
1160 result.append(commit)
1161 seen.add(commit)
1162 return result
1163
1164 def _validate_commit(self, commit_hash: str) -> str:
1165 candidate = str(commit_hash or "").strip()
1166 if not candidate:
1167 raise TimeTravelError("Commit hash is required.")
1168 completed = self._git("rev-parse", "--verify", f"{candidate}^{{commit}}", check=False)
1169 if completed.returncode != 0:
1170 raise TimeTravelError("Unknown Time Travel commit.")
1171 return completed.stdout.strip()
1172
1173 def _first_parent(self, commit_hash: str) -> str:
1174 completed = self._git("rev-list", "--parents", "-n", "1", commit_hash)
1175 parts = completed.stdout.strip().split()
1176 return parts[1] if len(parts) > 1 else ""
1177
1178 def _commit_tree(self, commit_hash: str) -> str:
1179 return self._git("show", "-s", "--format=%T", commit_hash).stdout.strip()
1180
1181 def _preserve_ref(self, commit_hash: str, *, reason: str) -> str:
1182 stamp = Localization.get().now().strftime("%Y%m%d%H%M%S")
1183 base_ref = f"{PRESERVED_REF_PREFIX}/{stamp}-{reason}-{commit_hash[:12]}"
1184 ref = base_ref
1185 counter = 2
1186 while self._git("show-ref", "--verify", "--quiet", ref, check=False).returncode == 0:
1187 ref = f"{base_ref}-{counter}"
1188 counter += 1
1189 self._git("update-ref", ref, commit_hash)
1190 return ref
1191
1192 def _commit_message(self, message: str, metadata: dict[str, Any]) -> str:
1193 encoded = base64.b64encode(json.dumps(metadata, sort_keys=True).encode("utf-8")).decode("ascii")
1194 return f"{message.strip() or 'Snapshot'}\n\n{METADATA_PREFIX} {encoded}\n"
1195
1196 def _parse_metadata(self, body: str) -> dict[str, Any]:
1197 for line in body.splitlines():
1198 if line.startswith(METADATA_PREFIX):
1199 encoded = line.removeprefix(METADATA_PREFIX).strip()
1200 try:
1201 return json.loads(base64.b64decode(encoded).decode("utf-8"))
1202 except Exception:
1203 return {}
1204 return {}
1205
1206 def _metadata(
1207 self,
1208 trigger: str,
1209 metadata: dict[str, Any] | None,
1210 changed_path_hints: list[str] | None,
1211 ) -> dict[str, Any]:
1212 result = dict(metadata or {})
1213 result.setdefault("context_id", self.workspace.context_id)
1214 result.setdefault("project_name", self.workspace.project_name)
1215 result.setdefault("trigger", trigger)
1216 result.setdefault("timestamp", now_iso())
1217 hints = [normalize_display_path(path) for path in (changed_path_hints or []) if path]
1218 if hints:
1219 result.setdefault("changed_path_hints", hints)
1220 return {key: value for key, value in result.items() if value not in (None, "")}
1221
1222 def _default_snapshot_message(self, trigger: str) -> str:
1223 label = str(trigger or "snapshot").replace("_", " ").strip().title()
1224 return f"Snapshot: {label}"
1225
1226 def _ensure_workspace_dir(self) -> None:
1227 if not self.workspace.real_path.exists():
1228 raise TimeTravelError("Workspace path does not exist.")
1229 if not self.workspace.real_path.is_dir():
1230 raise TimeTravelError("Workspace path is not a directory.")
1231
1232 def _safe_rel_path(self, path: str) -> str:
1233 rel = str(path or "").replace("\\", "/").lstrip("/")
1234 normalized = posixpath.normpath(rel)
1235 if not normalized or normalized == "." or normalized.startswith("../") or normalized == "..":
1236 raise TimeTravelError("Invalid path.")
1237 return normalized
1238
1239 def _workspace_child(self, rel_path: str) -> Path:
1240 rel = self._safe_rel_path(rel_path)
1241 path = self.workspace.real_path.joinpath(*rel.split("/"))
1242 try:
1243 path.relative_to(self.workspace.real_path)
1244 except ValueError:
1245 raise TimeTravelError("Invalid path.")
1246 return path
1247
1248 def _rel_from_workspace(self, path: Path) -> str:
1249 try:
1250 return path.relative_to(self.workspace.real_path).as_posix()
1251 except ValueError:
1252 return str(path)
1253
1254 def _git_env(self) -> dict[str, str]:
1255 env = os.environ.copy()
1256 env["GIT_TERMINAL_PROMPT"] = "0"
1257 env["GIT_OPTIONAL_LOCKS"] = "0"
1258 return env
1259
1260 def _run_git_dir(self, *args: str, check: bool = False) -> subprocess.CompletedProcess[str]:
1261 return subprocess.run(
1262 ["git", f"--git-dir={self.workspace.repo_git_path}", *args],
1263 capture_output=True,
1264 text=True,
1265 encoding="utf-8",
1266 errors="replace",
1267 env=self._git_env(),
1268 timeout=GIT_TIMEOUT_SECONDS,
1269 check=check,
1270 )
1271
1272 def _shadow_repo_valid(self) -> bool:
1273 if not self.workspace.repo_git_path.is_dir():
1274 return False
1275 completed = self._run_git_dir("rev-parse", "--git-dir")
1276 return completed.returncode == 0
1277
1278 def _repair_shadow_repo_head(self) -> None:
1279 if not self.workspace.repo_git_path.is_dir():
1280 return
1281 if not (self.workspace.repo_git_path / "objects").is_dir() or not (self.workspace.repo_git_path / "refs").is_dir():
1282 return
1283 target_ref = CURRENT_REF if self._loose_ref_exists(CURRENT_REF) else self._first_loose_head_ref()
1284 try:
1285 (self.workspace.repo_git_path / "HEAD").write_text(f"ref: {target_ref}\n", encoding="utf-8")
1286 except OSError:
1287 return
1288
1289 def _initialize_shadow_repo(self, *, quarantine_existing: bool = False) -> None:
1290 if quarantine_existing and self.workspace.repo_git_path.exists():
1291 backup_path = self._next_invalid_repo_backup_path()
1292 shutil.move(str(self.workspace.repo_git_path), str(backup_path))
1293 completed = subprocess.run(
1294 ["git", "init", "--bare", str(self.workspace.repo_git_path)],
1295 capture_output=True,
1296 text=True,
1297 encoding="utf-8",
1298 errors="replace",
1299 env=self._git_env(),
1300 timeout=GIT_TIMEOUT_SECONDS,
1301 )
1302 if completed.returncode != 0:
1303 raise GitCommandError(
1304 (completed.stderr or completed.stdout or "Could not initialize shadow Git repository.").strip(),
1305 stdout=completed.stdout,
1306 stderr=completed.stderr,
1307 )
1308 updated = self._run_git_dir("symbolic-ref", "HEAD", CURRENT_REF)
1309 if updated.returncode != 0:
1310 raise GitCommandError(
1311 (updated.stderr or updated.stdout or "Could not initialize shadow Git HEAD.").strip(),
1312 stdout=updated.stdout,
1313 stderr=updated.stderr,
1314 )
1315
1316 def _loose_ref_exists(self, ref: str) -> bool:
1317 return self.workspace.repo_git_path.joinpath(*ref.split("/")).is_file()
1318
1319 def _first_loose_head_ref(self) -> str:
1320 heads_dir = self.workspace.repo_git_path / "refs" / "heads"
1321 try:
1322 refs = sorted(path for path in heads_dir.rglob("*") if path.is_file())
1323 except OSError:
1324 refs = []
1325 if not refs:
1326 return CURRENT_REF
1327 return "refs/heads/" + refs[0].relative_to(heads_dir).as_posix()
1328
1329 def _next_invalid_repo_backup_path(self) -> Path:
1330 stamp = Localization.get().now().strftime("%Y%m%d%H%M%S")
1331 base_path = self.workspace.shadow_path / f"{SHADOW_REPO_BACKUP_PREFIX}-{stamp}"
1332 backup_path = base_path
1333 counter = 2
1334 while backup_path.exists():
1335 backup_path = self.workspace.shadow_path / f"{base_path.name}-{counter}"
1336 counter += 1
1337 return backup_path
1338
1339 def _ensure_current_head_ref(self) -> None:
1340 current_ref = self._run_git_dir("symbolic-ref", "-q", "HEAD")
1341 if current_ref.returncode == 0 and current_ref.stdout.strip() == CURRENT_REF:
1342 return
1343
1344 current_commit = self._run_git_dir("rev-parse", "--verify", "HEAD^{commit}")
1345 if current_commit.returncode == 0:
1346 self._run_git_dir("update-ref", CURRENT_REF, current_commit.stdout.strip())
1347
1348 updated = self._run_git_dir("symbolic-ref", "HEAD", CURRENT_REF)
1349 if updated.returncode != 0:
1350 raise GitCommandError(
1351 (updated.stderr or updated.stdout or "Could not repair shadow Git HEAD.").strip(),
1352 stdout=updated.stdout,
1353 stderr=updated.stderr,
1354 )
1355
1356 def _git(self, *args: str, input: str | None = None, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
1357 self.workspace.shadow_path.mkdir(parents=True, exist_ok=True)
1358 completed = subprocess.run(
1359 [
1360 "git",
1361 f"--git-dir={self.workspace.repo_git_path}",
1362 f"--work-tree={self.workspace.real_path}",
1363 "-c",
1364 "core.bare=false",
1365 *args,
1366 ],
1367 input=input,
1368 capture_output=True,
1369 text=True,
1370 encoding="utf-8",
1371 errors="replace",
1372 env=env or self._git_env(),
1373 cwd=str(self.workspace.real_path) if self.workspace.real_path.exists() else None,
1374 timeout=GIT_TIMEOUT_SECONDS,
1375 )
1376 if check and completed.returncode != 0:
1377 raise GitCommandError(
1378 (completed.stderr or completed.stdout or "Git command failed.").strip(),
1379 stdout=completed.stdout,
1380 stderr=completed.stderr,
1381 )
1382 return completed
1383
1384 def _git_bytes(self, *args: str, input: bytes | None = None, check: bool = True) -> subprocess.CompletedProcess[bytes]:
1385 self.workspace.shadow_path.mkdir(parents=True, exist_ok=True)
1386 completed = subprocess.run(
1387 [
1388 "git",
1389 f"--git-dir={self.workspace.repo_git_path}",
1390 f"--work-tree={self.workspace.real_path}",
1391 "-c",
1392 "core.bare=false",
1393 *args,
1394 ],
1395 input=input,
1396 capture_output=True,
1397 env=self._git_env(),
1398 cwd=str(self.workspace.real_path) if self.workspace.real_path.exists() else None,
1399 timeout=GIT_TIMEOUT_SECONDS,
1400 )
1401 if check and completed.returncode != 0:
1402 stderr = completed.stderr.decode("utf-8", errors="replace")
1403 stdout = completed.stdout.decode("utf-8", errors="replace")
1404 raise GitCommandError((stderr or stdout or "Git command failed.").strip(), stdout=stdout, stderr=stderr)
1405 return completed
1406
1407
1408 def iter_snapshot_paths(workspace: Path, *, display_path: str = "") -> Iterable[str]:
1409 workspace = workspace.resolve(strict=False)
1410 if display_path:
1411 root_is_usr = normalize_display_path(display_path) == USR_DISPLAY_ROOT
1412 else:
1413 root_is_usr = workspace == real_path_for_display(USR_DISPLAY_ROOT)
1414
1415 def walk(folder: Path, rel_prefix: str = "") -> Iterable[str]:
1416 try:
1417 with os.scandir(folder) as iterator:
1418 entries = sorted(iterator, key=lambda entry: entry.name)
1419 except OSError:
1420 return
1421 for entry in entries:
1422 rel = f"{rel_prefix}/{entry.name}" if rel_prefix else entry.name
1423 rel = rel.replace("\\", "/")
1424 try:
1425 is_dir = entry.is_dir(follow_symlinks=False)
1426 is_file = entry.is_file(follow_symlinks=False)
1427 is_link = entry.is_symlink()
1428 except OSError:
1429 continue
1430 if is_dir:
1431 if root_is_usr and not rel_prefix and entry.name in USR_ROOT_EXCLUDED_DIR_NAMES:
1432 continue
1433 if _is_nested_git_worktree_dir(Path(entry.path), workspace):
1434 continue
1435 if not is_snapshot_candidate(rel, is_dir=True):
1436 continue
1437 yield from walk(Path(entry.path), rel)
1438 elif (is_file or is_link) and is_snapshot_candidate(rel, is_dir=False):
1439 yield rel
1440
1441 yield from walk(workspace)
1442
1443
1444 def _is_nested_git_worktree_dir(folder: Path, workspace: Path) -> bool:
1445 try:
1446 if folder.resolve(strict=False) == workspace.resolve(strict=False):
1447 return False
1448 except OSError:
1449 return False
1450 dot_git = folder / ".git"
1451 return dot_git.exists() or dot_git.is_symlink()
1452
1453
1454 def is_snapshot_candidate(rel_path: str, *, is_dir: bool) -> bool:
1455 rel = rel_path.replace("\\", "/").strip("/")
1456 if not rel:
1457 return False
1458 parts = rel.split("/")
1459 name = parts[-1]
1460
1461 if name == ".git" or ".git" in parts:
1462 return False
1463 if name == ".time_travel" or ".time_travel" in parts:
1464 return False
1465 if name in {"secrets.env", "variables.env"}:
1466 return False
1467
1468 if rel.startswith(".a0proj/"):
1469 return _is_safe_a0proj_candidate(rel, is_dir=is_dir)
1470
1471 if is_dir:
1472 if name in EXCLUDED_DIR_NAMES:
1473 return False
1474 if any(fnmatch.fnmatch(name, pattern) for pattern in EXCLUDED_DIR_PATTERNS):
1475 return False
1476 return True
1477
1478 if any(fnmatch.fnmatch(name, pattern) for pattern in EXCLUDED_FILE_PATTERNS):
1479 return False
1480 return True
1481
1482
1483 def _is_safe_a0proj_candidate(rel: str, *, is_dir: bool) -> bool:
1484 if rel in {".a0proj/secrets.env", ".a0proj/variables.env"}:
1485 return False
1486 if rel == ".a0proj/memory" or rel.startswith(".a0proj/memory/"):
1487 return False
1488 if is_dir:
1489 return (
1490 rel == ".a0proj"
1491 or any(prefix.startswith(rel.rstrip("/") + "/") or rel.startswith(prefix) for prefix in SAFE_A0PROJ_DIRS)
1492 or rel.startswith(".a0proj/plugins")
1493 or rel.startswith(".a0proj/agents")
1494 )
1495 if rel in SAFE_A0PROJ_FILES:
1496 return True
1497 if any(rel.startswith(prefix) for prefix in SAFE_A0PROJ_DIRS):
1498 return True
1499 return _is_safe_plugin_asset(rel)
1500
1501
1502 def _is_safe_plugin_asset(rel: str) -> bool:
1503 parts = rel.split("/")
1504 if len(parts) < 4:
1505 return False
1506 for index, part in enumerate(parts):
1507 if part != "plugins":
1508 continue
1509 tail = parts[index + 1 :]
1510 if len(tail) == 2 and tail[1] in SAFE_PLUGIN_ASSET_NAMES:
1511 return True
1512 return False
1513
1514
1515 def _parse_name_status(output: str) -> list[dict[str, str]]:
1516 parts = [part for part in output.split("\0") if part]
1517 entries: list[dict[str, str]] = []
1518 index = 0
1519 while index < len(parts):
1520 raw_status = parts[index]
1521 index += 1
1522 status = raw_status[:1]
1523 if status in {"R", "C"} and index + 1 < len(parts):
1524 old_path = parts[index].replace("\\", "/")
1525 new_path = parts[index + 1].replace("\\", "/")
1526 index += 2
1527 entries.append({"status": status, "old_path": old_path, "path": new_path})
1528 continue
1529 if index < len(parts):
1530 path = parts[index].replace("\\", "/")
1531 index += 1
1532 entries.append({"status": status, "old_path": "", "path": path})
1533 entries.sort(key=lambda item: item.get("path") or item.get("old_path") or "")
1534 return entries
1535
1536
1537 def _safe_int(value: str) -> int:
1538 try:
1539 return max(0, int(value))
1540 except (TypeError, ValueError):
1541 return 0
1542
1543
1544 def _snapshot_public(snapshot: SnapshotResult) -> dict[str, Any]:
1545 return {
1546 "created": snapshot.created,
1547 "hash": snapshot.hash,
1548 "short_hash": snapshot.short_hash,
1549 "message": snapshot.message,
1550 "files": snapshot.files,
1551 "metadata": snapshot.metadata,
1552 }
1553
1554
1555 def _compact_git_error(text: str) -> str:
1556 lines = [line.strip() for line in str(text or "").splitlines() if line.strip()]
1557 if not lines:
1558 return "The patch could not be applied cleanly."
1559 return "\n".join(lines[:8])