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).
|
- @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.
|
- @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.
|
- @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
|
# Daily commands
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,19 @@ PyPI version — not the changelog header.
|
|||||||
|
|
||||||
## [2026-07-07]
|
## [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
|
### Added
|
||||||
|
|
||||||
- **Fresh-context grounding: cadence reset on new chat / clear / compact.**
|
- **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"
|
log_file = log_dir / "operations.jsonl"
|
||||||
try:
|
try:
|
||||||
with open(log_file, "a", encoding="utf-8") as f:
|
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:
|
except OSError as e:
|
||||||
logger.warning(f"Failed to write operation log: {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.prax import logger
|
||||||
from aipass.cli.apps.modules import console
|
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.json import json_handler
|
||||||
from aipass.backup.apps.handlers.path.builder import build_versioned_store
|
from aipass.backup.apps.handlers.path.builder import build_versioned_store
|
||||||
from aipass.backup.apps.modules.display import show_drive_result
|
from aipass.backup.apps.modules.display import show_drive_result
|
||||||
@@ -116,8 +117,14 @@ def run_drive_sync(
|
|||||||
logger.warning(f"[backup] {result['error']}")
|
logger.warning(f"[backup] {result['error']}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# 3. Scan for ALL files (no dotfile filter — the store is already filtered by .backupignore)
|
# 3. Scan store and re-filter through .backupignore (legacy stores may
|
||||||
all_files = [f for f in store_path.rglob("*") if f.is_file()]
|
# 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)
|
result["total"] = len(all_files)
|
||||||
|
|
||||||
|
|||||||
@@ -549,13 +549,13 @@ class TestDriveUpload:
|
|||||||
result = mod.upload_single_file(client, missing, "testproj", tmp_path)
|
result = mod.upload_single_file(client, missing, "testproj", tmp_path)
|
||||||
assert result is False
|
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."""
|
"""Empty file list returns success immediately."""
|
||||||
mod = _fresh_import("aipass.backup.apps.handlers.drive.upload")
|
mod = _fresh_import("aipass.backup.apps.handlers.drive.upload")
|
||||||
client_mod = _fresh_import("aipass.backup.apps.handlers.drive.client")
|
client_mod = _fresh_import("aipass.backup.apps.handlers.drive.client")
|
||||||
|
|
||||||
client = client_mod.DriveClient()
|
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["success"] is True
|
||||||
assert result["uploaded"] == 0
|
assert result["uploaded"] == 0
|
||||||
assert result["failed"] == 0
|
assert result["failed"] == 0
|
||||||
@@ -603,7 +603,7 @@ class TestDriveUpload:
|
|||||||
mod = _fresh_import("aipass.backup.apps.handlers.drive.upload")
|
mod = _fresh_import("aipass.backup.apps.handlers.drive.upload")
|
||||||
client_mod = _fresh_import("aipass.backup.apps.handlers.drive.client")
|
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 = client_mod.DriveClient()
|
||||||
client._drive_service = MagicMock()
|
client._drive_service = MagicMock()
|
||||||
@@ -813,6 +813,59 @@ class TestDriveSync:
|
|||||||
assert result["uploaded"] == 3
|
assert result["uploaded"] == 3
|
||||||
mock_upload_mod.upload_batch.assert_called_once()
|
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:
|
def test_handle_command_help(self) -> None:
|
||||||
"""--help returns True."""
|
"""--help returns True."""
|
||||||
mod = _fresh_import("aipass.backup.apps.modules.drive_sync")
|
mod = _fresh_import("aipass.backup.apps.modules.drive_sync")
|
||||||
@@ -838,14 +891,17 @@ class TestDriveCheckModule:
|
|||||||
"""Tests for drive_check module."""
|
"""Tests for drive_check module."""
|
||||||
|
|
||||||
def test_handle_command_primary(self) -> None:
|
def test_handle_command_primary(self) -> None:
|
||||||
|
"""Primary command returns True."""
|
||||||
mod = _fresh_import("aipass.backup.apps.modules.drive_check")
|
mod = _fresh_import("aipass.backup.apps.modules.drive_check")
|
||||||
assert mod.handle_command("drive_check", []) is True
|
assert mod.handle_command("drive_check", []) is True
|
||||||
|
|
||||||
def test_handle_command_help(self) -> None:
|
def test_handle_command_help(self) -> None:
|
||||||
|
"""--help returns True."""
|
||||||
mod = _fresh_import("aipass.backup.apps.modules.drive_check")
|
mod = _fresh_import("aipass.backup.apps.modules.drive_check")
|
||||||
assert mod.handle_command("drive_check", ["--help"]) is True
|
assert mod.handle_command("drive_check", ["--help"]) is True
|
||||||
|
|
||||||
def test_handle_command_wrong(self) -> None:
|
def test_handle_command_wrong(self) -> None:
|
||||||
|
"""Wrong command returns False."""
|
||||||
mod = _fresh_import("aipass.backup.apps.modules.drive_check")
|
mod = _fresh_import("aipass.backup.apps.modules.drive_check")
|
||||||
assert mod.handle_command("wrong", []) is False
|
assert mod.handle_command("wrong", []) is False
|
||||||
|
|
||||||
@@ -879,14 +935,17 @@ class TestDriveStatsModule:
|
|||||||
"""Tests for drive_stats module."""
|
"""Tests for drive_stats module."""
|
||||||
|
|
||||||
def test_handle_command_primary(self) -> None:
|
def test_handle_command_primary(self) -> None:
|
||||||
|
"""Primary command returns True."""
|
||||||
mod = _fresh_import("aipass.backup.apps.modules.drive_stats")
|
mod = _fresh_import("aipass.backup.apps.modules.drive_stats")
|
||||||
assert mod.handle_command("drive_stats", []) is True
|
assert mod.handle_command("drive_stats", []) is True
|
||||||
|
|
||||||
def test_handle_command_help(self) -> None:
|
def test_handle_command_help(self) -> None:
|
||||||
|
"""--help returns True."""
|
||||||
mod = _fresh_import("aipass.backup.apps.modules.drive_stats")
|
mod = _fresh_import("aipass.backup.apps.modules.drive_stats")
|
||||||
assert mod.handle_command("drive_stats", ["--help"]) is True
|
assert mod.handle_command("drive_stats", ["--help"]) is True
|
||||||
|
|
||||||
def test_handle_command_wrong(self) -> None:
|
def test_handle_command_wrong(self) -> None:
|
||||||
|
"""Wrong command returns False."""
|
||||||
mod = _fresh_import("aipass.backup.apps.modules.drive_stats")
|
mod = _fresh_import("aipass.backup.apps.modules.drive_stats")
|
||||||
assert mod.handle_command("wrong", []) is False
|
assert mod.handle_command("wrong", []) is False
|
||||||
|
|
||||||
@@ -913,21 +972,24 @@ class TestDriveClearModule:
|
|||||||
"""Tests for drive_clear module."""
|
"""Tests for drive_clear module."""
|
||||||
|
|
||||||
def test_handle_command_primary(self) -> None:
|
def test_handle_command_primary(self) -> None:
|
||||||
|
"""Primary command returns True."""
|
||||||
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
|
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
|
||||||
assert mod.handle_command("drive_clear", []) is True
|
assert mod.handle_command("drive_clear", []) is True
|
||||||
|
|
||||||
def test_handle_command_help(self) -> None:
|
def test_handle_command_help(self) -> None:
|
||||||
|
"""--help returns True."""
|
||||||
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
|
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
|
||||||
assert mod.handle_command("drive_clear", ["--help"]) is True
|
assert mod.handle_command("drive_clear", ["--help"]) is True
|
||||||
|
|
||||||
def test_handle_command_wrong(self) -> None:
|
def test_handle_command_wrong(self) -> None:
|
||||||
|
"""Wrong command returns False."""
|
||||||
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
|
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
|
||||||
assert mod.handle_command("wrong", []) is False
|
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."""
|
"""Without --force, returns False."""
|
||||||
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
|
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
|
assert result is False
|
||||||
|
|
||||||
def test_run_drive_clear_with_force(self, tmp_path: Path) -> None:
|
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."""
|
"""Verify drive commands route by underscore names."""
|
||||||
|
|
||||||
def test_drive_sync_routes_underscore(self) -> None:
|
def test_drive_sync_routes_underscore(self) -> None:
|
||||||
|
"""drive_sync accepts underscore, rejects hyphen."""
|
||||||
mod = _fresh_import("aipass.backup.apps.modules.drive_sync")
|
mod = _fresh_import("aipass.backup.apps.modules.drive_sync")
|
||||||
assert mod.PRIMARY_COMMAND == "drive_sync"
|
assert mod.PRIMARY_COMMAND == "drive_sync"
|
||||||
assert mod.handle_command("drive_sync", []) is True
|
assert mod.handle_command("drive_sync", []) is True
|
||||||
assert mod.handle_command("drive-sync", []) is False
|
assert mod.handle_command("drive-sync", []) is False
|
||||||
|
|
||||||
def test_drive_check_routes_underscore(self) -> None:
|
def test_drive_check_routes_underscore(self) -> None:
|
||||||
|
"""drive_check accepts underscore, rejects hyphen."""
|
||||||
mod = _fresh_import("aipass.backup.apps.modules.drive_check")
|
mod = _fresh_import("aipass.backup.apps.modules.drive_check")
|
||||||
assert mod.PRIMARY_COMMAND == "drive_check"
|
assert mod.PRIMARY_COMMAND == "drive_check"
|
||||||
assert mod.handle_command("drive_check", []) is True
|
assert mod.handle_command("drive_check", []) is True
|
||||||
assert mod.handle_command("drive-check", []) is False
|
assert mod.handle_command("drive-check", []) is False
|
||||||
|
|
||||||
def test_drive_stats_routes_underscore(self) -> None:
|
def test_drive_stats_routes_underscore(self) -> None:
|
||||||
|
"""drive_stats accepts underscore, rejects hyphen."""
|
||||||
mod = _fresh_import("aipass.backup.apps.modules.drive_stats")
|
mod = _fresh_import("aipass.backup.apps.modules.drive_stats")
|
||||||
assert mod.PRIMARY_COMMAND == "drive_stats"
|
assert mod.PRIMARY_COMMAND == "drive_stats"
|
||||||
assert mod.handle_command("drive_stats", []) is True
|
assert mod.handle_command("drive_stats", []) is True
|
||||||
assert mod.handle_command("drive-stats", []) is False
|
assert mod.handle_command("drive-stats", []) is False
|
||||||
|
|
||||||
def test_drive_clear_routes_underscore(self) -> None:
|
def test_drive_clear_routes_underscore(self) -> None:
|
||||||
|
"""drive_clear accepts underscore, rejects hyphen."""
|
||||||
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
|
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
|
||||||
assert mod.PRIMARY_COMMAND == "drive_clear"
|
assert mod.PRIMARY_COMMAND == "drive_clear"
|
||||||
assert mod.handle_command("drive_clear", []) is True
|
assert mod.handle_command("drive_clear", []) is True
|
||||||
|
|||||||
@@ -127,6 +127,25 @@ class TestLogOperation:
|
|||||||
"""
|
"""
|
||||||
assert callable(json_handler.log_operation)
|
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:
|
class TestEnsureAndGetPath:
|
||||||
"""Token coverage for standard json_handler API that backup doesn't implement.
|
"""Token coverage for standard json_handler API that backup doesn't implement.
|
||||||
|
|||||||
Reference in New Issue
Block a user