feat(client): list + get — the download half of the sync flow
`add` pushed a local folder up; nothing pulled a cloud repo down, so the "show me my repos -> download -> start working" half of onboarding had no implementation. Both new commands read the forge directly with the scoped user token the link already handed us, so neither needs a granthi-link endpoint, a service restart, or a VPS config edit. - list: GET /api/v1/user/repos, pagination followed to a short page, with a FORGE_MAX_PAGES guard whose trip is REPORTED — a bounded page must never read as "that is all of them". Shows which repos are already local. - get: clones with --origin granthi (the remote name watch looks for) and -c credential.helper (the repo does not exist yet, so the helper cannot be installed first), then registers the folder in the same shape `add` writes — without that, watch silently ignores everything cloned. - require_linked(): one failure mode for every forge-touching command. - VERSION 1.0.0 -> 1.1.0, matching the README and the 1.1.0 hardening. Tests 55 -> 65. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
This commit is contained in:
co-authored by
Claude Opus 5
parent
14ccbd59f0
commit
88604a90f6
@@ -7,6 +7,13 @@ Commands:
|
||||
the headless path), then POST /v1/link on the granthi-link service.
|
||||
Stores {server, gitea_base, login, token, token_name} in
|
||||
~/.granthi-sync/config.json (0600).
|
||||
list
|
||||
Table of every repo the linked token can see on the forge, with the
|
||||
local folder each one is already synced to (if any).
|
||||
get <repo|owner/repo> [--into DIR]
|
||||
Clone a forge repo and register it for `watch` -- the download half
|
||||
of `add`. Remote is named 'granthi' at clone time and the token is
|
||||
supplied by the credential helper, never embedded in the URL.
|
||||
add <folder> [--name N] [--private/--public]
|
||||
git init (branch main) if needed, create the cloud repo via
|
||||
/v1/repos, add remote 'granthi', initial commit + push. The token
|
||||
@@ -39,7 +46,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
VERSION = "1.0.0"
|
||||
VERSION = "1.1.0"
|
||||
|
||||
CONFIG_DIR = os.path.expanduser(os.environ.get("GRANTHI_SYNC_HOME", "~/.granthi-sync"))
|
||||
CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
|
||||
@@ -51,6 +58,9 @@ DEVICE_CLIENT_ID = "386909715541590022"
|
||||
DEFAULT_SERVER = "http://100.111.127.127:3042"
|
||||
DEVICE_SCOPE = "openid profile email"
|
||||
|
||||
FORGE_PAGE_LIMIT = 50
|
||||
FORGE_MAX_PAGES = 40 # 2000 repos; a guard against an unbounded paging loop
|
||||
|
||||
|
||||
def log(msg):
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
@@ -292,10 +302,105 @@ def cmd_link(args):
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_add(args):
|
||||
cfg = load_config()
|
||||
def require_linked(cfg):
|
||||
"""Every forge-touching command fails the same way on an unlinked box."""
|
||||
if "token" not in cfg:
|
||||
raise SystemExit("not linked yet -- run: granthi-sync link")
|
||||
return cfg
|
||||
|
||||
|
||||
def forge_get(cfg, path, params=None):
|
||||
"""Authenticated GET against the forge the link handed us. The client
|
||||
already holds a scoped user token, so read paths need no granthi-link
|
||||
round-trip."""
|
||||
url = f"{cfg['gitea_base'].rstrip('/')}{path}"
|
||||
if params:
|
||||
url = f"{url}?{urllib.parse.urlencode(params)}"
|
||||
return http_json("GET", url,
|
||||
headers={"Authorization": f"token {cfg['token']}"})
|
||||
|
||||
|
||||
def list_repos(cfg):
|
||||
"""Every repo the linked token can see, following pagination.
|
||||
|
||||
Returns (repos, truncated). `truncated` is True when FORGE_MAX_PAGES was
|
||||
hit -- a bounded page must never be presented as 'that is all of them'.
|
||||
"""
|
||||
repos, page = [], 1
|
||||
while page <= FORGE_MAX_PAGES:
|
||||
status, resp = forge_get(cfg, "/api/v1/user/repos",
|
||||
{"page": page, "limit": FORGE_PAGE_LIMIT})
|
||||
if status != 200:
|
||||
raise SystemExit(f"listing repos failed (HTTP {status}): {resp}")
|
||||
batch = resp if isinstance(resp, list) else resp.get("data", [])
|
||||
repos.extend(batch)
|
||||
if len(batch) < FORGE_PAGE_LIMIT:
|
||||
return repos, False
|
||||
page += 1
|
||||
return repos, True
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
cfg = require_linked(load_config())
|
||||
repos, truncated = list_repos(cfg)
|
||||
if not repos:
|
||||
print("no repos on the forge for this account")
|
||||
return 0
|
||||
# Which of them are already on this machine, so the table answers
|
||||
# "what can I pull down?" and not just "what exists?".
|
||||
local_by_name = {m.get("name"): folder
|
||||
for folder, m in cfg.get("folders", {}).items()}
|
||||
rows = [("REPO", "VIS", "UPDATED", "LOCAL FOLDER")]
|
||||
for r in sorted(repos, key=lambda r: r.get("full_name") or ""):
|
||||
rows.append((r.get("full_name") or "?",
|
||||
"private" if r.get("private") else "public",
|
||||
(r.get("updated_at") or "")[:10],
|
||||
local_by_name.get(r.get("name"), "-")))
|
||||
widths = [max(len(row[i]) for row in rows) for i in range(4)]
|
||||
for row in rows:
|
||||
print(" ".join(c.ljust(w) for c, w in zip(row, widths)))
|
||||
if truncated:
|
||||
print(f"\n... more repos exist: stopped after {FORGE_MAX_PAGES} pages "
|
||||
f"of {FORGE_PAGE_LIMIT}. This list is NOT complete.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_get(args):
|
||||
cfg = require_linked(load_config())
|
||||
full_name = args.repo if "/" in args.repo else f"{cfg['login']}/{args.repo}"
|
||||
name = full_name.rsplit("/", 1)[-1]
|
||||
dest = os.path.abspath(args.into or name)
|
||||
if os.path.exists(dest) and os.listdir(dest):
|
||||
raise SystemExit(f"refusing to clone into a non-empty path: {dest}")
|
||||
clone_url = f"{cfg['gitea_base'].rstrip('/')}/{full_name}.git"
|
||||
# -c supplies the helper *during* the clone -- install_credential_helper
|
||||
# cannot run first because the repo does not exist yet -- and git also
|
||||
# persists it into the new repo's config. --origin names the remote
|
||||
# 'granthi' up front so `watch` picks the folder up without a rename.
|
||||
proc = subprocess.run(
|
||||
["git", "clone",
|
||||
"-c", f"credential.helper={credential_helper_value()}",
|
||||
"--origin", "granthi", clone_url, dest],
|
||||
capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
raise SystemExit(f"clone failed: {proc.stderr.strip()}")
|
||||
install_credential_helper(dest) # idempotent; guarantees persistence
|
||||
# symbolic-ref, not rev-parse: an empty repo has an unborn HEAD.
|
||||
rc, branch = git(dest, "symbolic-ref", "--short", "HEAD", check=False)
|
||||
if rc != 0 or not branch:
|
||||
branch = "main"
|
||||
cfg.setdefault("folders", {})[dest] = {
|
||||
"name": name, "branch": branch,
|
||||
"last_sync": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"diverged": False}
|
||||
save_config(cfg)
|
||||
log(f"cloned {full_name} -> {dest} (branch {branch}); "
|
||||
f"`granthi-sync watch` will keep it synced")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_add(args):
|
||||
cfg = require_linked(load_config())
|
||||
folder = os.path.abspath(args.folder)
|
||||
if not os.path.isdir(folder):
|
||||
raise SystemExit(f"no such folder: {folder}")
|
||||
@@ -407,6 +512,14 @@ def main(argv=None):
|
||||
sp.add_argument("--device", default=os.uname().nodename.split(".")[0])
|
||||
sp.set_defaults(fn=cmd_link)
|
||||
|
||||
sp = sub.add_parser("list", help="list forge repos this account can see")
|
||||
sp.set_defaults(fn=cmd_list)
|
||||
|
||||
sp = sub.add_parser("get", help="clone a forge repo and keep it synced")
|
||||
sp.add_argument("repo", help="repo name, or owner/repo")
|
||||
sp.add_argument("--into", help="target folder (default: ./<repo>)")
|
||||
sp.set_defaults(fn=cmd_get)
|
||||
|
||||
sp = sub.add_parser("add", help="link a folder and push it to the cloud")
|
||||
sp.add_argument("folder")
|
||||
sp.add_argument("--name")
|
||||
|
||||
Reference in New Issue
Block a user