Live QA against the beta forge failed its first push with 'Failed to
authenticate user' while the config held a valid token. Cause: credential.helper
is a list accumulated across system/global/repo config, and this machine has
osxkeychain (Xcode gitconfig) plus store (~/.gitconfig). A stale entry for the
forge host answered before our helper.
The same list is a token leak in the other direction: git calls approve on
every helper after a successful auth, so 'store' writes the forge token into
~/.git-credentials in plaintext -- undoing the 0600 config and the
no-token-in-URL rule. Confirmed accidentally during QA when a verification
clone with a URL-embedded token re-created exactly that entry.
Fix: set an empty credential.helper first (git reads that as 'forget the
inherited list'), then add ours -- in install_credential_helper and in the
git clone inside get.
2 regression tests, one of which drives 'git credential fill' against a
poisoned outer helper. 143 tests.
Two modes per linked folder. 'mirror' keeps today's behaviour for a plain
folder that add turned into a repo. 'snapshot' is new and is for a folder
that already had a git history: nothing is ever committed on the user's
behalf, and instead each pass builds a commit object from the working tree
via a scratch index + commit-tree and pushes it to
refs/granthi-backup/<device>/<ts>. HEAD, the index and every file stay
exactly as the user left them, so uncommitted, unmerged, half-finished work
leaves the machine with a timestamp to restore from.
Verified on the beta forge (Gitea 1.27.2) that a custom ref namespace is
accepted, readable via ls-remote, and absent from the branch list.
Also: retention (all for 24h, hourly for 7d, daily beyond; unparseable
timestamps kept), snapshots/restore commands, restore never writing over the
working tree, get --all bounded by what the forge grants, list <pattern>,
.gitignore seeding, an add size guard, and a persisted device_id.
141 tests (was 108).
- parse_repo_arg(): validate <name> / <owner>/<name> against a strict segment
pattern. Not shell injection (argv list, no shell), but '?', '#', '..', an
encoded slash or an extra path component could redirect the clone URL and
the remote that gets persisted. Validate rather than quote — the forge's
own naming rules are this narrow anyway.
- list now keys local folders on full_name, not bare name: an account that
can see alice/cloud and bob/cloud showed BOTH as local when one was. `get`
and `add` both record full_name; older entries fall back to <login>/<name>.
- list_repos truncation was off by one page: a repo total that is an exact
multiple of the page size ends on a full page and was reported as
truncated. One sentinel fetch past the cap separates complete from
truncated.
Tests 65 -> 69, including hostile repo arguments and the exact-multiple case.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
`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