Skip to content

Commit 9069204

Browse files
committed
fix: move backup_repos.py into repo so CI tests can import it
backup_repos.py lived outside the repo at ~/Documents/localpibox, but test_localpibox.py imported it, so the CI unit-test job failed with ModuleNotFoundError. Move it to scripts/ (importable on the checked-out tree), point the top-level backup-repos.sh wrapper at the repo copy, and keep snapshots in the wrapper's ../backups dir as before.
1 parent 4842ae6 commit 9069204

2 files changed

Lines changed: 143 additions & 2 deletions

File tree

scripts/backup_repos.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
#!/usr/bin/env python3
2+
"""backup-repos — mirror all GitHub repos to a timestamped snapshot dir.
3+
4+
Python port of backup-repos.sh. Uses the `gh` CLI (already authenticated) to
5+
list repos and `git clone --mirror` to snapshot each one.
6+
7+
Usage:
8+
backup-repos.py [--out DIR] [--account OWNER] [--limit N]
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import argparse
14+
import datetime
15+
import re
16+
import sys
17+
from pathlib import Path
18+
19+
_SELF_DIR = Path(__file__).resolve().parent
20+
for _c in (_SELF_DIR, _SELF_DIR.parent / "scripts"):
21+
if (_c / "localpibox").is_dir():
22+
sys.path.insert(0, str(_c))
23+
break
24+
25+
from localpibox import log # noqa: E402
26+
from localpibox.cli import add_common_args, console_from_args, install_sigpipe_handler # noqa: E402
27+
from localpibox.run import run_cmd, which # noqa: E402
28+
29+
_REPO_RE = re.compile(r"[\w.-]+/[\w.-]+")
30+
31+
32+
def parse_repo_list(text: str) -> list[str]:
33+
"""Parse `gh repo list` TSV output -> ``owner/name`` strings.
34+
35+
Ignores blank lines and any row whose first column is not a valid
36+
``owner/name`` pair (headers, error text, extra columns).
37+
"""
38+
repos: list[str] = []
39+
for line in text.splitlines():
40+
if not line.strip():
41+
continue
42+
first = line.split("\t", 1)[0].strip()
43+
if _REPO_RE.fullmatch(first):
44+
repos.append(first)
45+
return repos
46+
47+
48+
def list_repos(
49+
account: str | None = None,
50+
*,
51+
limit: int = 500,
52+
gh: str = "gh",
53+
runner=None,
54+
) -> list[str]:
55+
"""List repos for *account* (or the authenticated user when None)."""
56+
runner = runner or run_cmd
57+
cmd = [gh, "repo", "list", "--limit", str(limit)]
58+
if account:
59+
cmd.insert(3, account)
60+
out, err, code = runner(cmd, timeout=120)
61+
if code:
62+
raise RuntimeError(f"`gh repo list` failed: {err.strip() or out.strip()}")
63+
return parse_repo_list(out)
64+
65+
66+
def mirror_repo(
67+
repo: str,
68+
dest: str | Path,
69+
*,
70+
git: str = "git",
71+
runner=None,
72+
) -> bool:
73+
"""Mirror-clone *repo* into *dest*; True on success."""
74+
runner = runner or run_cmd
75+
out, err, code = runner(
76+
[git, "clone", "--mirror", f"https://github.com/{repo}", str(dest)],
77+
timeout=600,
78+
)
79+
if code:
80+
log.warn(f" failed to clone {repo}: {err.strip() or out.strip()}")
81+
return False
82+
return True
83+
84+
85+
def default_out_dir() -> Path:
86+
"""Default snapshot root: ``~/.localpibox/backups`` (matches lpb state dirs)."""
87+
return Path.home() / ".localpibox" / "backups"
88+
89+
90+
def main(argv: list[str] | None = None) -> int:
91+
install_sigpipe_handler()
92+
parser = argparse.ArgumentParser(prog="backup-repos", description=__doc__)
93+
add_common_args(parser)
94+
parser.add_argument(
95+
"--out", default=None,
96+
help="snapshot dir (default: ~/.localpibox/backups/snapshot_<timestamp>)",
97+
)
98+
parser.add_argument(
99+
"--account", default=None,
100+
help="GitHub owner to back up (default: authenticated user)",
101+
)
102+
parser.add_argument("--limit", type=int, default=500, help="max repos to fetch")
103+
args = parser.parse_args(argv)
104+
cons = console_from_args(args)
105+
106+
if not which("gh"):
107+
log.error("gh CLI not found — install it and run `gh auth login` first")
108+
return 1
109+
if not which("git"):
110+
log.error("git not found")
111+
return 1
112+
113+
out_dir = (
114+
Path(args.out)
115+
if args.out
116+
else default_out_dir()
117+
/ f"snapshot_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
118+
)
119+
out_dir.mkdir(parents=True, exist_ok=True)
120+
cons.info(f"Backing up repos to {out_dir}")
121+
122+
try:
123+
repos = list_repos(args.account, limit=args.limit)
124+
except RuntimeError as exc:
125+
log.error(str(exc))
126+
return 1
127+
128+
ok = failed = 0
129+
for repo in repos:
130+
dest = out_dir / f"{repo.split('/', 1)[1]}.git"
131+
cons.info(f"Backing up: {repo}")
132+
if mirror_repo(repo, dest):
133+
ok += 1
134+
else:
135+
failed += 1
136+
137+
cons.done(f"Backup complete: {ok} backed up, {failed} failed -> {out_dir}")
138+
return 1 if failed else 0
139+
140+
141+
if __name__ == "__main__":
142+
raise SystemExit(main())

scripts/test_localpibox.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,8 @@
2929
SCRIPTS_DIR = Path(__file__).resolve().parent
3030
DEVSTACK_DIR = SCRIPTS_DIR.parent
3131
SUPPORT_DIR = DEVSTACK_DIR / "support"
32-
PARENT_DIR = DEVSTACK_DIR.parent # /home/sp/Documents/localpibox
3332

34-
for _d in (str(SCRIPTS_DIR), str(SUPPORT_DIR), str(PARENT_DIR)):
33+
for _d in (str(SCRIPTS_DIR), str(SUPPORT_DIR)):
3534
if _d not in sys.path:
3635
sys.path.insert(0, _d)
3736

0 commit comments

Comments
 (0)