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:
Nirav Patel
2026-08-22 14:25:03 -04:00
co-authored by Claude Opus 5
parent 14ccbd59f0
commit 88604a90f6
4 changed files with 275 additions and 5 deletions
+116 -3
View File
@@ -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")