From 88604a90f6a9177fd93d9eab4f634da76245f46c Mon Sep 17 00:00:00 2001 From: Nirav Patel Date: Sat, 22 Aug 2026 14:24:58 -0400 Subject: [PATCH] =?UTF-8?q?feat(client):=20list=20+=20get=20=E2=80=94=20th?= =?UTF-8?q?e=20download=20half=20of=20the=20sync=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg --- .gitignore | 2 + README.md | 23 +++++- client/granthi_sync_client.py | 119 ++++++++++++++++++++++++++++- tests/test_client.py | 136 ++++++++++++++++++++++++++++++++++ 4 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 8cba3f7..b92b09e 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,20 @@ Empirically verified mechanics on Gitea **1.27.1** (beta forge): (`authorization_pending`/`slow_down` handled), then calls `/v1/link`. `--token` skips the device flow with a ready Zitadel token (headless/dev). Result stored in `~/.granthi-sync/config.json` (0600). +* `list` — every repo the linked token can see, with the local folder each + is already synced to. Reads `GET /api/v1/user/repos` on the forge + **directly** with the scoped user token — no granthi-link round-trip, so + the read path needs no service change. Pagination is followed to a short + page; if the `FORGE_MAX_PAGES` guard trips, the output says the list is + incomplete rather than letting a bounded page read as the whole set. +* `get [--into DIR]` — the download half of `add`. 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; git also persists it into the new config), then + registers the folder in `config.json` with the same shape `add` writes — + so a cloned repo is picked up by `watch` immediately. Refuses a non-empty + destination. Branch is read with `symbolic-ref` (an empty repo has an + unborn HEAD) and falls back to `main`. * `add [--name N] [--private|--public]` — `git init -b main` if needed, creates the cloud repo via `/v1/repos`, adds remote `granthi`, initial commit + push. The token is delivered by a **git credential @@ -129,7 +143,7 @@ Empirically verified mechanics on Gitea **1.27.1** (beta forge): ## Tests -* `python3 -m unittest discover -s tests` — 53 tests: autocommit/ff/diverged +* `python3 -m unittest discover -s tests` — 65 tests: autocommit/ff/diverged logic against real temp git repos (including "diverged never touches the remote"), config 0600 handling (including umask-proof creation and a no-chmod guard), credential-helper quoting/injection, mocked device-flow @@ -137,7 +151,12 @@ Empirically verified mechanics on Gitea **1.27.1** (beta forge): in-process stub playing Zitadel + Gitea, all identity-binding rules (collision 409, verified-email adoption, deleted-login re-create/refuse, concurrent-create race, corrupt-state fail-closed), the test_mode env - gate, config-permission refusal, and the 64 KB body cap. + gate, config-permission refusal, and the 64 KB body cap. The `list`/`get` + set covers pagination-to-a-short-page, truncation being reported rather + than hidden, HTTP errors being fatal instead of a silent empty list, + non-empty-destination refusal, owner-qualified names, unborn-HEAD branch + fallback, no token in the remote URL, and — the one that matters — that a + `get` folder is actually picked up by a subsequent `sync_folder` pass. * Live E2E against the beta forge is recorded in the delivery notes (link → add → watch ff/push → forced divergence → DIVERGED skip verified via API, remote sha untouched). diff --git a/client/granthi_sync_client.py b/client/granthi_sync_client.py index d4f3328..5e467fd 100644 --- a/client/granthi_sync_client.py +++ b/client/granthi_sync_client.py @@ -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 [--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 [--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: ./)") + 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") diff --git a/tests/test_client.py b/tests/test_client.py index 5070010..678211e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,7 @@ """Unit tests for the granthi-sync client: autocommit / ff / diverged logic, config handling, device-flow polling (mocked HTTP). Stdlib unittest only.""" +import argparse import json import os import shlex @@ -248,5 +249,140 @@ class TestCredentialHelper(GitScenarioBase): self.assertNotIn("password=", out.stdout) +class TestListRepos(unittest.TestCase): + """Pagination must be followed, and a bounded page must never be + presented as the complete set.""" + + def setUp(self): + self.cfg = {"gitea_base": "http://100.111.127.127:3041", + "login": "alice", "token": "sekrit", "folders": {}} + + @staticmethod + def _page(n, count): + return [{"name": f"r{n}-{i}", "full_name": f"alice/r{n}-{i}", + "private": True, "updated_at": "2026-08-20T00:00:00Z"} + for i in range(count)] + + def test_follows_pagination_until_short_page(self): + calls = [] + + def fake_http(method, url, headers=None, **kw): + calls.append((method, url, headers)) + page = int(url.split("page=")[1].split("&")[0]) + # two full pages, then a short one ends the walk + return 200, self._page(page, 50 if page <= 2 else 7) + + with mock.patch.object(client, "http_json", fake_http): + repos, truncated = client.list_repos(self.cfg) + self.assertEqual(len(repos), 107) + self.assertFalse(truncated) + self.assertEqual(len(calls), 3) + self.assertEqual(calls[0][2]["Authorization"], "token sekrit") + self.assertIn("/api/v1/user/repos", calls[0][1]) + + def test_truncation_is_reported_not_hidden(self): + with mock.patch.object(client, "http_json", + lambda *a, **k: (200, self._page(1, 50))): + repos, truncated = client.list_repos(self.cfg) + self.assertTrue(truncated) + self.assertEqual(len(repos), client.FORGE_MAX_PAGES * 50) + + def test_http_error_is_fatal_not_silent_empty(self): + with mock.patch.object(client, "http_json", + lambda *a, **k: (401, {"error": "bad token"})): + with self.assertRaises(SystemExit): + client.list_repos(self.cfg) + + def test_list_unlinked_exits_like_add(self): + with mock.patch.object(client, "load_config", lambda: {}): + with self.assertRaises(SystemExit) as cm: + client.cmd_list(argparse.Namespace()) + self.assertIn("not linked", str(cm.exception)) + + +class TestGet(GitScenarioBase): + """`get` is only useful if `watch` subsequently picks the folder up.""" + + def setUp(self): + super().setUp() + # a bare repo standing in for the forge, at /alice/cloud.git + self.forge = os.path.join(self.tmp, "forge") + self.remote_path = os.path.join(self.forge, "alice", "cloud.git") + os.makedirs(os.path.dirname(self.remote_path)) + subprocess.run(["git", "init", "--bare", "-b", "main", + self.remote_path], check=True, capture_output=True, + env=GIT_ENV) + seed = os.path.join(self.tmp, "seed") + subprocess.run(["git", "clone", self.remote_path, seed], check=True, + capture_output=True, env=GIT_ENV) + run_git(seed, "config", "user.name", "s") + run_git(seed, "config", "user.email", "s@s") + self.write(seed, "hello.txt", "from the forge") + run_git(seed, "add", "-A") + run_git(seed, "commit", "-m", "seed") + run_git(seed, "push", "origin", "main") + client.save_config({"gitea_base": self.forge, "login": "alice", + "token": "sekrit", "folders": {}}) + + def test_get_clones_registers_and_is_watchable(self): + dest = os.path.join(self.tmp, "pulled") + client.cmd_get(argparse.Namespace(repo="cloud", into=dest)) + + # cloned content + self.assertTrue(os.path.exists(os.path.join(dest, "hello.txt"))) + # remote is 'granthi', which is the name sync_folder/watch use + self.assertEqual(run_git(dest, "remote"), "granthi") + # credential helper persisted into the new repo + self.assertIn("git-credential", + run_git(dest, "config", "credential.helper")) + # registered with the same shape `add` writes + meta = client.load_config()["folders"][os.path.abspath(dest)] + self.assertEqual(meta["name"], "cloud") + self.assertEqual(meta["branch"], "main") + self.assertFalse(meta["diverged"]) + self.assertIn("last_sync", meta) + + # the real proof: a watch pass sees it and reports it in-sync + # rather than skipping it as unknown. + run_git(dest, "config", "user.name", "t") + run_git(dest, "config", "user.email", "t@t") + outcome, _ = client.sync_folder(dest, branch=meta["branch"]) + self.assertEqual(outcome, "clean") + + def test_get_accepts_owner_qualified_name(self): + dest = os.path.join(self.tmp, "pulled2") + client.cmd_get(argparse.Namespace(repo="alice/cloud", into=dest)) + self.assertTrue(os.path.exists(os.path.join(dest, "hello.txt"))) + + def test_get_refuses_non_empty_destination(self): + dest = os.path.join(self.tmp, "occupied") + os.makedirs(dest) + self.write(dest, "mine.txt", "do not clobber") + with self.assertRaises(SystemExit): + client.cmd_get(argparse.Namespace(repo="cloud", into=dest)) + self.assertEqual(open(os.path.join(dest, "mine.txt")).read(), + "do not clobber") + + def test_get_unlinked_exits_like_add(self): + with mock.patch.object(client, "load_config", lambda: {}): + with self.assertRaises(SystemExit) as cm: + client.cmd_get(argparse.Namespace(repo="cloud", into=None)) + self.assertIn("not linked", str(cm.exception)) + + def test_get_empty_repo_falls_back_to_main(self): + empty = os.path.join(self.forge, "alice", "blank.git") + subprocess.run(["git", "init", "--bare", "-b", "main", empty], + check=True, capture_output=True, env=GIT_ENV) + dest = os.path.join(self.tmp, "blank") + client.cmd_get(argparse.Namespace(repo="blank", into=dest)) + meta = client.load_config()["folders"][os.path.abspath(dest)] + self.assertEqual(meta["branch"], "main") + + def test_get_never_puts_token_in_remote_url(self): + dest = os.path.join(self.tmp, "pulled3") + client.cmd_get(argparse.Namespace(repo="cloud", into=dest)) + self.assertNotIn("sekrit", run_git(dest, "remote", "get-url", "granthi")) + + if __name__ == "__main__": unittest.main()