Exclude Time Travel history from backups

Skip usr/.time_travel during self-update user-data backups and add the same Time Travel shadow-history exclusion to default BackupService metadata.\n\nAdd focused regressions proving regular user files stay in backup coverage while Time Travel shadow repositories are omitted.

Alessandro committed Jul 9, 2026 at 13:27 UTC 3d87998821202dd5c9ac8ad18eb1782a8e2e247c
6 files changed +63
docker/run/AGENTS.md
+1
@@ -22,6 +22,7 @@
22 - Do not bake secrets, local `.env` values, or user data into the image.
23 - Runtime startup must ensure `/a0/usr/uploads` exists before supervised services start.
24 - Runtime startup raises the soft open-file limit toward `A0_NOFILE_LIMIT` (default `65535`) before supervisord starts, bounded by the container hard limit.
25 +- Self-update user-data backups skip Time Travel shadow history under `usr/.time_travel/` and transient Desktop agent state.
26
27 ## Work Guidance
28
docker/run/fs/exe/self_update_manager.py
+5
@@ -405,6 +405,11 @@ def should_exclude_from_usr_backup(
405 logger: AttemptLogger,
406 ) -> bool:
407 parts = relative_dir.parts
408 + if parts and parts[0] == ".time_travel":
409 + logger.log(
410 + f"Skipping Time Travel history during usr backup: {Path('usr') / relative_dir}"
411 + )
412 + return True
413 if (
414 len(parts) >= 6
415 and parts[0] == "plugins"
helpers/backup.py
+1
@@ -63,6 +63,7 @@ class BackupService:
63 return f"""# User data
64 # All persistent user data is now centralized in /usr for easier backup and restore
65 {agent_root}/usr/**
66 +!{agent_root}/usr/.time_travel/**
67 """
68
69 def _get_agent_zero_version(self) -> str:
helpers/backup.py.dox.md
+1
@@ -26,6 +26,7 @@
26 - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, settings/state persistence, secret handling.
27 - Imported dependency areas include: `datetime`, `helpers`, `helpers.localization`, `helpers.print_style`, `json`, `os`, `pathspec`, `platform`, `tempfile`, `typing`, `zipfile`.
28 - `test_patterns(..., max_files=None)` is the unlimited scan mode. UI preview and dry-run callers may pass bounded limits, but real backup creation and restore clean-before-restore must use unlimited matching so archives and cleanup are not silently truncated.
29 +- Default backup metadata includes persistent `/usr` data but excludes Time Travel shadow history under `usr/.time_travel/**`.
30
31 ## Key Concepts
32
tests/test_backup_large_archives.py
+22
@@ -16,6 +16,28 @@ class UploadedBackup:
16 shutil.copyfile(self.path, target)
17
18
19 +@pytest.mark.asyncio
20 +async def test_default_backup_patterns_exclude_time_travel_history(tmp_path):
21 + root = tmp_path / "a0"
22 + usr = root / "usr"
23 + time_travel = usr / ".time_travel" / "workspaces" / "demo" / "repo.git"
24 + time_travel.mkdir(parents=True)
25 + (usr / "settings.json").write_text('{"ok": true}\n', encoding="utf-8")
26 + (time_travel / "objects.pack").write_text("history\n", encoding="utf-8")
27 +
28 + service = BackupService()
29 + service.agent_zero_root = str(root)
30 + service.base_paths = {str(root): str(root)}
31 + metadata = service.get_default_backup_metadata()
32 +
33 + files = await service.test_patterns(metadata, max_files=None)
34 + paths = {item["real_path"] for item in files}
35 +
36 + assert str(usr / "settings.json") in paths
37 + assert str(time_travel / "objects.pack") not in paths
38 + assert f"{root}/usr/.time_travel/**" in metadata["exclude_patterns"]
39 +
40 +
41 @pytest.mark.asyncio
42 async def test_pattern_scan_can_run_without_file_limit(tmp_path):
43 root = tmp_path / "a0"
tests/test_self_update_tag_filter.py
+33
@@ -829,6 +829,39 @@ def test_self_update_manager_usr_backup_skips_runtime_sockets():
829 )
830
831
832 +def test_self_update_manager_usr_backup_skips_time_travel_history(tmp_path):
833 + manager = load_self_update_manager()
834 + repo_dir = tmp_path / "repo"
835 + usr_dir = repo_dir / "usr"
836 + time_travel = usr_dir / ".time_travel" / "workspaces" / "demo" / "repo.git"
837 + time_travel.mkdir(parents=True)
838 + (usr_dir / "settings.json").write_text('{"ok": true}\n', encoding="utf-8")
839 + (time_travel / "objects.pack").write_text("history\n", encoding="utf-8")
840 + messages = []
841 +
842 + class ListLogger:
843 + def log(self, message=""):
844 + messages.append(message)
845 +
846 + backup_path = manager.create_usr_backup(
847 + repo_dir=repo_dir,
848 + backup_path=str(tmp_path / "backups"),
849 + backup_name="usr-backup.zip",
850 + conflict_policy="rename",
851 + logger=ListLogger(),
852 + )
853 +
854 + with zipfile.ZipFile(backup_path) as archive:
855 + names = set(archive.namelist())
856 +
857 + assert "usr/settings.json" in names
858 + assert "usr/.time_travel/workspaces/demo/repo.git/objects.pack" not in names
859 + assert any(
860 + "Skipping Time Travel history during usr backup: usr/.time_travel" in message
861 + for message in messages
862 + )
863 +
864 +
865 def test_self_update_manager_usr_backup_skips_transient_desktop_ssh_agent_dir(tmp_path):
866 manager = load_self_update_manager()
867 repo_dir = tmp_path / "repo"