Merge feat/list-get: the download half of granthi-sync

list + get complete the onboarding flow (see my repos -> download -> work ->
sync). Both read the forge directly with the scoped user token, so no
granthi-link endpoint, service restart, or VPS config change is involved.
Codex-reviewed twice: 3 [P2] findings fixed, re-review clean. 69 tests pass.

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:29:44 -04:00
co-authored by Claude Opus 5
4 changed files with 353 additions and 6 deletions
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+21 -2
View File
@@ -114,6 +114,20 @@ Empirically verified mechanics on Gitea **1.27.1** (beta forge):
(`authorization_pending`/`slow_down` handled), then calls `/v1/link`. (`authorization_pending`/`slow_down` handled), then calls `/v1/link`.
`--token` skips the device flow with a ready Zitadel token (headless/dev). `--token` skips the device flow with a ready Zitadel token (headless/dev).
Result stored in `~/.granthi-sync/config.json` (0600). 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 <repo|owner/repo> [--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 <folder> [--name N] [--private|--public]``git init -b main` if * `add <folder> [--name N] [--private|--public]``git init -b main` if
needed, creates the cloud repo via `/v1/repos`, adds remote `granthi`, needed, creates the cloud repo via `/v1/repos`, adds remote `granthi`,
initial commit + push. The token is delivered by a **git credential 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 ## 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 logic against real temp git repos (including "diverged never touches the
remote"), config 0600 handling (including umask-proof creation and a remote"), config 0600 handling (including umask-proof creation and a
no-chmod guard), credential-helper quoting/injection, mocked device-flow 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 in-process stub playing Zitadel + Gitea, all identity-binding rules
(collision 409, verified-email adoption, deleted-login re-create/refuse, (collision 409, verified-email adoption, deleted-login re-create/refuse,
concurrent-create race, corrupt-state fail-closed), the test_mode env 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 * Live E2E against the beta forge is recorded in the delivery notes
(link → add → watch ff/push → forced divergence → DIVERGED skip verified (link → add → watch ff/push → forced divergence → DIVERGED skip verified
via API, remote sha untouched). via API, remote sha untouched).
+148 -4
View File
@@ -7,6 +7,13 @@ Commands:
the headless path), then POST /v1/link on the granthi-link service. the headless path), then POST /v1/link on the granthi-link service.
Stores {server, gitea_base, login, token, token_name} in Stores {server, gitea_base, login, token, token_name} in
~/.granthi-sync/config.json (0600). ~/.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] add <folder> [--name N] [--private/--public]
git init (branch main) if needed, create the cloud repo via git init (branch main) if needed, create the cloud repo via
/v1/repos, add remote 'granthi', initial commit + push. The token /v1/repos, add remote 'granthi', initial commit + push. The token
@@ -39,7 +46,7 @@ import urllib.parse
import urllib.request import urllib.request
from datetime import datetime, timezone 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_DIR = os.path.expanduser(os.environ.get("GRANTHI_SYNC_HOME", "~/.granthi-sync"))
CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json") 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" DEFAULT_SERVER = "http://100.111.127.127:3042"
DEVICE_SCOPE = "openid profile email" 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): def log(msg):
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -292,10 +302,136 @@ def cmd_link(args):
return 0 return 0
def cmd_add(args): def require_linked(cfg):
cfg = load_config() """Every forge-touching command fails the same way on an unlinked box."""
if "token" not in cfg: if "token" not in cfg:
raise SystemExit("not linked yet -- run: granthi-sync link") 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'.
"""
def fetch(page):
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}")
return resp if isinstance(resp, list) else resp.get("data", [])
repos, page = [], 1
while page <= FORGE_MAX_PAGES:
batch = fetch(page)
repos.extend(batch)
if len(batch) < FORGE_PAGE_LIMIT:
return repos, False
page += 1
# Every page up to the cap was full, which does not by itself mean more
# exist: a total that is an exact multiple of the page size ends on a
# full page. One sentinel fetch tells "complete" from "truncated".
return repos, bool(fetch(FORGE_MAX_PAGES + 1))
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?".
# Keyed on full_name, not name: an account that can see both alice/cloud
# and bob/cloud would otherwise show both as local when only one is.
# Folders written before full_name was recorded fall back to <login>/<name>.
local_by_full = {}
for folder, m in cfg.get("folders", {}).items():
full = m.get("full_name") or f"{cfg.get('login')}/{m.get('name')}"
local_by_full[full] = folder
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_full.get(r.get("full_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
_SEGMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
def parse_repo_arg(repo, login):
"""'<name>' or '<owner>/<name>' -> full_name. Rejects anything else.
The result is concatenated into a URL and a filesystem path, so a segment
carrying '?', '#', '..', an encoded slash, or an extra path component
could redirect the clone or the remote that gets persisted. Validate
rather than quote: the forge's own naming rules are this narrow anyway.
"""
parts = repo.split("/") if "/" in repo else [login, repo]
if len(parts) != 2 or not all(_SEGMENT_RE.match(p) and p not in (".", "..")
for p in parts):
raise SystemExit(
f"invalid repo {repo!r}: expected <name> or <owner>/<name> using "
f"letters, digits, '.', '_' or '-'")
return "/".join(parts)
def cmd_get(args):
cfg = require_linked(load_config())
full_name = parse_repo_arg(args.repo, cfg["login"])
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, "full_name": full_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) folder = os.path.abspath(args.folder)
if not os.path.isdir(folder): if not os.path.isdir(folder):
raise SystemExit(f"no such folder: {folder}") raise SystemExit(f"no such folder: {folder}")
@@ -320,7 +456,7 @@ def cmd_add(args):
autocommit(folder) autocommit(folder)
git(folder, "push", "-u", "granthi", "main") git(folder, "push", "-u", "granthi", "main")
cfg.setdefault("folders", {})[folder] = { cfg.setdefault("folders", {})[folder] = {
"name": name, "branch": "main", "name": name, "full_name": f"{cfg['login']}/{name}", "branch": "main",
"last_sync": datetime.now(timezone.utc).isoformat(timespec="seconds"), "last_sync": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"diverged": False} "diverged": False}
save_config(cfg) save_config(cfg)
@@ -407,6 +543,14 @@ def main(argv=None):
sp.add_argument("--device", default=os.uname().nodename.split(".")[0]) sp.add_argument("--device", default=os.uname().nodename.split(".")[0])
sp.set_defaults(fn=cmd_link) 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 = sub.add_parser("add", help="link a folder and push it to the cloud")
sp.add_argument("folder") sp.add_argument("folder")
sp.add_argument("--name") sp.add_argument("--name")
+182
View File
@@ -1,6 +1,8 @@
"""Unit tests for the granthi-sync client: autocommit / ff / diverged logic, """Unit tests for the granthi-sync client: autocommit / ff / diverged logic,
config handling, device-flow polling (mocked HTTP). Stdlib unittest only.""" config handling, device-flow polling (mocked HTTP). Stdlib unittest only."""
import argparse
import io
import json import json
import os import os
import shlex import shlex
@@ -248,5 +250,185 @@ class TestCredentialHelper(GitScenarioBase):
self.assertNotIn("password=", out.stdout) 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_exact_multiple_of_page_size_is_not_truncated(self):
"""Every page full up to the cap does not imply more exist -- a total
that is an exact multiple ends on a full page. The sentinel fetch
past the cap is what tells the two apart."""
def fake_http(method, url, headers=None, **kw):
page = int(url.split("page=")[1].split("&")[0])
return 200, self._page(page, 0 if page > client.FORGE_MAX_PAGES
else 50)
with mock.patch.object(client, "http_json", fake_http):
repos, truncated = client.list_repos(self.cfg)
self.assertFalse(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 <base>/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_rejects_hostile_repo_arguments(self):
for bad in ["../../etc/passwd", "alice/cloud?x=1", "alice/cloud#frag",
"a/b/c", "..", "-flag", "alice/../bob", "cloud%2f..",
"", "alice/"]:
with self.subTest(repo=bad):
with self.assertRaises(SystemExit):
client.cmd_get(argparse.Namespace(repo=bad, into=None))
def test_get_records_full_name_so_list_matches_the_right_owner(self):
dest = os.path.join(self.tmp, "pulled4")
client.cmd_get(argparse.Namespace(repo="cloud", into=dest))
meta = client.load_config()["folders"][os.path.abspath(dest)]
self.assertEqual(meta["full_name"], "alice/cloud")
def test_list_does_not_mark_a_same_named_other_owner_repo_as_local(self):
dest = os.path.join(self.tmp, "pulled5")
client.cmd_get(argparse.Namespace(repo="cloud", into=dest))
cfg = client.load_config()
repos = [{"name": "cloud", "full_name": "alice/cloud", "private": True,
"updated_at": "2026-08-20T00:00:00Z"},
{"name": "cloud", "full_name": "bob/cloud", "private": False,
"updated_at": "2026-08-20T00:00:00Z"}]
with mock.patch.object(client, "list_repos",
lambda c: (repos, False)), \
mock.patch("sys.stdout", new_callable=io.StringIO) as out:
client.cmd_list(argparse.Namespace())
lines = {l.split()[0]: l for l in out.getvalue().splitlines()
if l.strip()}
self.assertIn(os.path.abspath(dest), lines["alice/cloud"])
self.assertNotIn(os.path.abspath(dest), lines["bob/cloud"])
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__": if __name__ == "__main__":
unittest.main() unittest.main()