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
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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 <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
|
||||
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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 <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_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()
|
||||
|
||||
Reference in New Issue
Block a user