# Backend refactor plan Branch: `audit` @ `6c4cc9f`. Baseline: `cargo check --workspace` clean (~10s incremental), `cargo test --workspace` 111/111 passing. Scope: `signed_core`, `signed_git`, `signed_nostr`, `signed_state`, `utils`, `paths`, `settings` (~13,000 lines). `workspace`, `signed_ui`, `dock`, `desktop` are touched only as call sites. `.delta/` is ignored throughout. ## Goals 1. Behavior lives in `impl` blocks on a named domain type. No free functions in domain crates. 2. Free functions are allowed only in `utils` (and `paths`, which is the same kind of crate) — pure, stateless helpers. 3. Comments only where they carry non-obvious "why": protocol rules, safety invariants, races, ordering guarantees. No summaries, no field-name restatements. 4. Tests only for protocol interop and user-visible contracts. Delete tests that lock internal structure. Everything below is behavior-preserving except the explicit deletions in Phase 0. --- ## 1. Audit summary | Crate | Lines | Free fns (non-test) | Impl blocks | Comment lines | Tests | |---|---|---|---|---|---| | `signed_core` | 2,226 | 47 | 6 | ~216 | 29 | | `signed_git` | 3,743 | 52 | 3 | ~276 | 38 | | `signed_nostr` | 257 | 3 | 1 | ~6 | 0 | | `signed_state` | 6,206 | 53 | ~20 | ~460 | 25 | | `utils` | 98 | 4 | 0 | ~3 | 3 | | `paths` | 105 | 8 | 0 | ~26 | 0 | | `settings` | 369 | 1 | 4 | ~44 | 5 | ### Structural findings - **`signed_git` is 52 free functions and 3 impl blocks.** `gix::open` is called 29×; nearly every entry point re-opens the repository. Eight near-duplicate `worktree_*`/`repo_*` pairs exist only to bridge `&Path` ↔ `&gix::Repository` (`worktree_all_commits`/`all_commits`, `worktree_branches`/`repo_branches`, `worktree_ref_state`/`repo_ref_state`, `worktree_current_branch`/`current_branch`, `head_commit_id`/`head_commit`, …). The `worktree_` prefix means two different things (path-based vs `&gix::Repository`-based). `GitCache` is the one type done right — it is the template for the refactor. - **`signed_state/backend.rs` (2,020 lines) is a god object plus 24 free helpers.** `create_repository` (L370–546) and `publish_local_repo` (L549–722) duplicate ~120 lines of validation/announce/push/retract/fan-out; `push_repo_from` (L752) is already the factored third variant. The grasp push loop (`push_staged_to_grasps` + 6 helpers) and the grasp URL family float freely. `Backend::connect_repo_relays` (method) calls a free fn of the same name (L1375). - **`signed_core` operates on foreign types.** 47 free functions take `&Event`/`&RepoAddr`-alias first args or are module-bound constructors; inherent impls are impossible without introducing types. `RepoAddr` is a bare alias for the SDK's `Coordinate`, so it can carry no methods. - **`signed_nostr::new_backend` returns a tuple** `(Client, UniversalSigner)` whose halves are always created and consumed together. - Duplication across crates: three middle-truncation helpers (`utils` private `truncate_middle`, `signed_ui::middle_truncate`, `workspace::truncate_label`), two hand-rolled mbox envelope parsers, three git-CLI spawn patterns, and `workspace/src/views/repo/mod.rs:1955` re-assembling by hand what `worktree_snapshot` should provide. ### Dead code (grep-verified, zero callers outside own crate) | Item | Location | Note | |---|---|---| | `login`, `login_with_new_identity`, `login_with_nsec`, `login_with_bunker`, `logout`, `with_master_key` | backend.rs L917–1025, L1451 | No UI path calls these; `import_dialog::open` is an empty stub (TODO.md confirms) | | `BackendEvent::SyncProgress` pipeline (variant, `sync_progress` field + accessor, progress task) | backend.rs L43, L64, L1104, L1191–1263 | No subscriber; TODO.md retains it for an unbuilt indicator | | `BackendEvent::Error` | backend.rs L47 | Emitted 9×, subscribed 0× — errors currently vanish | | `RepoStore::merge_pull_request` + `publish_applied_status` | repo.rs L1195, L1499 | Zero callers | | `Inbox::mark_read` / `mark_archived` | inbox.rs L27, L43 | Zero callers | | `InboxItem::kind` | signed_core/inbox.rs L45 | View reads `root_event.kind` directly | | `filters::notification_comments`, `filters::NOTIFICATION_KINDS`, `inbox::notification_root` re-exports | signed_core | Internal-only despite `pub` + root re-export | | `paths::{home_dir, config_dir, data_dir}` pub-ness | paths/lib.rs | Internal-only | | `local_repo_addr` re-export | signed_state/lib.rs L19 | Internal-only | | wasm32 code paths (`new_backend` wasm variant, `init` wasm variant, `cfg!` guards, `nostr-memory` dep) | signed_nostr/backend.rs L25, signed_state/lib.rs L65, backend.rs L174, checkouts.rs L103/136/162 | No wasm target exists in the workspace | | Unused `signed_core/lib.rs` root re-exports | lib.rs L11–14 | Callers use `filters::`/`inbox::` module paths | ### Speculative generality - `U: AsRef` generics on `clone_repo`, `fetch_repo_refs`, `GitCache::ensure_clone` — every call site passes `&str`/`String`. - `GraspSignals` exposes 10 bools; consumers read 4. - `RepoStore::set_status` and `reply` are pub but only used internally (draft-PR flow / `comment`). --- ## 2. Target types New or promoted types that absorb the free functions. Each row is a home, not a new abstraction layer — they are mostly newtypes over data the code already passes around. | Crate | Type | Absorbs | |---|---|---| | `signed_core` | `RepoAddr` (newtype over `Coordinate`, replacing the alias) | `repo_addr` → `RepoAddr::new`; `identifier_from_name` → `RepoAddr::identifier_from_name` (NIP-34 identifier policy stays here, not utils); enables `addr.announcement_filter()` etc. | | `signed_core` | `PullRequest<'a>(&'a Event)` | `pull_request_patches`, `pull_request_patch`, `latest_update`, private `forward_series`, `patch_produces_commit` | | `signed_core` | `trait Nip34Event` (extension on `Event`) | `current_commit_of`, `merge_base_of`, `clone_urls_of`, `branch_name_of`, `activity_subject`, `references_root`, `is_git_activity` — needed because the same accessors apply to PR roots *and* updates, where `PullRequest` can't go | | `signed_core` | `RepoState { refs, head }` | `build_state` → `RepoState::builder(id)`, `parse_state` → `RepoState::from_event`; kills the anonymous tuple return | | `signed_core` | `ThreadResolver<'a>` (wraps the event lookup closure) | `resolve_thread_root`, `parent_id`, `nip10_root_id`, `first_e_id`, `first_uppercase_e_id`, `first_tag_id`, `e_tag_with_marker`, `notification_root` | | `signed_core` | `Filters` (unit struct) | the 12 filter constructors (`announcement`, `state`, `activity`, `statuses_for`, `grasp_list`, `comments_for`, `notifications`, `authored_activity`, `all_announcements`, `all_states`, `deletions`, `deletions_for_repo`) — repo-scoped ones become `RepoAddr` methods | | `signed_core` | `Announcement::forks_in` (assoc fn) | `fork_candidates` | | `signed_nostr` | `NostrBackend { client, signer }` | `new_backend` → `NostrBackend::open(path)`; private `with_database` assoc fn; kills the tuple | | `signed_git` | `Repo` (wrapper over `gix::Repository`) | ~46 of the 52 free functions — see Appendix A. `Repo::open`, `Repo::open_cached` (object cache for history walks), `Repo::init`, `Repo::clone`. One `gix::open` per operation instead of 29 scattered calls. The `worktree_*`/`repo_*` pairs collapse | | `signed_git` | `GitCache` (existing) | unchanged except `open`/`ensure_clone` return `Repo` | | `signed_state` | `GraspServer<'a>(&'a RelayUrl)` | `grasp_base_url`, `grasp_clone_url`, `grasp06_prs_url`, `pr_clone_urls`, `grasp_list_servers`, `latest_grasp_list_servers`, `user_grasp_list_servers` | | `signed_state` | `GraspPush { client, signer, executor }` | `push_staged_to_grasps`, `sign_state_event`, `stage_event_on_relay`, `keep_newest`, `is_transient_grasp_denial`, `is_stale_advertisement_race` (→ `PushRejection::classify`), `PushOutcome` — moved to a new `push.rs` | | `signed_state` | `BunkerCredential { uri, session_key }` | `extract_master_key`, `with_master_key` (if the bunker login path survives Phase 0 — otherwise deleted) | | `signed_state` | `StatusIndex(HashMap)` | `resolve_statuses`, `status_of`; `RepoStore::status_of` reads through it | | `signed_state` | `PatchSeries` | `patch_current_commit`, `split_patch_series` consumers in repo.rs | | `signed_state` | `Mirrors` (unit struct over the `OnceLock`) | the 6 free fns in `git_store.rs` | | `signed_state` | methods on existing types | `query_inbox`/`load_state`/`save_state`/`fetch_notifications`/`event_references` → `Inbox`; `pr_proposes_checkout`, `resolve_associations`, `checkout_status`, `checkout_push_status`, `compute_statuses` → `CheckoutsStore` assoc fns; `resolve_local_repos` → `LocalReposStore`; `next_update` → `impl UpdateEvent`; `retract_event`, `publish_best_effort`, `ensure_bootstrap_relays`, `subscribe_bootstrap_only`, `sync_bootstrap_only` → `Backend`; `publish_patch_series`, `comment_builder` → `RepoStore`; `url_identity`, `same_repo_url` → `utils` | | `utils` | consolidation | one `middle_truncate` (absorbing 3 copies — `signed_ui` gains a `utils` dep), `flatten_whitespace`, `url_identity`/`same_repo_url`, `latest`, `sort_newest_first`/`sort_oldest_first` | `settings` needs nothing: it is already plain data + one store. `default_scan_paths` folds into the `Default` impl. --- ## 3. Phases Each phase ends with `cargo check --workspace && cargo test --workspace` green and its own commit, so review and bisection stay cheap. Call-site updates in `workspace`/`signed_ui`/`desktop` happen in the same commit as the API change. ### Phase 0 — Delete dead code and non-critical tests The cheapest phase; shrinks every later diff. 1. Delete the `login`/`logout` family + `with_master_key` (backend.rs). Either delete the empty `import_dialog` stub and its menu entry, or leave the stub — **decision D1**. 2. Delete the `SyncProgress` pipeline (variant, field, accessor, progress task). TODO.md's planned indicator can re-add from git history when it is actually built — **decision D2, default delete**. 3. `BackendEvent::Error`: 9 emit sites, 0 subscribers. Either wire one subscriber in `workspace` (a banner/toast, ~15 lines, makes existing error emissions user-visible) or delete variant + emissions — **decision D3, default: wire the subscriber** (AGENTS.md requires errors reach the UI; the emissions already exist). 4. Delete `merge_pull_request` + `publish_applied_status` (recoverable from history if the merge UI is built) — **decision D4, default delete**. 5. Delete `Inbox::mark_read`/`mark_archived`, `InboxItem::kind`. 6. Demote to private: `RepoStore::set_status`, `RepoStore::reply`, `Backend::restore_session`, `Backend::set_signer`. 7. Privatize `filters::notification_comments`, `NOTIFICATION_KINDS`, `inbox::notification_root`, `paths::{home_dir, config_dir, data_dir}`; drop unused `signed_core`/`signed_state` root re-exports (`local_repo_addr`, `GraspSignals` path, etc.). 8. Remove wasm32 paths + `nostr-memory` target dep — **decision D5, default remove** (no wasm target exists). Revert is trivial if a web build materializes. 9. Replace `U: AsRef` generics with concrete `&[String]`/`&str` signatures. 10. **Delete all NON-CRITICAL tests** (list in §5). Doing this before the structural phases means the structure changes never touch them. ### Phase 1 — `utils` consolidation - Add `middle_truncate`; delete `signed_ui`'s private copy and `workspace::truncate_label`; `shorten_pubkey` calls it. - Move in: `flatten_whitespace`, `url_identity`, `same_repo_url`, `latest`, `sort_newest_first`, `sort_oldest_first`. - These are drop-in; domain crates already depend on `utils`. ### Phase 2 — `signed_core` Dependency bottom, consumed by everything else. 1. `RepoAddr`: alias → newtype with `new`, `identifier_from_name`, `Display`/`From`/`FromStr` delegation. Mechanical fallout across all crates (~30 sites). 2. `Nip34Event` extension trait for the tag accessors; `PullRequest<'a>` newtype; `Announcement::forks_in`. 3. `RepoState` (replaces the tuple return; one consumer in `signed_state/repo.rs` refresh). 4. `ThreadResolver<'a>` absorbing the seven inbox/threading helpers. 5. `Filters` unit struct + repo-scoped filters as `RepoAddr` methods; `RepoStatus::resolve` assoc fn. ### Phase 3 — `signed_nostr` `NostrBackend::open` / `in_memory`; `signed_state::init` constructs it. ~30 lines. ### Phase 4 — `signed_git` → `Repo` The biggest mechanical phase. 1. Introduce `Repo` wrapping `gix::Repository` with `open`, `open_cached`, `init`, `clone`. 2. Move every function from Appendix A into methods; collapse the 8 duplicate pairs; the `worktree_` prefix disappears (`repo.dirty()`, `repo.snapshot()`, `repo.branches()` …). 3. Fold remote operations into methods (`push_ref`, `push_all`, `fetch_refs`, `origin_url`, `ensure_origin`, `set_origin`, `remote_has_refs`); one git-CLI spawn helper instead of three patterns. 4. One mbox envelope parser shared by `split_patch_series`/`patch_commits` (stays in `signed_git` as an impl on the patch parser, not utils — it is not generic). 5. `GitCache::open`/`ensure_clone` return `Repo`. 6. Extend `Repo::snapshot()` with branches/tags/current_branch so `workspace/src/views/repo/mod.rs:1955` (`load_repo_data`) collapses into it. 7. Keep pure string parsing (`sanitize_path_component`, `fork_namespace`, `split_patch_series`, `patch_diffs`, `patch_commits`) as assoc fns on the owning type (`Repo::sanitize_path_component`, `PatchParser::…`) or move to `utils` if they carry no git semantics — Appendix A marks each. ### Phase 5 — `signed_state` 1. New `push.rs`: `GraspServer` newtype + `GraspPush` + `PushOutcome`/`PushRejection`, moving ~400 lines out of `backend.rs`. 2. Factor the shared announce→authorize-push→retract-on-failure→fan-out orchestration out of `create_repository`/`publish_local_repo`, using `push_repo_from` as the template (~120 lines saved). Add `publish_one` (sign → send → `require_relay_accepted`, returning the `Event`) and collapse its 3 open-coded copies in repo.rs; merge the shadowed `connect_repo_relays` pair. 3. `StatusIndex`, `PatchSeries`, `Mirrors`; `Inbox`/`CheckoutsStore`/`LocalReposStore`/`Backend`/`RepoStore` method moves per §2. 4. Fold `push_repository`/`push_checkout` wrapper bodies into one helper. 5. Consider whether `backend.rs` (~2,020 → ~1,200 lines) still warrants a file split after the push extraction; stop there — the remaining methods are genuinely `Backend`'s job. ### Phase 6 — Comment stripping Apply §4 across all backend crates. Mechanical; do it last so it never masks a behavioral diff. ### Phase 7 — Final sweep - `cargo fmt`, `cargo clippy --workspace`, full test run. - Invariant grep: `grep -rn '^\(pub \)\?\(async \)\?fn ' crates/{signed_core,signed_git,signed_nostr,signed_state}/src` returns only `lib.rs` `init` and test helpers. - Update `docs/TODO.md` (both entries are consumed by this plan) and this document's status. --- ## 4. Comment policy Delete by default. Keep only (one line each, unless truly needed): - **Protocol/format rules**: NIP-34 `u`-tag parsing workaround (model.rs L311 — the SDK's `Nip34Tag` doesn't model the `u` tag); `subject`-tag fallback; `refs/heads` publication form in state events; NIP-10 `e` vs NIP-22 uppercase `E` semantics; kind→root mapping table in inbox.rs L115–124; `comments_for` returning two filters because `#E`+`#e` would AND (filters.rs L109–112); ngit URL parity notes (`grasp_base_url` scheme mapping, `/prs/` namespace, `clone` tag ordering). - **Safety invariants**: `sanitize_path_component` untrusted-input contract; fast-forward-only branch updates; `checkout_push_status` never fetching checked-out refs. - **Races / retry taxonomy**: grasp denial classification (backend.rs L1620–1638 — the single most valuable comment in the codebase); stale-advertisement race; same-second resend dedup on `sign_state_event`; convergence probe rationale. - **Ordering guarantees**: `commits_since` oldest-first (`git am` order); connect-relays-before-send; state-event-authorizes-push; retract-on-failed-push; fan-out-after-landing. - **Non-obvious `None`/error contracts** where callers depend on them (merge-base `Ok(None)` = unrelated history, unborn HEAD → `None`). - **Perf "why"**: object-cache rationale in history walks; commit cap for virtual lists. Delete: every field doc that restates the field name, method summary one-liners, `/// Get the profile.`-style docs, organizational comments, test-helper docs, per-constant restatements, duplicated platform tables in `paths`. Expected reduction: ~1,030 comment lines across backend crates → roughly 200. --- ## 5. Test disposition Rule: a test earns its place if breaking the behavior would silently break a user (protocol wire formats, interop with `git`/`ngit`/`nak`, state machines users hit). Delete tests of internal ordering, trivial helpers, and coalescing machinery. ### Keep (critical interop, ~73 tests) - `signed_core/model.rs`: announcement parsing, malformed-value dropping, `u`-tag fork semantics (3), maintainer authority (2), patch-set chain assembly (3), `latest_update` author scoping (2). - `signed_core/inbox.rs`: threading/root resolution (5) + `group` thread-merging. - `signed_core/filters.rs`: both (NIP-22 `K` vs NIP-34 `k`). - `signed_core/status.rs`, `state.rs`: all (kind-30618 wire format, latest-wins, authority, round-trip). - `signed_git/tests.rs`: `split_patch_series…`, `patch_commits…`, `parses_real_format_patch_output`, `push_all_mirrors…`, `remote_has_refs…`, `fetch_repo_refs_imports_heads_under_a_prefix`, `repo_ref_state_lists…`, `root_commit_reports…`, `blocks_parent_components`, `merge_base_finds…`, `working_copy_cloned_from_the_mirror…`, `head_commit_and_commits_since…` (pins `git am` order), plus `fast_forward_branches_moves_the_mirror_and_keeps_local_work` — kept as a safety invariant, not structure. - `signed_git/nip34.rs`: all 10 (on-disk marker interop with `nak`/`ngit`). - `signed_state/backend.rs`: the 6 URL-format/denial-classification tests. - `signed_state/repo.rs`: all 3 (format-patch header, NIP-22 comment builder, maintainer filters). - `signed_state/checkouts.rs`: `same_repo_url…`, `checkout_status_reports_ahead_branches_only`, `checkout_push_status_counts_unpushed_commits_only`. - `signed_state/local_repos.rs`: the 2 sidebar-contract tests (own announcement dropped / other owner's linked). - `settings`: `save_and_load_roundtrip`, `partial_json_merges_with_defaults` (breaking these silently resets user settings). ### Delete (~38 tests, ~1,100 lines) - `signed_git/tests.rs` (16): scan walker, `init_repository` contents, `set_origin`, `worktree_last_commits` (2), `all_commits_lists_every_commit`, `find_readme_prefers_markdown`, `current_branch_tracks_checkout`, `worktree_snapshot_reflects…`, diff display (4), `worktree_dirty…`, `worktree_commits_ahead…`. - `signed_state` (10): `push_outcome_reports_partial_failures`, `resolve_orders_remembered_freshest_first`, `resolve_deduplicates_paths…`, the 4 `local_repos` trivial pass-through/name tests, all 4 `refresh.rs` coalescing tests. - `signed_core` (9): the 4 `InboxReadState` policy tests, 2 `fork_candidates` ordering/policy tests, 3 `utils` formatting tests. - `settings` (3): keep only the 2 named above. Consolidate retained helpers while there: three git-env helpers → one, five bare-server `git init --bare` blocks → one fixture, delete the inline `run` closure at tests.rs L634. Out of scope but flagged: `signed_ui/pixel_avatar` (identicon determinism — borderline, visual), `dock/tests/render_smoke.rs` (UI smoke). --- ## 6. Risks - **`PullRequest<'a>` lifetimes** at `pull_requests/detail.rs` call sites — mitigated by keeping it a borrow newtype, not an owning copy. - **`Repo` wrapper vs background tasks**: `RepoStore` spawns git work via `cx.background_spawn`. The wrapper must stay `Send` when moved into tasks exactly as `gix::Repository` is today; no interior mutation planned, so this is mechanical. - **Phase 2 `RepoAddr` newtype** touches ~30 call sites in one commit — small each, many files; consider `impl From` bridges to keep the diff mechanical. - **Backend push orchestration dedup** (Phase 5.2) is the only phase that reorders logic; land it alone in its own commit behind the existing tests (URL-format + denial-classification + `push_all`/`fetch_repo_refs` interop tests are the net). ## 7. Open decisions | # | Question | Default | |---|---|---| | D1 | Delete the empty `import_dialog` stub + menu entry, or keep the shell? | Keep shell (UI territory, 0 cost) | | D2 | `SyncProgress` pipeline | Delete; re-add with the feature | | D3 | `BackendEvent::Error` (9 emitters, 0 subscribers) | Wire one workspace subscriber | | D4 | `merge_pull_request` (unwired feature) | Delete; recover from history when merge UI is built | | D5 | wasm32 code paths + `nostr-memory` dep | Remove (no wasm target in workspace) | | D6 | `paths`: keep as a second function-crate, or fold into `utils`? | Keep separate (no `nostr` dep for path users) | --- ## Appendix A — `signed_git` function → `Repo` method mapping | Current free fn | Becomes | |---|---| | `merge_base`, `head_commit_id`, `commits_since`, `root_commit`, `refs_with_prefix`, `delete_refs_with_prefix` | `Repo::merge_base`, `head`, `commits_since`, `root_commit`, `refs_with_prefix`, `delete_refs_with_prefix` | | `init_repository` | `Repo::init` | | `worktree_current_branch`/`current_branch` | `Repo::current_branch` | | `worktree_ref_exists` | `Repo::ref_exists` | | `worktree_branches`/`repo_branches` | `Repo::branches` | | `worktree_ref_state`/`repo_ref_state` | `Repo::ref_state` | | `repo_tags` | `Repo::tags` | | `fast_forward_branches` | `Repo::fast_forward_branches` | | `worktree_checkout_branch`/`worktree_checkout_tag` | `Repo::checkout_branch` / `checkout_tag` | | `worktree_snapshot` | `Repo::snapshot` (+ branches/tags/current_branch, absorbing `load_repo_data`) | | `worktree_dirty` | `Repo::is_dirty` | | `worktree_commits_ahead` | `Repo::commits_ahead` | | `worktree_all_commits`/`all_commits` | `Repo::all_commits` | | `worktree_last_commits`/`last_commits` | `Repo::last_commits` | | `worktree_commit_range_commits` | `Repo::commit_range` | | `worktree_commit` | `Repo::commit` | | `worktree_commit_diff`/`commit_diff` | `Repo::commit_diff` | | `worktree_commit_range_diff` | `Repo::range_diff` | | `worktree_entries`, `worktree_read`, `find_readme` | `Repo::entries`, `read`, `find_readme` | | `clone_repo` | `Repo::clone` | | `push_commit_ref`, `push_main`, `push_all` | `Repo::push_ref`, `push_main`, `push_all` | | `remote_has_refs` | `Repo::remote_has_refs` | | `ensure_origin`, `set_origin`, `origin_url` | `Repo::ensure_origin`, `set_origin`, `origin_url` | | `fetch_repo_refs`, `fetch_all` | `Repo::fetch_refs`, `fetch` | | `apply_patch`, `format_patch_between` | `Repo::apply_patch`, `format_patch_between` | | `detect_nip34`, `set_nostr_repo` | `Repo::nip34_binding`, `set_nostr_repo` | | `find_git_repos` | stays free in `scan.rs` (directory-tree scan, no repo opened) — acceptable exception, or `utils` | | `sanitize_path_component`, `fork_namespace` | assoc fns on `GitCache` (they build its layout) | | `split_patch_series`, `patch_diffs`, `patch_commits` | assoc fns on a small `PatchParser` impl (pure parsing, no `Repo`) | | `is_grasp_url` + private nip34 url helpers | private assoc fns of the NIP-34 detection impl | | `repository_signature`, `open_with_cache`, `resolve_commit`, `move_head`, `force_checkout`, `edit_local_config` | private methods/assoc fns on `Repo` |