backup: drive-sync respects .backupignore on sync path + PosixPath log fix — stale-store junk can't cause 8hr syncs (built by @backup)

This commit is contained in:
AIOSAI
2026-07-07 21:18:39 -07:00
parent a20d191f87
commit 195b9f081c
6 changed files with 114 additions and 9 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ src/aipass/<name>/
- @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
+13
View File
@@ -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.**
@@ -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}")
+9 -2
View File
@@ -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)
+71 -5
View File
@@ -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
@@ -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.