Fix Time Travel snapshot resilience
Force-add curated snapshot paths so workspace .gitignore rules cannot break Time Travel snapshots, while preserving Time Travel's own exclusions for secrets and generated files. Repair invalid shadow Git repositories by restoring HEAD when possible or quarantining and reinitializing unusable repos, and canonicalize workspace paths to avoid duplicate shadow histories for aliases. Add regression coverage for ignored paths, corrupt shadow HEAD recovery, and canonical workspace identity.
Alessandro committed
May 2, 2026 at 20:27 UTC
d8c0d6b9fe6cbfdb9af126d513f420f648f73dcd
2 files changed
+167
-21
plugins/_time_travel/helpers/time_travel.py
+115
-21
@@ -31,6 +31,7 @@ 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] = {}
@@ -189,7 +190,7 @@ def is_inside_usr_display(display_path: str) -> bool:
190
191
192
def workspace_id_for(display_path: str) -> str:
192
- normalized = normalize_display_path(display_path).rstrip("/")
193
+ normalized = canonical_workspace_display_path(display_path).rstrip("/")
194
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:32]
195
196
@@ -202,6 +203,13 @@ def real_path_for_display(display_path: str) -> Path:
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 resolve_workspace(context_id: str = "", *, context_loader=None) -> WorkspaceInfo:
214
from helpers import projects, settings
215
@@ -220,7 +228,7 @@ def resolve_workspace(context_id: str = "", *, context_loader=None) -> Workspace
228
configured = str(settings.get_settings().get("workdir_path") or "")
229
display_path = configured or files.normalize_a0_path(files.get_abs_path("usr/workdir"))
230
223
- normalized = normalize_display_path(display_path)
231
+ normalized = canonical_workspace_display_path(display_path)
232
if not is_inside_usr_display(normalized):
233
raise WorkspaceRejectedError("Time Travel is only available for workspaces inside /a0/usr.")
234
@@ -241,7 +249,7 @@ def resolve_workspace(context_id: str = "", *, context_loader=None) -> Workspace
249
def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None:
250
from helpers import settings
251
244
- normalized = normalize_display_path(path_hint)
252
+ normalized = canonical_workspace_display_path(path_hint)
253
if not is_inside_usr_display(normalized):
254
return None
255
@@ -251,7 +259,7 @@ def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None:
259
return _workspace_from_display(project_display, project_name=parts[3])
260
261
configured = str(settings.get_settings().get("workdir_path") or "")
254
- workdir_display = normalize_display_path(configured or files.normalize_a0_path(files.get_abs_path("usr/workdir")))
262
+ workdir_display = canonical_workspace_display_path(configured or files.normalize_a0_path(files.get_abs_path("usr/workdir")))
263
if normalized == workdir_display or normalized.startswith(workdir_display.rstrip("/") + "/"):
264
return _workspace_from_display(workdir_display)
265
@@ -259,7 +267,7 @@ def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None:
267
268
269
def _workspace_from_display(display_path: str, *, project_name: str = "", context_id: str = "") -> WorkspaceInfo:
262
- normalized = normalize_display_path(display_path)
270
+ normalized = canonical_workspace_display_path(display_path)
271
if not is_inside_usr_display(normalized):
272
raise WorkspaceRejectedError("Time Travel is only available for workspaces inside /a0/usr.")
273
workspace_id = workspace_id_for(normalized)
@@ -553,22 +561,11 @@ class TimeTravelService:
561
562
def ensure_repo(self) -> None:
563
self.workspace.shadow_path.mkdir(parents=True, exist_ok=True)
556
- if not self.workspace.repo_git_path.exists():
557
- completed = subprocess.run(
558
- ["git", "init", "--bare", str(self.workspace.repo_git_path)],
559
- capture_output=True,
560
- text=True,
561
- encoding="utf-8",
562
- errors="replace",
563
- timeout=GIT_TIMEOUT_SECONDS,
564
- )
565
- if completed.returncode != 0:
566
- raise GitCommandError(
567
- (completed.stderr or completed.stdout or "Could not initialize shadow Git repository.").strip(),
568
- stdout=completed.stdout,
569
- stderr=completed.stderr,
570
- )
571
- self._git("symbolic-ref", "HEAD", CURRENT_REF)
564
+ if not self._shadow_repo_valid():
565
+ self._repair_shadow_repo_head()
566
+ if not self._shadow_repo_valid():
567
+ self._initialize_shadow_repo(quarantine_existing=True)
568
+ self._ensure_current_head_ref()
569
570
self._git("config", "user.name", "Agent Zero Time Travel")
571
self._git("config", "user.email", "time-travel@agent-zero.local")
@@ -854,6 +851,7 @@ class TimeTravelService:
851
payload = "\0".join(paths).encode("utf-8") + b"\0"
852
self._git_bytes(
853
"add",
854
+ "-f",
855
"-A",
856
"--pathspec-from-file=-",
857
"--pathspec-file-nul",
@@ -1106,6 +1104,102 @@ class TimeTravelService:
1104
env["GIT_OPTIONAL_LOCKS"] = "0"
1105
return env
1106
1107
+ def _run_git_dir(self, *args: str, check: bool = False) -> subprocess.CompletedProcess[str]:
1108
+ return subprocess.run(
1109
+ ["git", f"--git-dir={self.workspace.repo_git_path}", *args],
1110
+ capture_output=True,
1111
+ text=True,
1112
+ encoding="utf-8",
1113
+ errors="replace",
1114
+ env=self._git_env(),
1115
+ timeout=GIT_TIMEOUT_SECONDS,
1116
+ check=check,
1117
+ )
1118
+
1119
+ def _shadow_repo_valid(self) -> bool:
1120
+ if not self.workspace.repo_git_path.is_dir():
1121
+ return False
1122
+ completed = self._run_git_dir("rev-parse", "--git-dir")
1123
+ return completed.returncode == 0
1124
+
1125
+ def _repair_shadow_repo_head(self) -> None:
1126
+ if not self.workspace.repo_git_path.is_dir():
1127
+ return
1128
+ if not (self.workspace.repo_git_path / "objects").is_dir() or not (self.workspace.repo_git_path / "refs").is_dir():
1129
+ return
1130
+ target_ref = CURRENT_REF if self._loose_ref_exists(CURRENT_REF) else self._first_loose_head_ref()
1131
+ try:
1132
+ (self.workspace.repo_git_path / "HEAD").write_text(f"ref: {target_ref}\n", encoding="utf-8")
1133
+ except OSError:
1134
+ return
1135
+
1136
+ def _initialize_shadow_repo(self, *, quarantine_existing: bool = False) -> None:
1137
+ if quarantine_existing and self.workspace.repo_git_path.exists():
1138
+ backup_path = self._next_invalid_repo_backup_path()
1139
+ shutil.move(str(self.workspace.repo_git_path), str(backup_path))
1140
+ completed = subprocess.run(
1141
+ ["git", "init", "--bare", str(self.workspace.repo_git_path)],
1142
+ capture_output=True,
1143
+ text=True,
1144
+ encoding="utf-8",
1145
+ errors="replace",
1146
+ env=self._git_env(),
1147
+ timeout=GIT_TIMEOUT_SECONDS,
1148
+ )
1149
+ if completed.returncode != 0:
1150
+ raise GitCommandError(
1151
+ (completed.stderr or completed.stdout or "Could not initialize shadow Git repository.").strip(),
1152
+ stdout=completed.stdout,
1153
+ stderr=completed.stderr,
1154
+ )
1155
+ updated = self._run_git_dir("symbolic-ref", "HEAD", CURRENT_REF)
1156
+ if updated.returncode != 0:
1157
+ raise GitCommandError(
1158
+ (updated.stderr or updated.stdout or "Could not initialize shadow Git HEAD.").strip(),
1159
+ stdout=updated.stdout,
1160
+ stderr=updated.stderr,
1161
+ )
1162
+
1163
+ def _loose_ref_exists(self, ref: str) -> bool:
1164
+ return self.workspace.repo_git_path.joinpath(*ref.split("/")).is_file()
1165
+
1166
+ def _first_loose_head_ref(self) -> str:
1167
+ heads_dir = self.workspace.repo_git_path / "refs" / "heads"
1168
+ try:
1169
+ refs = sorted(path for path in heads_dir.rglob("*") if path.is_file())
1170
+ except OSError:
1171
+ refs = []
1172
+ if not refs:
1173
+ return CURRENT_REF
1174
+ return "refs/heads/" + refs[0].relative_to(heads_dir).as_posix()
1175
+
1176
+ def _next_invalid_repo_backup_path(self) -> Path:
1177
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
1178
+ base_path = self.workspace.shadow_path / f"{SHADOW_REPO_BACKUP_PREFIX}-{stamp}"
1179
+ backup_path = base_path
1180
+ counter = 2
1181
+ while backup_path.exists():
1182
+ backup_path = self.workspace.shadow_path / f"{base_path.name}-{counter}"
1183
+ counter += 1
1184
+ return backup_path
1185
+
1186
+ def _ensure_current_head_ref(self) -> None:
1187
+ current_ref = self._run_git_dir("symbolic-ref", "-q", "HEAD")
1188
+ if current_ref.returncode == 0 and current_ref.stdout.strip() == CURRENT_REF:
1189
+ return
1190
+
1191
+ current_commit = self._run_git_dir("rev-parse", "--verify", "HEAD^{commit}")
1192
+ if current_commit.returncode == 0:
1193
+ self._run_git_dir("update-ref", CURRENT_REF, current_commit.stdout.strip())
1194
+
1195
+ updated = self._run_git_dir("symbolic-ref", "HEAD", CURRENT_REF)
1196
+ if updated.returncode != 0:
1197
+ raise GitCommandError(
1198
+ (updated.stderr or updated.stdout or "Could not repair shadow Git HEAD.").strip(),
1199
+ stdout=updated.stdout,
1200
+ stderr=updated.stderr,
1201
+ )
1202
+
1203
def _git(self, *args: str, input: str | None = None, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
1204
self.workspace.shadow_path.mkdir(parents=True, exist_ok=True)
1205
completed = subprocess.run(
tests/test_time_travel.py
+52
@@ -135,6 +135,58 @@ def test_kernel_boundary_real_git_repo_and_git_dir_exclusion(workspace):
135
assert "untracked.txt" in tracked_paths(service, snapshot.hash)
136
137
138
+def test_snapshot_force_adds_curated_paths_ignored_by_workspace_gitignore(workspace):
139
+ root, service = workspace
140
+ (root / ".gitignore").write_text("ignored.txt\nignored-dir/\n.env\n", encoding="utf-8")
141
+ (root / "ignored.txt").write_text("still important\n", encoding="utf-8")
142
+ (root / "ignored-dir").mkdir()
143
+ (root / "ignored-dir" / "note.txt").write_text("nested\n", encoding="utf-8")
144
+ (root / ".env").write_text("SECRET=still excluded\n", encoding="utf-8")
145
+
146
+ snapshot = service.snapshot(trigger="manual")
147
+ paths = tracked_paths(service, snapshot.hash)
148
+
149
+ assert ".gitignore" in paths
150
+ assert "ignored.txt" in paths
151
+ assert "ignored-dir/note.txt" in paths
152
+ assert ".env" not in paths
153
+
154
+
155
+def test_shadow_repo_empty_head_is_repaired_without_losing_history(workspace):
156
+ root, service = workspace
157
+ (root / "a.txt").write_text("one\n", encoding="utf-8")
158
+ first = service.snapshot(trigger="manual")
159
+ (service.workspace.repo_git_path / "HEAD").write_text("", encoding="utf-8")
160
+
161
+ (root / "a.txt").write_text("one\ntwo\n", encoding="utf-8")
162
+ second = service.snapshot(trigger="manual")
163
+
164
+ assert second.created is True
165
+ assert service.current_hash() == second.hash
166
+ assert [commit["hash"] for commit in service.history_list(limit=10)["commits"][:2]] == [
167
+ second.hash,
168
+ first.hash,
169
+ ]
170
+
171
+
172
+def test_workspace_identity_canonicalizes_symlink_aliases():
173
+ name = f"tt-{uuid.uuid4().hex}"
174
+ root = PROJECT_ROOT / "usr" / "time-travel-tests" / name
175
+ target = root / "target"
176
+ alias = root / "alias"
177
+ target.mkdir(parents=True)
178
+ os.symlink(target, alias)
179
+
180
+ target_workspace = _workspace_from_display(f"/a0/usr/time-travel-tests/{name}/target")
181
+ alias_workspace = _workspace_from_display(f"/a0/usr/time-travel-tests/{name}/alias")
182
+ try:
183
+ assert alias_workspace.id == target_workspace.id
184
+ assert alias_workspace.display_path == target_workspace.display_path
185
+ finally:
186
+ shutil.rmtree(root, ignore_errors=True)
187
+ shutil.rmtree(target_workspace.shadow_path, ignore_errors=True)
188
+
189
+
190
def test_usr_root_snapshot_skips_plugins_and_nested_git_projects(tmp_path: Path):
191
root = tmp_path / "usr"
192
root.mkdir()