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
@@ -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