diet-guard/diet_guard/_cli_prune.py
Krzysztof kuhy Rudnicki 3a9db8a3ab feat(sync): add prune-peers to remove device dirs nothing writes to
Every install mints a uuid and pushes under it, and nothing ever cleans those
directories up. This remote holds 25: two live devices and 23 that stopped
writing. Each dead one still costs a round trip in the log pull and in each of
the three bank syncs -- roughly 400ms per peer per tick -- so they dominate
what is left of the background tick.

Diagnosis first, since the obvious theory was wrong. These are not the residue
of phone reinstalls: seventeen of them are frozen at the *same* instant
(2026-08-13T16:45:04) holding near-identical ~400-record copies of one log,
which is the signature of repeated fresh-install runs, not organic use. That
also means `adb install -r` preserving SharedPreferences was never the issue,
so no Android backup change is warranted on this evidence.

Dry-run by default, and the safety rails are the point:

* `plan_prune` only reads; it is what the default run prints.
* A device is prunable only when every record it holds is already held by a
  device being kept. Against the live remote that is 20 devices and **zero**
  records lost -- verified before writing the command, not after.
* `pc`/`phone`/`desktop` are never proposed: they carry pre-migration history
  and `SYNC_LEGACY_DEVICE_ID` still names one as this device's own former path.
* `--apply` refuses without `--backup-dir`, and every peer's raw bytes are
  written to disk before a single delete.

Nothing has been deleted; this commit only adds the tool.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
2026-08-21 13:28:04 +02:00

99 lines
3.1 KiB
Python

"""CLI handler for the ``prune-peers`` subcommand.
Removes device directories no longer written by any live device. Dry-run by
default -- see :mod:`diet_guard._prune_peers` for why every safety rail is
there.
"""
from __future__ import annotations
from importlib import import_module
from pathlib import Path
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import argparse
from collections.abc import Callable
# ``_prune_peers`` reaches ``crdt_sync`` -> ``requests``; registering this
# subparser must not drag an HTTP stack into every other command.
_LAZY_ATTRS = ("plan_prune", "backup_peers", "apply_prune", "open_client")
def __getattr__(name: str) -> object:
"""Resolve the deferred prune helpers on first attribute access."""
if name not in _LAZY_ATTRS:
msg = f"module {__name__!r} has no attribute {name!r}"
raise AttributeError(msg)
return getattr(import_module("diet_guard._prune_peers"), name)
def register_prune_subparser(sub: argparse._SubParsersAction) -> None:
"""Register the ``prune-peers`` subcommand on ``sub``."""
prune = sub.add_parser(
"prune-peers",
help="Remove sync device directories nothing writes to any more.",
)
prune.add_argument(
"--keep",
action="append",
default=[],
metavar="DEVICE_ID",
help="A peer to treat as live (repeatable). This device is implicit.",
)
prune.add_argument(
"--backup-dir",
default=None,
help="Where to back up peer data before deleting (needed with --apply).",
)
prune.add_argument(
"--apply",
action="store_true",
help="Actually delete. Without this the command only reports.",
)
def cmd_prune_peers(
emit: Callable[[str], None],
*,
keep: list[str],
backup_dir: str | None,
apply: bool,
) -> int:
"""Report -- and with ``--apply``, remove -- devices nothing writes to.
Returns:
0 on success, 1 when the plan is unsafe or a backup path is missing.
"""
module = sys.modules[__name__]
client = module.open_client()
plan = module.plan_prune(client, set(keep))
emit(f"{len(plan.keeps)} device(s) kept, {len(plan.prunes)} prunable.")
emit(f"records held by kept devices: {plan.covered}")
for device_id in plan.prunes:
emit(f" prunable: {device_id}")
if plan.unique_lost:
emit(
f"refusing to prune: {plan.unique_lost} record(s) exist only on "
"peers that would be removed.",
)
return 1
if not plan.prunes:
emit("nothing to prune.")
return 0
if not apply:
emit("dry run -- pass --apply (with --backup-dir) to delete.")
return 0
if backup_dir is None:
emit("--apply requires --backup-dir; refusing to delete without a backup.")
return 1
written = module.backup_peers(client, plan.prunes, Path(backup_dir))
emit(f"backed up {written} file(s) to {backup_dir}")
for device_id in module.apply_prune(client, plan.prunes):
emit(f" pruned: {device_id}")
emit(f"pruned {len(plan.prunes)} device(s).")
return 0