Skip to content

Commit 30a51b7

Browse files
feat(config): co-locate state directory inside mirror base path
Store state.db and blobs/ inside the mirror base path as .androidmigrate/ instead of the current working directory. A pointer file at ~/.config/androidmigrate/home remembers the base path for subsequent launches. Priority: ANDROIDMIGRATE_HOME env > --state-dir > pointer file > CWD.
1 parent 5b7e665 commit 30a51b7

4 files changed

Lines changed: 207 additions & 8 deletions

File tree

src/androidmigrate/cli.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
from __future__ import annotations
22

33
import argparse
4+
import os
5+
import sys
46
from pathlib import Path
57

6-
from .config import derive_label, get_state_dir, unique_label
8+
from .config import ENV_STATE_DIR, derive_label, get_state_dir, relocate_state, state_dir_for_base, unique_label, write_pointer_file
79
from .storage import BlobStore, Repository
810
from .sync_engine import SyncEngine, summary_to_text
911
from .transport import ADBTransport, TransportError
1012

1113

14+
def _has_explicit_state_dir(argv: list[str]) -> bool:
15+
return bool(os.environ.get(ENV_STATE_DIR)) or any(a.startswith("--state-dir") for a in argv)
16+
17+
1218
def build_parser() -> argparse.ArgumentParser:
1319
parser = argparse.ArgumentParser(prog="androidmigrate")
1420
parser.add_argument("--state-dir", default=str(get_state_dir()), help="State directory for SQLite and blobs")
@@ -78,7 +84,22 @@ def build_parser() -> argparse.ArgumentParser:
7884
def main(argv: list[str] | None = None) -> int:
7985
parser = build_parser()
8086
args = parser.parse_args(argv)
81-
state_dir = Path(args.state_dir).expanduser()
87+
explicit = _has_explicit_state_dir(argv or sys.argv[1:])
88+
89+
if not explicit and args.command == "profile" and args.profile_command == "create":
90+
mirror_base = Path(args.mirror_dir).expanduser().resolve()
91+
state_dir = state_dir_for_base(mirror_base)
92+
old_state_dir = Path(args.state_dir).expanduser()
93+
if old_state_dir != state_dir and (old_state_dir / "state.db").exists():
94+
relocate_state(old_state_dir, state_dir)
95+
write_pointer_file(mirror_base)
96+
elif not explicit and args.command == "clone-restore":
97+
mirror_base = Path(args.mirror_dir).expanduser().resolve()
98+
state_dir = state_dir_for_base(mirror_base)
99+
write_pointer_file(mirror_base)
100+
else:
101+
state_dir = Path(args.state_dir).expanduser()
102+
82103
repository = Repository(state_dir)
83104
blob_store = BlobStore(state_dir)
84105
transport = ADBTransport()
@@ -126,7 +147,7 @@ def main(argv: list[str] | None = None) -> int:
126147
if args.command == "tui":
127148
from .tui import run_tui
128149

129-
return run_tui(repository, blob_store, transport)
150+
return run_tui(repository, blob_store, transport, state_dir_explicit=explicit)
130151
except (ValueError, TransportError) as exc:
131152
parser.exit(status=2, message=f"error: {exc}\n")
132153
finally:

src/androidmigrate/config.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,69 @@
22

33
import os
44
import re
5+
import shutil
56
from pathlib import Path, PurePosixPath
67

78
from .models import Profile, SyncRoot
89

910

1011
ENV_STATE_DIR = "ANDROIDMIGRATE_HOME"
12+
POINTER_DIR = Path.home() / ".config" / "androidmigrate"
13+
POINTER_FILE = POINTER_DIR / "home"
14+
STATE_SUBDIR = ".androidmigrate"
15+
16+
17+
def read_pointer_file() -> Path | None:
18+
try:
19+
text = POINTER_FILE.read_text().strip()
20+
if text:
21+
return Path(text)
22+
except (OSError, ValueError):
23+
pass
24+
return None
25+
26+
27+
def write_pointer_file(base_path: Path) -> None:
28+
POINTER_DIR.mkdir(parents=True, exist_ok=True)
29+
POINTER_FILE.write_text(str(base_path.resolve()) + "\n")
30+
31+
32+
def state_dir_for_base(base_path: Path) -> Path:
33+
return base_path / STATE_SUBDIR
34+
35+
36+
def relocate_state(old_state_dir: Path, new_state_dir: Path) -> None:
37+
old_state_dir = old_state_dir.resolve()
38+
new_state_dir = new_state_dir.resolve()
39+
if old_state_dir == new_state_dir:
40+
return
41+
old_db = old_state_dir / "state.db"
42+
if not old_db.exists():
43+
return
44+
new_db = new_state_dir / "state.db"
45+
if new_db.exists():
46+
raise FileExistsError(f"State database already exists at {new_db}")
47+
new_state_dir.mkdir(parents=True, exist_ok=True)
48+
for suffix in ("", "-wal", "-shm", "-journal"):
49+
src = old_state_dir / f"state.db{suffix}"
50+
if src.exists():
51+
shutil.move(str(src), str(new_state_dir / src.name))
52+
old_blobs = old_state_dir / "blobs"
53+
if old_blobs.exists():
54+
shutil.move(str(old_blobs), str(new_state_dir / "blobs"))
55+
try:
56+
old_state_dir.rmdir()
57+
except OSError:
58+
pass
1159

1260

1361
def get_state_dir() -> Path:
1462
raw = os.environ.get(ENV_STATE_DIR)
1563
if raw:
1664
return Path(raw).expanduser()
65+
pointer = read_pointer_file()
66+
if pointer:
67+
return state_dir_for_base(pointer)
1768
return Path.cwd() / ".androidmigrate"
1869

1970

src/androidmigrate/tui.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from datetime import datetime
88
from pathlib import Path
99

10+
from .config import relocate_state, state_dir_for_base, write_pointer_file
1011
from .mirror_path import autocomplete_directory_input, validate_target_mirror_path
1112
from .models import PROFILE_ACTIVE, PROFILE_PENDING_CLONE
1213
from .root_manager import ROOT_BROWSER, RootManagerController
@@ -460,6 +461,7 @@ def __init__(self, stdscr, repository, transport, theme) -> None:
460461
self.repository = repository
461462
self.transport = transport
462463
self.theme = theme
464+
self.created_mirror_base: Path | None = None
463465
self.state = CreateProfileState()
464466
self.state.existing_names = {p.name for p in repository.list_profiles()}
465467
self._refresh_devices()
@@ -712,6 +714,7 @@ def _submit(self) -> str | None:
712714
self.state.status_message = f"Unable to create mirror directory: {exc}"
713715
return None
714716

717+
self.created_mirror_base = Path(self.state.mirror_text.strip()).expanduser().resolve()
715718
return f"Saved new profile {name}"
716719

717720
def _show_confirmation(self, title: str, lines: list[str]) -> bool:
@@ -838,12 +841,14 @@ def _draw_banner(self, banner: Banner) -> None:
838841

839842

840843
class DashboardApp:
841-
def __init__(self, stdscr, repository: Repository, blob_store: BlobStore, transport: ADBTransport) -> None:
844+
def __init__(self, stdscr, repository: Repository, blob_store: BlobStore, transport: ADBTransport, state_dir_explicit: bool = False) -> None:
842845
self.stdscr = stdscr
843846
self.repository = repository
847+
self.blob_store = blob_store
844848
self.transport = transport
845849
self.engine = SyncEngine(repository, blob_store, transport)
846850
self.state = DashboardState()
851+
self.state_dir_explicit = state_dir_explicit
847852
self.devices = []
848853
self.profiles = []
849854
self.theme = None
@@ -858,6 +863,22 @@ def refresh(self) -> None:
858863
else:
859864
self.state.selected_profile = 0
860865

866+
def _maybe_relocate_state(self, mirror_base: Path) -> None:
867+
if self.state_dir_explicit:
868+
return
869+
new_state_dir = state_dir_for_base(mirror_base)
870+
old_state_dir = self.repository.state_dir
871+
if old_state_dir.resolve() == new_state_dir.resolve():
872+
write_pointer_file(mirror_base)
873+
return
874+
self.repository.close()
875+
relocate_state(old_state_dir, new_state_dir)
876+
write_pointer_file(mirror_base)
877+
self.repository = Repository(new_state_dir)
878+
new_blob_store = BlobStore(new_state_dir)
879+
self.blob_store = new_blob_store
880+
self.engine = SyncEngine(self.repository, new_blob_store, self.transport)
881+
861882
def run(self) -> int:
862883
curses.curs_set(0)
863884
self.stdscr.keypad(True)
@@ -1141,7 +1162,10 @@ def open_root_manager(self) -> None:
11411162
self.state.status_message = status
11421163

11431164
def create_profile(self) -> None:
1144-
status = CreateProfileScreen(self.stdscr, self.repository, self.transport, self.theme).run()
1165+
screen = CreateProfileScreen(self.stdscr, self.repository, self.transport, self.theme)
1166+
status = screen.run()
1167+
if status and screen.created_mirror_base is not None:
1168+
self._maybe_relocate_state(screen.created_mirror_base)
11451169
self.refresh()
11461170
if status:
11471171
self.state.status_message = status
@@ -1309,9 +1333,13 @@ def show_popup(self, title: str, lines: list[str]) -> None:
13091333
del win
13101334

13111335

1312-
def run_tui(repository: Repository, blob_store: BlobStore, transport: ADBTransport) -> int:
1336+
def run_tui(repository: Repository, blob_store: BlobStore, transport: ADBTransport, state_dir_explicit: bool = False) -> int:
13131337
def _wrapped(stdscr) -> int:
1314-
app = DashboardApp(stdscr, repository, blob_store, transport)
1315-
return app.run()
1338+
app = DashboardApp(stdscr, repository, blob_store, transport, state_dir_explicit)
1339+
try:
1340+
return app.run()
1341+
finally:
1342+
if app.repository is not repository:
1343+
app.repository.close()
13161344

13171345
return curses.wrapper(_wrapped)

tests/test_config.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
from androidmigrate import config
8+
9+
10+
@pytest.fixture(autouse=True)
11+
def _isolate_pointer(tmp_path, monkeypatch):
12+
pointer_dir = tmp_path / "config" / "androidmigrate"
13+
monkeypatch.setattr(config, "POINTER_DIR", pointer_dir)
14+
monkeypatch.setattr(config, "POINTER_FILE", pointer_dir / "home")
15+
16+
17+
def test_read_pointer_file_returns_none_when_missing():
18+
assert config.read_pointer_file() is None
19+
20+
21+
def test_write_and_read_pointer_file_roundtrip(tmp_path):
22+
base = tmp_path / "backups"
23+
base.mkdir()
24+
config.write_pointer_file(base)
25+
result = config.read_pointer_file()
26+
assert result == base.resolve()
27+
28+
29+
def test_get_state_dir_prefers_env_over_pointer(tmp_path, monkeypatch):
30+
env_dir = tmp_path / "env_state"
31+
pointer_base = tmp_path / "pointer_base"
32+
pointer_base.mkdir()
33+
config.write_pointer_file(pointer_base)
34+
monkeypatch.setenv("ANDROIDMIGRATE_HOME", str(env_dir))
35+
assert config.get_state_dir() == env_dir
36+
37+
38+
def test_get_state_dir_uses_pointer_when_no_env(tmp_path, monkeypatch):
39+
monkeypatch.delenv("ANDROIDMIGRATE_HOME", raising=False)
40+
base = tmp_path / "backups"
41+
base.mkdir()
42+
config.write_pointer_file(base)
43+
assert config.get_state_dir() == base / ".androidmigrate"
44+
45+
46+
def test_get_state_dir_falls_back_to_cwd(monkeypatch):
47+
monkeypatch.delenv("ANDROIDMIGRATE_HOME", raising=False)
48+
cwd = Path.cwd()
49+
assert config.get_state_dir() == cwd / ".androidmigrate"
50+
51+
52+
def test_state_dir_for_base(tmp_path):
53+
assert config.state_dir_for_base(tmp_path / "backups") == tmp_path / "backups" / ".androidmigrate"
54+
55+
56+
def test_relocate_state_moves_db_and_blobs(tmp_path):
57+
old = tmp_path / "old_state"
58+
old.mkdir()
59+
(old / "state.db").write_text("db content")
60+
(old / "state.db-wal").write_text("wal content")
61+
blobs = old / "blobs" / "ab"
62+
blobs.mkdir(parents=True)
63+
(blobs / "abcdef").write_text("blob data")
64+
65+
new = tmp_path / "new_state"
66+
config.relocate_state(old, new)
67+
68+
assert (new / "state.db").read_text() == "db content"
69+
assert (new / "state.db-wal").read_text() == "wal content"
70+
assert (new / "blobs" / "ab" / "abcdef").read_text() == "blob data"
71+
assert not (old / "state.db").exists()
72+
assert not (old / "blobs").exists()
73+
74+
75+
def test_relocate_state_noop_same_dir(tmp_path):
76+
state = tmp_path / "state"
77+
state.mkdir()
78+
(state / "state.db").write_text("db content")
79+
config.relocate_state(state, state)
80+
assert (state / "state.db").read_text() == "db content"
81+
82+
83+
def test_relocate_state_noop_no_db(tmp_path):
84+
old = tmp_path / "old"
85+
old.mkdir()
86+
new = tmp_path / "new"
87+
config.relocate_state(old, new)
88+
assert not new.exists()
89+
90+
91+
def test_relocate_state_raises_when_dest_has_db(tmp_path):
92+
old = tmp_path / "old"
93+
old.mkdir()
94+
(old / "state.db").write_text("old db")
95+
new = tmp_path / "new"
96+
new.mkdir()
97+
(new / "state.db").write_text("existing db")
98+
with pytest.raises(FileExistsError):
99+
config.relocate_state(old, new)

0 commit comments

Comments
 (0)