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
+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`.
`--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).