* README: new commands (milestone, search code, watch/star, edit-comment, request-review) and the `--json-fields` / `--no-pager` globals. * CLAUDE.md: layout includes json_filter and .forgejo/workflows; test count updated to 75; HTTP retry, JSON projection, pager-on-Unix conventions documented; setup-git hostname-validation invariant called out; auth token TTY guard noted. * docs/architecture.md: HTTP retry section now covers 429 / Retry-After and Request::try_clone; new JSON projection section explains the global --json-fields flow; test strategy mentions remote CI and ignored env-mutating tests. * docs/README.md: link to SECURITY.md and CHANGELOG.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6.9 KiB
Architecture
fj is a thin Rust CLI over Forgejo's /api/v1. The whole binary is
~10k LOC and shapes itself like gh: clap-parsed subcommands, one
typed wrapper per API resource, one HTTP client funnel for everything.
Module graph
┌─────────────┐
│ main.rs │ alias expansion + fj-<x> plugin dispatch
└──────┬──────┘
│
┌──────▼──────┐
│ cli::run │ Ctrl+C race + pager guard
└──────┬──────┘
│
┌─────────────┼────────────────────┐
│ │ │
┌────▼─────┐ ┌─────▼─────┐ ┌──────▼──────┐
│ cli::auth│ │ cli::repo │ ... │ cli::api │ raw escape hatch
└────┬─────┘ └─────┬─────┘ └──────┬──────┘
│ │ │
│ ┌─────▼─────┐ │
│ │ context:: │ -R or git-remote autodetect
│ │resolve_repo│
│ └─────┬─────┘ │
│ │ │
│ ┌─────▼─────┐ │
└──────►│ api::* │◄─────────────┘
│ typed │
│ wrappers │
└─────┬─────┘
│
┌─────▼─────┐
│ client │ one funnel: auth headers, retry, pagination,
│ │ debug logging, custom headers
└─────┬─────┘
│
┌─────▼─────┐
│ reqwest │ rustls-tls, JSON, multipart
└───────────┘
Layering rules
cli::*handles flags, argument validation, output formatting. It never makes HTTP calls directly: it callsapi::*.api::*is one file per Forgejo resource. Each function takes a&Clientand returns typedResult<T>.client::*is the only place that touchesreqwest. All HTTP goes throughClient::request_with_headers, which handles auth headers, retries, debug logging, and custom header merging. There is no parallel HTTP path (other than the multipart asset upload inapi::release::upload_asset, which goes throughclient.http()but still usesclient.token()for auth).config::*is the on-disk shape ofhosts.toml. Tokens are NOT stored here. They live in the OS keychain viaauth::*.git::*shells out to thegitbinary. The only place we call git.
Repo resolution
Every command that operates on a specific repo calls
cli::context::resolve_repo(explicit_repo, explicit_host). The
algorithm:
- If
-R/--repowas given, parse it asowner/name. - Otherwise call
git remote -vand pick the first remote that parses as a Forgejo URL. Preference order isupstream, thenorigin, then any other. - The host comes from
--host, then the detected remote's host, then the configured default host inhosts.toml.
This is the magic that lets fj pr list work without any flags inside
a clone.
HTTP retry
In client::request_with_headers:
- The request is built once and stored as
reqwest::Request. Each retry usesRequest::try_clone. - Idempotent methods (GET, HEAD, OPTIONS, PUT, DELETE) get 3 attempts.
- POST and PATCH get 1 attempt; never retried.
- Backoff is
200ms * 2^attemptbetween retries. - On 5xx or transport error, we retry.
- On 429 we honor the
Retry-Afterheader (capped at 30 s) and fall back to exponential backoff if the header is absent or non-numeric.
Auto-pagination
Client::get_all(path, query, total_limit) follows Link: rel=next
until either total_limit items have accumulated or there are no more
pages. List commands switch to this path automatically when the user
asks for --limit > 50 (Forgejo's per-page cap).
Pager
When cli::run dispatches a command whose output can run long (list,
view, diff, search, status, api), it calls
output::pager::maybe_start(force_disabled). That function:
- Bails if stdout isn't a TTY or
--no-pager/FJ_NO_PAGERis set. - Spawns
$FJ_PAGER/$PAGER/less -FRX. dups our stdout fd (saving it for restore) thendup2s the pager child's stdin fd onto stdout (fd 1).- Returns a
PagerGuardwhoseDropimpl flushes stdout, restores the saved fd, closes the saved fd, and waits on the child.
This means the rest of fj keeps using println!/print! unmodified
even though it's now writing to the pager.
SIGINT
cli::run tokio::select!s the command future against
tokio::signal::ctrl_c(). On SIGINT the command future is dropped,
which propagates to the _pager binding's Drop and tears down the
pager cleanly.
Aliases and extensions
main.rs runs two passes before clap:
- Alias expansion via
cli::alias::expand_argv. Readsaliases.toml. Ifargv[1]matches a defined alias, replaces it with the tokenized expansion. - Plugin dispatch via
main::detect_plugin. Ifargv[1]isn't a known subcommand ANDfj-<name>is on PATH, exec it.
Only if neither pass triggers do we hand off to clap.
JSON projection
A global --json-fields foo,bar flag works on top of any --json
output. cli::run reads it once and stores the parsed list in a
OnceLock in output::mod.rs. Every output::print_json call routes
through json_filter::project before printing, so individual commands
need no awareness of the filter.
Supports dotted paths (owner.login). Missing fields project to null
rather than failing, matching gh.
Test strategy
- Unit tests stay inline next to the code they cover. Pure
functions only (no I/O), with two exceptions marked
#[ignore]:editor_command_uses_visual_firstandedit_text_with_true_returns_initialmutate$EDITOR/$VISUAL, which races with the cargo test harness on macOS. Run them withcargo test -- --ignored --test-threads=1. - Integration tests for HTTP behavior live in
src/client/integration_tests.rsand use a wiremock server. Construct test clients viaClient::for_base_url. - Live E2E lives in
scripts/e2e-smoke.sh, gated onFJ_E2E=1in the pre-push hook. It hits the real Forgejo atrasterhub.comagainststephen/fj-cli-test. - Remote CI at
.forgejo/workflows/ci.ymlruns the same gate (fmt, clippy-D warnings, test, release build) on every push and PR. The local hook is no longer the only gate.