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:
@@ -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
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user