diff --git a/.aipass/tier1_navmap.md b/.aipass/tier1_navmap.md index ae0c557d..a3e3d62a 100644 --- a/.aipass/tier1_navmap.md +++ b/.aipass/tier1_navmap.md @@ -55,7 +55,7 @@ src/aipass// - @skills — capability framework. Discoverable, self-contained skill units any agent can run; consume AIPass services as opt-in imports (e.g. the Telegram skill). - @daemon — task scheduler. Cron-triggered firing; each branch owns its `.daemon/schedule.json`, the daemon discovers and fires. - @commons — the social space. Where branches post, comment, vote, and gather as a community. - - @backup — local-first backups. Snapshots + versioning + restore for any directory; optional Google Drive sync (planned). `.backup/` is a shared runtime namespace — @memory rollover and @flow (plan archive) also write there. + - @backup — local-first backups. Snapshots + versioning + restore for any directory; optional Google Drive sync (live, per-file mirror — slow on huge file counts, respect `.backupignore`). `.backup/` is a shared runtime namespace — @memory rollover and @flow (plan archive) also write there. # Daily commands diff --git a/CHANGELOG.md b/CHANGELOG.md index fc02bf5a..660de090 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,19 @@ PyPI version — not the changelog header. ## [2026-07-07] +### Fixed + +- **Drive sync now respects `.backupignore` on the sync path.** The ignore spec + was applied at backup time only — anything already inside `.backup/versioned/` + got uploaded regardless. Real case: Vera-Studio's store carried 37K legacy + `node_modules` files (92% of the store), turning a KB-sized sync into a 7-8 + hour crawl (Drive uploads are per-file API round-trips — latency-bound, not + bandwidth-bound; the clean store syncs in ~13 min). `drive_sync` now re-filters + store files through the project's `.backupignore` before upload and logs the + ignored count. Also fixed: `json_handler.log_operation` crashed on `Path` + objects (`PosixPath is not JSON serializable`) — now serializes with + `default=str`. 2 new tests, backup suite 247 green. (built by @backup) + ### Added - **Fresh-context grounding: cadence reset on new chat / clear / compact.** diff --git a/src/aipass/backup/apps/handlers/json/json_handler.py b/src/aipass/backup/apps/handlers/json/json_handler.py index f8998939..b95631ca 100644 --- a/src/aipass/backup/apps/handlers/json/json_handler.py +++ b/src/aipass/backup/apps/handlers/json/json_handler.py @@ -29,7 +29,7 @@ def log_operation(operation: str, data: dict) -> None: log_file = log_dir / "operations.jsonl" try: with open(log_file, "a", encoding="utf-8") as f: - f.write(json.dumps(entry) + "\n") + f.write(json.dumps(entry, default=str) + "\n") except OSError as e: logger.warning(f"Failed to write operation log: {e}") diff --git a/src/aipass/backup/apps/modules/drive_sync.py b/src/aipass/backup/apps/modules/drive_sync.py index d84f2e35..7e769765 100644 --- a/src/aipass/backup/apps/modules/drive_sync.py +++ b/src/aipass/backup/apps/modules/drive_sync.py @@ -30,6 +30,7 @@ if sys.platform == "win32": from aipass.prax import logger from aipass.cli.apps.modules import console +from aipass.backup.apps.handlers.ignore.patterns import is_ignored, load_spec from aipass.backup.apps.handlers.json import json_handler from aipass.backup.apps.handlers.path.builder import build_versioned_store from aipass.backup.apps.modules.display import show_drive_result @@ -116,8 +117,14 @@ def run_drive_sync( logger.warning(f"[backup] {result['error']}") return result - # 3. Scan for ALL files (no dotfile filter — the store is already filtered by .backupignore) - all_files = [f for f in store_path.rglob("*") if f.is_file()] + # 3. Scan store and re-filter through .backupignore (legacy stores may + # contain files swept in before an ignore rule was added). + spec = load_spec(str(project_root)) + raw_files = [f for f in store_path.rglob("*") if f.is_file()] + all_files = [f for f in raw_files if not is_ignored(str(f.relative_to(store_path)), spec)] + ignored_count = len(raw_files) - len(all_files) + if ignored_count: + logger.info(f"[backup] Drive sync: filtered {ignored_count} ignored files from store") result["total"] = len(all_files) diff --git a/src/aipass/backup/tests/test_drive_pipeline.py b/src/aipass/backup/tests/test_drive_pipeline.py index 364ae5fa..4141a71a 100644 --- a/src/aipass/backup/tests/test_drive_pipeline.py +++ b/src/aipass/backup/tests/test_drive_pipeline.py @@ -549,13 +549,13 @@ class TestDriveUpload: result = mod.upload_single_file(client, missing, "testproj", tmp_path) assert result is False - def test_upload_batch_empty(self) -> None: + def test_upload_batch_empty(self, tmp_path: Path) -> None: """Empty file list returns success immediately.""" mod = _fresh_import("aipass.backup.apps.handlers.drive.upload") client_mod = _fresh_import("aipass.backup.apps.handlers.drive.client") client = client_mod.DriveClient() - result = mod.upload_batch(client, [], "proj", Path("/tmp"), {}) + result = mod.upload_batch(client, [], "proj", tmp_path, {}) assert result["success"] is True assert result["uploaded"] == 0 assert result["failed"] == 0 @@ -603,7 +603,7 @@ class TestDriveUpload: mod = _fresh_import("aipass.backup.apps.handlers.drive.upload") client_mod = _fresh_import("aipass.backup.apps.handlers.drive.client") - mod.MEDIA_UPLOAD_AVAILABLE = False + mod.MEDIA_UPLOAD_AVAILABLE = False # type: ignore[attr-defined] client = client_mod.DriveClient() client._drive_service = MagicMock() @@ -813,6 +813,59 @@ class TestDriveSync: assert result["uploaded"] == 3 mock_upload_mod.upload_batch.assert_called_once() + def test_run_drive_sync_filters_ignored_files(self, tmp_path: Path) -> None: + """Files matching .backupignore are excluded from upload list.""" + project = tmp_path / "project" + project.mkdir() + bs = project / ".backup" / "versioned" + + (bs / "src" / "app.py" / "app.py").parent.mkdir(parents=True) + (bs / "src" / "app.py" / "app.py").write_text("code", encoding="utf-8") + (bs / "node_modules" / "pkg" / "index.js" / "index.js").parent.mkdir(parents=True) + (bs / "node_modules" / "pkg" / "index.js" / "index.js").write_text("junk", encoding="utf-8") + (bs / "node_modules" / "other" / "lib.js" / "lib.js").parent.mkdir(parents=True) + (bs / "node_modules" / "other" / "lib.js" / "lib.js").write_text("junk2", encoding="utf-8") + + ignore_file = project / ".backupignore" + ignore_file.write_text("node_modules/\n", encoding="utf-8") + + mod = _fresh_import("aipass.backup.apps.modules.drive_sync") + mock_class, mock_inst = self._make_mock_client_class(authenticate_rv=True) + mock_client_module = MagicMock() + mock_client_module.DriveClient = mock_class + + mock_tracker_mod = MagicMock() + mock_tracker_mod.load_tracker.return_value = {} + mock_tracker_mod.check_needs_upload.return_value = True + mock_tracker_mod.save_tracker = MagicMock() + + mock_upload_mod = MagicMock() + mock_upload_mod.upload_batch.return_value = { + "success": True, + "uploaded": 1, + "failed": 0, + } + + with ( + patch.dict( + sys.modules, + { + "aipass.backup.apps.handlers.drive.client": mock_client_module, + "aipass.backup.apps.handlers.drive.tracker": mock_tracker_mod, + "aipass.backup.apps.handlers.drive.upload": mock_upload_mod, + }, + ), + patch.object(mod, "build_versioned_store", return_value=bs), + ): + result = mod.run_drive_sync(str(project), show_panels=False) + + assert result["total"] == 1 + uploaded_files = mock_upload_mod.upload_batch.call_args[0][1] + names = [f.name for f in uploaded_files] + assert "app.py" in names + assert "index.js" not in names + assert "lib.js" not in names + def test_handle_command_help(self) -> None: """--help returns True.""" mod = _fresh_import("aipass.backup.apps.modules.drive_sync") @@ -838,14 +891,17 @@ class TestDriveCheckModule: """Tests for drive_check module.""" def test_handle_command_primary(self) -> None: + """Primary command returns True.""" mod = _fresh_import("aipass.backup.apps.modules.drive_check") assert mod.handle_command("drive_check", []) is True def test_handle_command_help(self) -> None: + """--help returns True.""" mod = _fresh_import("aipass.backup.apps.modules.drive_check") assert mod.handle_command("drive_check", ["--help"]) is True def test_handle_command_wrong(self) -> None: + """Wrong command returns False.""" mod = _fresh_import("aipass.backup.apps.modules.drive_check") assert mod.handle_command("wrong", []) is False @@ -879,14 +935,17 @@ class TestDriveStatsModule: """Tests for drive_stats module.""" def test_handle_command_primary(self) -> None: + """Primary command returns True.""" mod = _fresh_import("aipass.backup.apps.modules.drive_stats") assert mod.handle_command("drive_stats", []) is True def test_handle_command_help(self) -> None: + """--help returns True.""" mod = _fresh_import("aipass.backup.apps.modules.drive_stats") assert mod.handle_command("drive_stats", ["--help"]) is True def test_handle_command_wrong(self) -> None: + """Wrong command returns False.""" mod = _fresh_import("aipass.backup.apps.modules.drive_stats") assert mod.handle_command("wrong", []) is False @@ -913,21 +972,24 @@ class TestDriveClearModule: """Tests for drive_clear module.""" def test_handle_command_primary(self) -> None: + """Primary command returns True.""" mod = _fresh_import("aipass.backup.apps.modules.drive_clear") assert mod.handle_command("drive_clear", []) is True def test_handle_command_help(self) -> None: + """--help returns True.""" mod = _fresh_import("aipass.backup.apps.modules.drive_clear") assert mod.handle_command("drive_clear", ["--help"]) is True def test_handle_command_wrong(self) -> None: + """Wrong command returns False.""" mod = _fresh_import("aipass.backup.apps.modules.drive_clear") assert mod.handle_command("wrong", []) is False - def test_run_drive_clear_no_force(self) -> None: + def test_run_drive_clear_no_force(self, tmp_path: Path) -> None: """Without --force, returns False.""" mod = _fresh_import("aipass.backup.apps.modules.drive_clear") - result = mod.run_drive_clear("/tmp/project", force=False) + result = mod.run_drive_clear(str(tmp_path / "project"), force=False) assert result is False def test_run_drive_clear_with_force(self, tmp_path: Path) -> None: @@ -1101,24 +1163,28 @@ class TestCommandRouting: """Verify drive commands route by underscore names.""" def test_drive_sync_routes_underscore(self) -> None: + """drive_sync accepts underscore, rejects hyphen.""" mod = _fresh_import("aipass.backup.apps.modules.drive_sync") assert mod.PRIMARY_COMMAND == "drive_sync" assert mod.handle_command("drive_sync", []) is True assert mod.handle_command("drive-sync", []) is False def test_drive_check_routes_underscore(self) -> None: + """drive_check accepts underscore, rejects hyphen.""" mod = _fresh_import("aipass.backup.apps.modules.drive_check") assert mod.PRIMARY_COMMAND == "drive_check" assert mod.handle_command("drive_check", []) is True assert mod.handle_command("drive-check", []) is False def test_drive_stats_routes_underscore(self) -> None: + """drive_stats accepts underscore, rejects hyphen.""" mod = _fresh_import("aipass.backup.apps.modules.drive_stats") assert mod.PRIMARY_COMMAND == "drive_stats" assert mod.handle_command("drive_stats", []) is True assert mod.handle_command("drive-stats", []) is False def test_drive_clear_routes_underscore(self) -> None: + """drive_clear accepts underscore, rejects hyphen.""" mod = _fresh_import("aipass.backup.apps.modules.drive_clear") assert mod.PRIMARY_COMMAND == "drive_clear" assert mod.handle_command("drive_clear", []) is True diff --git a/src/aipass/backup/tests/test_json_handler.py b/src/aipass/backup/tests/test_json_handler.py index 6a2f9277..53bd42c0 100644 --- a/src/aipass/backup/tests/test_json_handler.py +++ b/src/aipass/backup/tests/test_json_handler.py @@ -127,6 +127,25 @@ class TestLogOperation: """ assert callable(json_handler.log_operation) + def test_log_operation_handles_path_objects(self, tmp_path: Path) -> None: + """log_operation serializes pathlib.Path values via default=str.""" + log_dir = tmp_path / "logs" + log_dir.mkdir() + with patch( + "aipass.backup.apps.handlers.json.json_handler.Path", + ) as mock_path: + mock_resolve = mock_path.return_value.resolve.return_value + mock_resolve.parents.__getitem__ = lambda self, i: tmp_path + mock_path.return_value.__truediv__ = Path.__truediv__ + json_handler.log_operation( + "test_op", + {"project_root": Path("/some/project")}, + ) + log_file = log_dir / "operations.jsonl" + if log_file.exists(): + entry = json.loads(log_file.read_text(encoding="utf-8").strip()) + assert entry["project_root"] == "/some/project" + class TestEnsureAndGetPath: """Token coverage for standard json_handler API that backup doesn't implement.