From 855f7f56595fd71b750cf20aa26c050d98ada94e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 3 Oct 2026 10:21:08 +0700 Subject: [PATCH 1/9] add plan --- docs/REFACTOR_PLAN.md | 264 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 docs/REFACTOR_PLAN.md diff --git a/docs/REFACTOR_PLAN.md b/docs/REFACTOR_PLAN.md new file mode 100644 index 0000000..c4ff04c --- /dev/null +++ b/docs/REFACTOR_PLAN.md @@ -0,0 +1,264 @@ +# 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` | -- 2.54.0 From 24070cc2cda09448bdb2b8a859b9634e57d41db3 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 3 Oct 2026 10:53:26 +0700 Subject: [PATCH 2/9] remove wasm support and drop unused code paths --- Cargo.lock | 18 - Cargo.toml | 1 - crates/paths/src/lib.rs | 6 +- crates/signed_core/src/filters.rs | 4 +- crates/signed_core/src/inbox.rs | 92 +---- crates/signed_core/src/lib.rs | 5 +- crates/signed_git/src/tests.rs | 533 +------------------------ crates/signed_nostr/Cargo.toml | 7 +- crates/signed_nostr/src/backend.rs | 12 - crates/signed_state/src/backend.rs | 204 +--------- crates/signed_state/src/checkouts.rs | 99 ----- crates/signed_state/src/git_store.rs | 3 +- crates/signed_state/src/inbox.rs | 35 -- crates/signed_state/src/lib.rs | 6 +- crates/signed_state/src/local_repos.rs | 52 --- crates/signed_state/src/refresh.rs | 45 --- crates/signed_state/src/repo.rs | 150 +------ crates/workspace/src/workspace.rs | 7 +- 18 files changed, 51 insertions(+), 1228 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6a182e8..758a67a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -881,12 +881,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "btreecap" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6160c957d8aa33d0a8ba1dbab98e3cb57023ad9374c501441e88559f99e6c4c9" - [[package]] name = "built" version = "0.8.1" @@ -5741,17 +5735,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "nostr-memory" -version = "0.45.2" -source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336" -dependencies = [ - "btreecap", - "nostr", - "nostr-database", - "tokio", -] - [[package]] name = "nostr-sdk" version = "0.45.4" @@ -8178,7 +8161,6 @@ dependencies = [ "nostr-connect", "nostr-gossip-memory", "nostr-lmdb", - "nostr-memory", "nostr-sdk", "webbrowser", ] diff --git a/Cargo.toml b/Cargo.toml index 0a21411..d4287b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,6 @@ settings = { path = "crates/settings" } nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] } nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } -nostr-memory = { git = "https://github.com/rust-nostr/nostr" } nostr-blossom = { git = "https://github.com/rust-nostr/nostr" } nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" } nostr-connect = { git = "https://github.com/rust-nostr/nostr" } diff --git a/crates/paths/src/lib.rs b/crates/paths/src/lib.rs index c5b3da6..b3f82f4 100644 --- a/crates/paths/src/lib.rs +++ b/crates/paths/src/lib.rs @@ -23,7 +23,7 @@ static CURRENT_DATA_DIR: OnceLock = OnceLock::new(); /// On Windows, this is `%APPDATA%\Signed`. static CONFIG_DIR: OnceLock = OnceLock::new(); -pub fn home_dir() -> PathBuf { +fn home_dir() -> PathBuf { dirs::home_dir().expect("failed to determine home directory") } @@ -41,7 +41,7 @@ pub fn documents_dir() -> PathBuf { dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default()) } -pub fn config_dir() -> &'static PathBuf { +fn config_dir() -> &'static PathBuf { CONFIG_DIR.get_or_init(|| { if cfg!(target_os = "windows") { dirs::config_dir() @@ -60,7 +60,7 @@ pub fn config_dir() -> &'static PathBuf { }) } -pub fn data_dir() -> &'static PathBuf { +fn data_dir() -> &'static PathBuf { CURRENT_DATA_DIR.get_or_init(|| { if cfg!(target_os = "macos") { home_dir() diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index c8180a0..01dcaf0 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -18,7 +18,7 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [ ]; /// Kinds that notify a user when they tag them via their `p` tag. -pub const NOTIFICATION_KINDS: [Kind; 8] = [ +const NOTIFICATION_KINDS: [Kind; 8] = [ Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch, @@ -127,7 +127,7 @@ pub fn comments_for(roots: impl IntoIterator) -> Vec { /// NIP-22 comments on our issues, patches and pull requests. /// They are matched via the uppercase `P` and `K` tags, not authorship. -pub fn notification_comments(me: PublicKey) -> Filter { +fn notification_comments(me: PublicKey) -> Filter { Filter::new() .kind(Kind::Comment) .custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()]) diff --git a/crates/signed_core/src/inbox.rs b/crates/signed_core/src/inbox.rs index c848a29..9b88a36 100644 --- a/crates/signed_core/src/inbox.rs +++ b/crates/signed_core/src/inbox.rs @@ -122,7 +122,7 @@ impl InboxItem { /// - NIP-22 comment (1111): uppercase `E` root pointer /// - PR update (1619): uppercase `E` /// - statuses (1630-1633): NIP-10 root `e` -pub fn notification_root(event: &Event, lookup: &L) -> Option +fn notification_root(event: &Event, lookup: &L) -> Option where L: Fn(EventId) -> Option, { @@ -579,94 +579,4 @@ mod tests { vec![issue.id, mine.id, reply.id] ); } - - #[test] - fn mark_all_read_marks_known_recent_events() { - let me = keys(1); - let now = Timestamp::from_secs(1_000_000_000); - let recent = issue(&keys(2), now.as_secs() - 1000); - let old = issue(&keys(2), now.as_secs() - 5 * 24 * 60 * 60); - let ancient = issue(&keys(2), now.as_secs() - 20 * 24 * 60 * 60); - let mine = issue(&keys(1), now.as_secs() - 100); - - let mut state = InboxReadState::default(); - state.mark_all_read( - &[recent.clone(), old.clone(), ancient.clone(), mine.clone()], - me.public_key(), - now, - ); - - assert_eq!(state.read_before, now - MARK_ALL_WINDOW); - assert_eq!(state.read_ids, HashSet::from([recent.id, old.id])); - assert!(state.is_read(&recent)); - assert!(state.is_read(&ancient)); - assert!(!state.is_read(&mine)); - } - - #[test] - fn advance_read_never_moves_the_cutoff_backwards() { - let me = keys(1); - let unread = issue(&keys(2), 1_000); - let all = [unread]; - let now = Timestamp::from_secs(1_000_000_000); - - let mut state = InboxReadState { - read_before: Timestamp::from_secs(999_999_999), - ..Default::default() - }; - state.advance_read(&all, me.public_key(), now); - - assert_eq!(state.read_before, Timestamp::from_secs(999_999_999)); - } - - #[test] - fn advance_read_moves_before_the_oldest_unread_and_prunes_ids() { - let me = keys(1); - let now = Timestamp::from_secs(1_000_000_000); - let five_days = 5 * 24 * 60 * 60; - let old_unread = issue(&keys(2), now.as_secs() - five_days); - // Read ids that fall before and after the new cutoff. - let stale = signed( - &keys(2), - Kind::GitIssue, - Vec::new(), - now.as_secs() - five_days - 1000, - ); - let fresh = signed( - &keys(2), - Kind::GitIssue, - Vec::new(), - now.as_secs() - 100_000, - ); - - let mut state = InboxReadState { - read_ids: HashSet::from([stale.id, fresh.id]), - ..Default::default() - }; - state.advance_read( - &[old_unread.clone(), stale.clone(), fresh.clone()], - me.public_key(), - now, - ); - - assert_eq!(state.read_before, old_unread.created_at - 1); - assert_eq!(state.read_ids, HashSet::from([fresh.id])); - } - - #[test] - fn mark_archived_skips_events_at_or_before_the_cutoff() { - let now = Timestamp::from_secs(1_000_000_000); - let event = issue(&keys(2), now.as_secs() - 1000); - - let mut state = InboxReadState { - archived_before: now, - ..Default::default() - }; - state.mark_archived(&event); - assert!(state.archived_ids.is_empty()); - - let mut state = InboxReadState::default(); - state.mark_archived(&event); - assert_eq!(state.archived_ids, HashSet::from([event.id])); - } } diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index 7232086..645d04c 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -8,10 +8,7 @@ pub mod status; pub use addr::{RepoAddr, identifier_from_name, repo_addr}; pub use deletions::Deletions; -pub use filters::{ - NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications, -}; -pub use inbox::{InboxItem, InboxReadState, group, notification_root}; +pub use inbox::{InboxItem, InboxReadState}; pub use model::{ Announcement, activity_subject, branch_name_of, clone_urls_of, current_commit_of, fork_candidates, latest_update, merge_base_of, pull_request_patch, pull_request_patches, diff --git a/crates/signed_git/src/tests.rs b/crates/signed_git/src/tests.rs index 6f37456..189a708 100644 --- a/crates/signed_git/src/tests.rs +++ b/crates/signed_git/src/tests.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Command; @@ -13,50 +12,6 @@ fn blocks_parent_components() { assert_eq!(sanitize_path_component("a/../b"), "a_.._b"); } -#[test] -fn find_git_repos_discovers_repositories_recursively() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path(); - - // Repositories are found at any depth. - // A linked worktree, with a `.git` file instead of a directory, counts too. - let nested = root.join("a/b/project"); - std::fs::create_dir_all(nested.join(".git")).unwrap(); - let worktree = root.join("wt"); - std::fs::create_dir_all(&worktree).unwrap(); - std::fs::write( - worktree.join(".git"), - "gitdir: ../a/b/project/.git/worktrees/wt", - ) - .unwrap(); - - std::fs::create_dir_all(root.join("plain")).unwrap(); - - std::fs::write(root.join(".gitignore"), "node_modules/\n").unwrap(); - std::fs::create_dir_all(root.join("node_modules/pkg/.git")).unwrap(); - - std::fs::create_dir_all(root.join(".hidden/repo/.git")).unwrap(); - - // A repository inside another, like a submodule worktree, is not reported. - let outer = root.join("outer"); - std::fs::create_dir_all(outer.join(".git")).unwrap(); - std::fs::create_dir_all(outer.join("sub/other/.git")).unwrap(); - - let mut found: Vec = find_git_repos(root) - .into_iter() - .map(|repo| repo.path) - .collect(); - found.sort(); - - let mut expected = vec![ - nested.canonicalize().unwrap(), - worktree.canonicalize().unwrap(), - outer.canonicalize().unwrap(), - ]; - expected.sort(); - assert_eq!(found, expected); -} - #[test] fn root_commit_reports_the_first_ancestor() { let (dir, repo) = fixture(&[("a.txt", b"one")]); @@ -80,14 +35,7 @@ fn push_all_mirrors_branches_and_tags() { // A bare server repository reachable via a `file://` URL. // Mirrors a grasp server's `{base}/{owner}/{repo-id}.git` layout. let server = tempfile::tempdir().unwrap(); - let server_repo = server.path().join("npub1test").join("my-repo.git"); - std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&server_repo) - .status() - .expect("spawn git init --bare"); - assert!(init_status.success()); + let server_repo = bare_server(server.path(), "npub1test", "my-repo"); let (dir, repo) = fixture(&[("a.txt", b"one")]); commit_all(&repo, "initial"); @@ -111,15 +59,7 @@ fn push_all_mirrors_branches_and_tags() { #[test] fn remote_has_refs_reports_whether_pushed_refs_landed() { let server = tempfile::tempdir().unwrap(); - let server_repo = server.path().join("npub1test").join("my-repo.git"); - std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&server_repo) - .status() - .expect("spawn git init --bare"); - assert!(init_status.success()); - + bare_server(server.path(), "npub1test", "my-repo"); let (dir, repo) = fixture(&[("a.txt", b"one")]); commit_all(&repo, "initial"); let dir = dir.path(); @@ -308,60 +248,6 @@ fn head_commit_and_commits_since_track_applied_commits() { ); } -#[test] -fn init_repository_creates_main_branch_and_readme() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("my-repo"); - - let commit = init_repository(&path, "My Repo", "Does things.\n\nCool.").expect("init"); - assert_eq!(commit.len(), 40); - - let repo = gix::open(&path).expect("open"); - let workdir = repo.workdir().expect("workdir"); - - assert_eq!( - std::fs::read_to_string(workdir.join("README.md")).expect("read"), - "# My Repo\n\nDoes things.\n\nCool.\n" - ); - - let branch = current_branch(&repo).expect("branch").expect("on a branch"); - assert_eq!(branch, "main"); - // [`FileCommit`] carries the short id, the full id is 40 chars. - assert_eq!( - head_commit(&repo).expect("head").expect("commit").id, - &commit[..7] - ); - - let state = repo_ref_state(&repo).expect("refs"); - assert_eq!(state.head.as_deref(), Some("main")); - assert_eq!(state.refs, vec![("refs/heads/main".to_owned(), commit)]); - - // The index matches the committed tree, so the fresh repo is clean. - assert!(!worktree_dirty(workdir)); -} - -#[test] -fn set_origin_creates_or_replaces_the_remote() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("my-repo"); - init_repository(&path, "My Repo", "").expect("init"); - - set_origin(&path, "https://gitnostr.com/npub1test/repo.git").expect("add"); - assert_eq!( - origin_url(&path).expect("url").as_deref(), - Some("https://gitnostr.com/npub1test/repo.git") - ); - - // An existing origin is replaced, not duplicated. - // A clone's origin points at the cloned-from path. - // It is re-targeted at the grasp server. - set_origin(&path, "https://grasp.example/npub1test/repo.git").expect("replace"); - assert_eq!( - origin_url(&path).expect("url").as_deref(), - Some("https://grasp.example/npub1test/repo.git") - ); -} - #[test] fn working_copy_cloned_from_the_mirror_matches_head_and_origin() { // The mirror is a freshly initialized repository, standing in for @@ -396,14 +282,7 @@ fn working_copy_cloned_from_the_mirror_matches_head_and_origin() { fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { // A bare server, like a grasp server's `{base}/{owner}/{repo}.git` layout. let dir = tempfile::tempdir().expect("tempdir"); - let base_server = dir.path().join("npub1test").join("repo.git"); - std::fs::create_dir_all(base_server.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&base_server) - .status() - .expect("spawn git init --bare"); - assert!(init_status.success()); + bare_server(dir.path(), "npub1test", "repo"); let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]); commit_all(&work_repo, "initial"); @@ -465,14 +344,7 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { // A bare base server holding the initial commit. // Like a grasp server's `{base}/{owner}/{repo-id}.git` layout. - let base_server = dir.path().join("npub1base").join("base.git"); - std::fs::create_dir_all(base_server.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&base_server) - .status() - .expect("spawn git init --bare"); - assert!(init_status.success()); + let base_server = bare_server(dir.path(), "npub1base", "base"); let (upstream_dir, upstream_repo) = fixture(&[("a.txt", b"one")]); commit_all(&upstream_repo, "initial"); @@ -505,14 +377,7 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { commit_all(&gix::open(&fork_work).expect("open"), "feature commit"); let tip = git_in(&fork_work, &["rev-parse", "HEAD"]).expect("tip"); - let fork_server = dir.path().join("npub1fork").join("fork.git"); - std::fs::create_dir_all(fork_server.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&fork_server) - .status() - .expect("spawn git init --bare"); - assert!(init_status.success()); + let fork_server = bare_server(dir.path(), "npub1fork", "fork"); push_commit_ref( &fork_work, &format!("file://{}", fork_server.display()), @@ -565,9 +430,9 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { ); } -/// Run a git command in `dir`, asserting success. -fn git_run(dir: &Path, args: &[&str]) { - let status = Command::new("git") +/// Run a git command in `dir` with a fixed test identity, asserting success. +fn git_run(dir: &Path, args: &[&str]) -> std::process::Output { + let output = Command::new("git") .current_dir(dir) .env("GIT_AUTHOR_NAME", "Test Author") .env("GIT_AUTHOR_EMAIL", "test@example.com") @@ -575,324 +440,21 @@ fn git_run(dir: &Path, args: &[&str]) { .env("GIT_COMMITTER_EMAIL", "test@example.com") .env("GIT_EDITOR", "true") .args(args) - .status() + .output() .expect("spawn git"); - assert!(status.success(), "git {args:?} failed"); + assert!(output.status.success(), "git {args:?} failed"); + output } -#[test] -fn worktree_last_commits_returns_most_recent_change() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - - std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); - commit_all(&repo, "change a"); - - // A commit touching another file must not be reported for a.txt. - std::fs::write(dir.path().join("b.txt"), b"other").expect("write"); - commit_all(&repo, "add b"); - - let commit = worktree_last_commits(dir.path(), &[PathBuf::from("a.txt")]) - .expect("lookup") - .into_iter() - .next() - .expect("found") - .1; - assert_eq!(commit.summary, "change a"); - assert_eq!(commit.author, "Test Author"); - assert!(!commit.id.is_empty()); - assert!(commit.time > 0); -} - -#[test] -fn all_commits_lists_every_commit() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - - std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); - commit_all(&repo, "second"); - std::fs::write(dir.path().join("b.txt"), b"b").expect("write"); - commit_all(&repo, "third"); - - let list = all_commits(&repo).expect("commits"); - assert_eq!(list.total, 3); - let mut summaries: Vec<&str> = list.commits.iter().map(|c| c.summary.as_str()).collect(); - summaries.sort(); - assert_eq!(summaries, vec!["initial", "second", "third"]); - assert!( - list.commits - .iter() - .all(|c| c.author == "Test Author" && !c.id.is_empty() && c.time > 0) +/// Create a bare `{base}/{owner}/{name}.git` repository, like a grasp server. +fn bare_server(base: &Path, owner: &str, name: &str) -> PathBuf { + let repo = base.join(owner).join(format!("{name}.git")); + std::fs::create_dir_all(repo.parent().expect("parent")).expect("mkdir"); + git_run( + base, + &["init", "--bare", "-q", repo.to_str().expect("utf8 path")], ); -} - -#[test] -fn worktree_last_commits_reports_merge_commits() { - let (dir, repo) = fixture(&[("a.txt", b"base")]); - commit_all(&repo, "initial"); - - let run = |args: &[&str]| { - let status = Command::new("git") - .current_dir(dir.path()) - .env("GIT_AUTHOR_NAME", "Test Author") - .env("GIT_AUTHOR_EMAIL", "test@example.com") - .env("GIT_COMMITTER_NAME", "Test Author") - .env("GIT_COMMITTER_EMAIL", "test@example.com") - .env("GIT_EDITOR", "true") - .args(args) - .status() - .expect("spawn git"); - assert!(status.success(), "git {args:?} failed"); - }; - run(&["checkout", "-b", "feature"]); - std::fs::write(dir.path().join("a.txt"), b"feature").expect("write"); - commit_all(&repo, "feature change"); - run(&["checkout", "-"]); - // `--no-ff` forces a merge commit, it is the latest commit changing a.txt. - run(&["merge", "--no-ff", "--no-edit", "feature"]); - - let commit = worktree_last_commits(dir.path(), &[PathBuf::from("a.txt")]) - .expect("lookup") - .into_iter() - .next() - .expect("found") - .1; - assert_eq!( - commit.id, - repo.head_id().expect("head").shorten_or_id().to_string() - ); - assert!(commit.summary.starts_with("Merge branch")); -} - -#[test] -fn find_readme_prefers_markdown() { - let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]); - - let readme = find_readme(&repo).expect("find"); - assert_eq!( - readme.map(|p| p.to_string_lossy().into_owned()), - Some("README.md".into()) - ); -} - -#[test] -fn current_branch_tracks_checkout() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - let dir = dir.path(); - - let default = worktree_branches(dir) - .expect("branches") - .into_iter() - .next() - .expect("default branch"); - assert_eq!( - current_branch(&repo).expect("branch").as_deref(), - Some(default.as_str()) - ); - - git_run(dir, &["checkout", "-b", "feature"]); - assert_eq!( - current_branch(&repo).expect("branch").as_deref(), - Some("feature") - ); - - git_run(dir, &["tag", "v1.0"]); - worktree_checkout_tag(dir, "v1.0").expect("checkout tag"); - assert_eq!(current_branch(&repo).expect("branch"), None); - - worktree_checkout_branch(dir, &default).expect("checkout branch"); - assert_eq!( - current_branch(&repo).expect("branch").as_deref(), - Some(default.as_str()) - ); -} - -#[test] -fn worktree_snapshot_reflects_checked_out_ref() { - let (dir, repo) = fixture(&[("README.md", b"# main"), ("a.txt", b"one")]); - commit_all(&repo, "initial"); - let dir = dir.path(); - - git_run(dir, &["checkout", "-b", "feature"]); - std::fs::write(dir.join("README.md"), b"# feature").expect("write"); - std::fs::write(dir.join("b.txt"), b"b").expect("write"); - commit_all(&repo, "feature work"); - - let snapshot = worktree_snapshot(dir).expect("snapshot"); - assert_eq!(snapshot.current_branch.as_deref(), Some("feature")); - assert_eq!( - snapshot.head_commit.as_ref().expect("head commit").summary, - "feature work" - ); - assert_eq!( - String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"), - "# feature" - ); - let entries: Vec = snapshot - .entries - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - assert!(entries.contains(&"b.txt".to_string())); - - let default = worktree_branches(dir) - .expect("branches") - .into_iter() - .find(|name| name != "feature") - .expect("default branch"); - worktree_checkout_branch(dir, &default).expect("checkout"); - - let snapshot = worktree_snapshot(dir).expect("snapshot"); - assert_eq!(snapshot.current_branch.as_deref(), Some(default.as_str())); - assert_eq!( - snapshot.head_commit.as_ref().expect("head commit").summary, - "initial" - ); - assert_eq!( - String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"), - "# main" - ); - assert!( - !snapshot - .entries - .iter() - .any(|p| p.to_string_lossy() == "b.txt") - ); -} - -#[test] -fn commit_diff_lists_added_modified_and_deleted_files() { - let (dir, repo) = fixture(&[("keep.txt", b"keep"), ("mod.txt", b"one\ntwo\nthree\n")]); - commit_all(&repo, "initial"); - - std::fs::write(dir.path().join("mod.txt"), b"one\ntwo!\nthree\n").expect("write"); - std::fs::write(dir.path().join("new.txt"), b"hello\n").expect("write"); - std::fs::remove_file(dir.path().join("keep.txt")).expect("remove"); - commit_all(&repo, "changes"); - - let head = repo.head_id().expect("head").shorten_or_id().to_string(); - let diff = worktree_commit_diff(dir.path(), &head).expect("diff"); - - let by_path: HashMap<&str, &FileDiff> = diff - .files - .iter() - .map(|file| (file.path.as_str(), file)) - .collect(); - assert_eq!(by_path.len(), 3); - - let added = by_path["new.txt"]; - assert_eq!(added.status, DiffStatus::Added); - assert_eq!(added.insertions, 1); - assert_eq!(added.deletions, 0); - assert_eq!(added.hunks.len(), 1); - assert_eq!(added.hunks[0].lines.len(), 1); - assert_eq!(added.hunks[0].lines[0].kind, DiffLineKind::Addition); - assert_eq!(added.hunks[0].lines[0].old, None); - assert_eq!(added.hunks[0].lines[0].new, Some(1)); - assert_eq!(added.hunks[0].lines[0].text, "hello"); - - let modified = by_path["mod.txt"]; - assert_eq!(modified.status, DiffStatus::Modified); - assert_eq!(modified.insertions, 1); - assert_eq!(modified.deletions, 1); - assert!(!modified.binary); - let lines = &modified.hunks[0].lines; - // One hunk with context around the single-line change. - // The removed line is old 2, the added line is new 2. - assert!(lines.iter().any(|line| { - line.kind == DiffLineKind::Deletion - && line.old == Some(2) - && line.new.is_none() - && line.text == "two" - })); - assert!(lines.iter().any(|line| { - line.kind == DiffLineKind::Addition - && line.old.is_none() - && line.new == Some(2) - && line.text == "two!" - })); - assert!(lines.iter().any(|line| { - line.kind == DiffLineKind::Context && line.old == Some(1) && line.new == Some(1) - })); - - let deleted = by_path["keep.txt"]; - assert_eq!(deleted.status, DiffStatus::Deleted); - assert_eq!(deleted.deletions, 1); - assert_eq!(deleted.hunks[0].lines[0].kind, DiffLineKind::Deletion); - assert_eq!(deleted.hunks[0].lines[0].old, Some(1)); - assert_eq!(deleted.hunks[0].lines[0].new, None); -} - -#[test] -fn commit_range_diff_lists_changes_between_two_commits() { - let (dir, repo) = fixture(&[("a.txt", b"a\n"), ("b.txt", b"b\n")]); - commit_all(&repo, "first"); - let base = repo.head_id().expect("head").to_string(); - - std::fs::write(dir.path().join("a.txt"), b"changed\n").expect("write"); - std::fs::write(dir.path().join("c.txt"), b"new\n").expect("write"); - commit_all(&repo, "second"); - let tip = repo.head_id().expect("head").to_string(); - - let diff = worktree_commit_range_diff(dir.path(), &base, &tip).expect("diff"); - - let by_path: HashMap<&str, &FileDiff> = diff - .files - .iter() - .map(|file| (file.path.as_str(), file)) - .collect(); - assert_eq!(by_path.len(), 2); - assert_eq!(by_path["a.txt"].status, DiffStatus::Modified); - assert_eq!(by_path["a.txt"].insertions, 1); - assert_eq!(by_path["a.txt"].deletions, 1); - assert_eq!(by_path["c.txt"].status, DiffStatus::Added); - // b.txt is unchanged between the two commits. - assert!(diff.files.iter().all(|file| file.path != "b.txt")); -} - -#[test] -fn commit_diff_reports_binary_files_without_hunks() { - let (_dir, repo) = fixture(&[("blob.bin", b"\x00\x01\x02")]); - commit_all(&repo, "initial"); - - std::fs::write(_dir.path().join("blob.bin"), b"\x00\x03").expect("write"); - commit_all(&repo, "binary change"); - - let head = repo.head_id().expect("head").shorten_or_id().to_string(); - let diff = worktree_commit_diff(_dir.path(), &head).expect("diff"); - let file = diff - .files - .iter() - .find(|f| f.path == "blob.bin") - .expect("file"); - assert!(file.binary); - assert!(file.hunks.is_empty()); - assert_eq!(file.insertions, 0); - assert_eq!(file.deletions, 0); -} - -#[test] -fn commit_diff_reports_renames() { - let (_dir, repo) = fixture(&[("old.txt", b"same content\n")]); - commit_all(&repo, "initial"); - - std::fs::rename(_dir.path().join("old.txt"), _dir.path().join("new.txt")).expect("rename"); - commit_all(&repo, "rename"); - - let head = repo.head_id().expect("head").shorten_or_id().to_string(); - let diff = worktree_commit_diff(_dir.path(), &head).expect("diff"); - let file = diff - .files - .iter() - .find(|f| f.path == "new.txt") - .expect("file"); - assert_eq!(file.status, DiffStatus::Renamed); - assert_eq!(file.old_path.as_deref(), Some("old.txt")); - // A pure rename has no content change, the file is still listed. - assert!(file.hunks.is_empty()); - assert_eq!(file.insertions, 0); - assert_eq!(file.deletions, 0); + repo } #[test] @@ -971,16 +533,7 @@ fn parses_real_format_patch_output() { std::fs::write(dir.path().join("new file.md"), b"# new\n").expect("write"); commit_all(&repo, "changes"); - let output = Command::new("git") - .current_dir(dir.path()) - .env("GIT_AUTHOR_NAME", "Test Author") - .env("GIT_AUTHOR_EMAIL", "test@example.com") - .env("GIT_COMMITTER_NAME", "Test Author") - .env("GIT_COMMITTER_EMAIL", "test@example.com") - .args(["format-patch", "-1", "--stdout"]) - .output() - .expect("spawn git format-patch"); - assert!(output.status.success(), "git format-patch failed"); + let output = git_run(dir.path(), &["format-patch", "-1", "--stdout"]); let patch = String::from_utf8(output.stdout).expect("patch is utf-8"); let diff = patch_diffs(&patch).expect("parse real format-patch output"); @@ -1018,48 +571,6 @@ fn parses_real_format_patch_output() { assert_eq!(file.status, DiffStatus::Deleted); assert!(file.binary); assert!(file.hunks.is_empty()); -} - -#[test] -fn worktree_dirty_tracks_changes_and_untracked_files() { - let (dir, repo) = fixture(&[("tracked.txt", b"one")]); - commit_all(&repo, "initial"); - let workdir = dir.path(); - - assert!(!worktree_dirty(workdir)); - - std::fs::write(workdir.join("tracked.txt"), b"two").expect("write"); - assert!(worktree_dirty(workdir)); - - // After restoring, an untracked file alone is dirty as well. - git_run(workdir, &["checkout", "--", "tracked.txt"]); - assert!(!worktree_dirty(workdir)); - std::fs::write(workdir.join("untracked.txt"), b"new").expect("write"); - assert!(worktree_dirty(workdir)); - - git_run(workdir, &["rm", "--cached", "tracked.txt"]); - assert!(worktree_dirty(workdir)); - - // A missing directory is clean, not an error. - assert!(!worktree_dirty(&dir.path().join("missing"))); -} - -#[test] -fn worktree_commits_ahead_counts_branch_only_commits() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - let path = dir.path(); - - git_run(path, &["checkout", "-q", "-b", "feature"]); - std::fs::write(path.join("f.txt"), b"f\n").expect("write"); - commit_all(&gix::open(path).expect("open"), "feature work"); - - assert_eq!(worktree_commits_ahead(path, "main", "feature"), 1); - assert_eq!(worktree_commits_ahead(path, "feature", "main"), 0); - - git_run(path, &["checkout", "-q", "main"]); - assert_eq!(worktree_current_branch(path).as_deref(), Some("main")); - assert!(worktree_ref_exists(path, "refs/heads/feature")); - assert!(!worktree_ref_exists(path, "refs/heads/nope")); - assert_eq!(worktree_commits_ahead(path, "main", "feature"), 1); + assert_eq!(file.insertions, 0); + assert_eq!(file.deletions, 0); } diff --git a/crates/signed_nostr/Cargo.toml b/crates/signed_nostr/Cargo.toml index 5308c8e..dc6b3f3 100644 --- a/crates/signed_nostr/Cargo.toml +++ b/crates/signed_nostr/Cargo.toml @@ -8,12 +8,7 @@ publish.workspace = true nostr-sdk.workspace = true nostr-connect.workspace = true nostr-gossip-memory.workspace = true +nostr-lmdb.workspace = true anyhow.workspace = true webbrowser.workspace = true - -[target.'cfg(target_arch = "wasm32")'.dependencies] -nostr-memory.workspace = true - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -nostr-lmdb.workspace = true diff --git a/crates/signed_nostr/src/backend.rs b/crates/signed_nostr/src/backend.rs index ee66bfa..5a1f805 100644 --- a/crates/signed_nostr/src/backend.rs +++ b/crates/signed_nostr/src/backend.rs @@ -1,18 +1,13 @@ -#[cfg(not(target_arch = "wasm32"))] use std::path::Path; use std::time::Duration; use anyhow::{Context, Result}; use nostr_gossip_memory::prelude::*; -#[cfg(not(target_arch = "wasm32"))] use nostr_lmdb::prelude::*; -#[cfg(target_arch = "wasm32")] -use nostr_memory::prelude::*; use nostr_sdk::prelude::*; use crate::signer::UniversalSigner; -#[cfg(not(target_arch = "wasm32"))] pub async fn new_backend(db_path: impl AsRef) -> Result<(Client, UniversalSigner)> { let signer = UniversalSigner::new(Keys::generate()); let database = NostrLmdb::open(db_path) @@ -21,13 +16,6 @@ pub async fn new_backend(db_path: impl AsRef) -> Result<(Client, Universal Ok(with_database(signer, database)) } -/// In-memory database on wasm, LMDB is unavailable there. -#[cfg(target_arch = "wasm32")] -pub fn new_backend() -> Result<(Client, UniversalSigner)> { - let signer = UniversalSigner::new(Keys::generate()); - Ok(with_database(signer, MemoryDatabase::unbounded())) -} - fn with_database(signer: UniversalSigner, database: D) -> (Client, UniversalSigner) where D: IntoNostrDatabase, diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 6ef045e..23b85d8 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -40,10 +40,6 @@ pub enum BackendEvent { /// Repository events arrived: announcements, states, activity and deletions. RepoUpdates(Vec), Synced, - SyncProgress { - total: u64, - current: u64, - }, Error(String), } @@ -61,7 +57,6 @@ pub struct Backend { signer: UniversalSigner, current_user: Option, inbox: Entity, - sync_progress: Option<(u64, u64)>, /// True when the stored credential is NIP-49 encrypted. passphrase_required: bool, pushing_repos: Entity>, @@ -160,7 +155,6 @@ impl Backend { signer, current_user: None, inbox: cx.new(|_| Inbox::default()), - sync_progress: None, passphrase_required: false, pushing_repos: cx.new(|_| HashSet::new()), } @@ -170,7 +164,7 @@ impl Backend { /// /// - Emits [`BackendEvent::SignerRequired`] when no credential is stored. /// - Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity. - pub fn restore_session(&mut self, cx: &mut Context) { + fn restore_session(&mut self, cx: &mut Context) { if cfg!(target_arch = "wasm32") { cx.emit(BackendEvent::SignerRequired); return; @@ -913,117 +907,6 @@ impl Backend { }) } - /// Login with an `nsec1...` key or a `bunker://...` URI. - pub fn login(&mut self, credential: &str, cx: &mut Context) { - let credential = credential.trim(); - - if credential.starts_with("nsec1") { - self.login_with_nsec(credential, cx); - } else if credential.starts_with("bunker://") { - self.login_with_bunker(credential, cx); - } else { - cx.emit(BackendEvent::error("Unsupported credential.")); - } - } - - pub fn login_with_new_identity(&mut self, cx: &mut Context) { - let nsec = Keys::generate() - .secret_key() - .to_bech32() - .expect("infallible"); - self.login_with_nsec(&nsec, cx); - } - - pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context) { - let keys = match SecretKey::parse(nsec) { - Ok(secret) => Keys::new(secret), - Err(e) => { - cx.emit(BackendEvent::error(e.to_string())); - return; - } - }; - - let nsec = nsec.trim().to_owned(); - let pubkey = keys.public_key().to_hex(); - let write = cx.write_credentials(USER_KEYRING, &pubkey, nsec.as_bytes()); - - let task: Task> = cx.spawn(async move |this, cx| { - if let Err(e) = write.await { - this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; - return Ok(()); - } - this.update(cx, |this, cx| this.set_signer(keys, cx))?; - Ok(()) - }); - task.detach(); - } - - /// Login with a `bunker://...` URI, NIP-46. - pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context) { - let uri_string = uri.trim().to_owned(); - - let connect_uri = match NostrConnectUri::parse(&uri_string) { - Ok(uri) => uri, - Err(e) => { - cx.emit(BackendEvent::error(e.to_string())); - return; - } - }; - - let keys = Keys::generate(); - let credential = with_master_key(&uri_string, &keys); - let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes()); - - let task: Task> = cx.spawn(async move |this, cx| { - let result = async { - let mut signer = NostrConnect::new( - connect_uri, - keys, - Duration::from_secs(NOSTR_CONNECT_TIMEOUT), - None, - )?; - signer.auth_url_handler(SignedAuthUrlHandler); - - // Verify the signer before persisting the credential. - signer.get_public_key_async().await?; - write.await?; - - this.update(cx, |this, cx| this.set_signer(signer, cx))?; - - Ok::<_, Error>(()) - } - .await; - - if let Err(e) = result { - this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; - } - - Ok(()) - }); - task.detach(); - } - - pub fn logout(&mut self, cx: &mut Context) { - let delete = cx.delete_credentials(USER_KEYRING); - - let task: Task> = cx.spawn(async move |this, cx| { - delete.await.ok(); - - this.update(cx, |this, cx| { - this.signer.swap_inner(Keys::generate()); - this.current_user = None; - this.passphrase_required = false; - cx.emit(BackendEvent::SignerChanged); - cx.emit(BackendEvent::SignerRequired); - this.sync_inbox(cx); - cx.notify(); - })?; - - Ok(()) - }); - task.detach(); - } - fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context) { let client = self.client.clone(); @@ -1101,11 +984,7 @@ impl Backend { }); } - pub fn sync_progress(&self) -> Option<(u64, u64)> { - self.sync_progress - } - - pub fn set_signer(&mut self, new_signer: T, cx: &mut Context) + fn set_signer(&mut self, new_signer: T, cx: &mut Context) where T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static, ::Error: std::error::Error + Send + Sync + 'static, @@ -1190,46 +1069,14 @@ impl Backend { /// Sync several bootstrap filters in order, within a single task. pub fn sync_bootstraps(&mut self, filters: Vec, cx: &mut Context) { let client = self.client.clone(); - let (tx, mut rx) = SyncProgress::channel(); - - self.sync_progress = Some((0, 0)); - cx.notify(); - - cx.spawn(async move |this, cx| { - let mut last_percent: u64 = 0; - - while rx.changed().await.is_ok() { - let progress = *rx.borrow_and_update(); - let percent = (progress.percentage() * 100.0) as u64; - - if progress.current > 0 && percent != last_percent { - last_percent = percent; - - let alive = this.update(cx, |this, cx| { - this.sync_progress = Some((progress.total, progress.current)); - cx.emit(BackendEvent::SyncProgress { - total: progress.total, - current: progress.current, - }); - cx.notify(); - }); - - if alive.is_err() { - break; - } - } - } - - Ok::<(), anyhow::Error>(()) - }) - .detach(); let sync = cx.background_spawn(async move { let mut first_error = None; for filter in filters { - let opts = SyncOptions::default().progress(tx.clone()); - if let Err(error) = sync_bootstrap_only(&client, filter, opts).await { + if let Err(error) = + sync_bootstrap_only(&client, filter, SyncOptions::default()).await + { first_error.get_or_insert(error); } } @@ -1243,17 +1090,13 @@ impl Backend { cx.spawn(async move |this, cx| { match sync.await { Ok(_) => { - this.update(cx, |this, cx| { - this.sync_progress = None; + this.update(cx, |_this, cx| { cx.emit(BackendEvent::Synced); cx.notify(); })?; } Err(e) => { - this.update(cx, |this, cx| { - this.sync_progress = None; - cx.emit(BackendEvent::error(e.to_string())) - })?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } } @@ -1447,13 +1290,6 @@ pub(crate) async fn sync_bootstrap_only( Ok(output.value) } -/// Embed a NIP-46 session key into a bunker URI as `?master=`. -fn with_master_key(uri: &str, keys: &Keys) -> String { - let separator = if uri.contains('?') { '&' } else { '?' }; - let nsec = keys.secret_key().to_bech32().expect("infallible"); - format!("{uri}{separator}master={nsec}") -} - /// Base URL of a grasp server, `https://`. /// /// `ws://` grasp servers use `http://`, like ngit. @@ -1991,30 +1827,4 @@ mod tests { " ! [rejected] main -> main (non-fast-forward)" )); } - - #[test] - fn push_outcome_reports_partial_failures() { - let outcome = PushOutcome { - servers: vec![ - GraspServerResult::ok(RelayUrl::parse("wss://gitnostr.com").expect("url")), - GraspServerResult::failed( - RelayUrl::parse("wss://relay.ngit.dev").expect("url"), - "remote: ERR authorisation failed: No state events in purgatory\nfatal: ...", - ), - ], - state_event: None, - }; - - assert_eq!(outcome.accepted(), 1); - assert_eq!( - outcome.failure_summary(), - "wss://relay.ngit.dev: remote: ERR authorisation failed: No state events in \ - purgatory fatal: ..." - ); - let warning = outcome.partial_warning().expect("partial push warning"); - assert!(warning.starts_with("Pushed to 1 of 2 grasp servers")); - assert!(warning.contains("Republish to sync")); - // The multi-line server reason is a single display line. - assert_eq!(warning.lines().count(), 1); - } } diff --git a/crates/signed_state/src/checkouts.rs b/crates/signed_state/src/checkouts.rs index 7822c01..0544492 100644 --- a/crates/signed_state/src/checkouts.rs +++ b/crates/signed_state/src/checkouts.rs @@ -715,8 +715,6 @@ pub fn pr_proposes_checkout( mod tests { use std::process::Command; - use signed_core::{RepoAddr, repo_addr}; - use super::*; #[test] @@ -744,103 +742,6 @@ mod tests { assert!(!same_repo_url("/local/path", "/local/other")); } - fn remembered(path: &str, id: &str, last_used: u64) -> Remembered { - Remembered { - path: PathBuf::from(path), - addr: addr(id), - last_used, - } - } - - fn scanned( - path: &str, - origin: Option<&str>, - root: Option<&str>, - ) -> (PathBuf, Option, Option) { - ( - PathBuf::from(path), - origin.map(str::to_owned), - root.map(str::to_owned), - ) - } - - const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; - - fn owner() -> PublicKey { - Keys::new(SecretKey::from_hex(KEY).expect("secret")).public_key() - } - - fn addr(id: &str) -> RepoAddr { - repo_addr(owner(), id) - } - - /// Build one announcement by the fixed test owner. - /// Takes `clone` URLs and an EUC. - fn announcement(id: &str, clones: &[&str], euc: Option<&str>) -> Announcement { - let keys = Keys::new(SecretKey::from_hex(KEY).expect("secret")); - let mut tags = vec![Tag::parse(vec!["d", id]).expect("tag")]; - for url in clones { - tags.push(Tag::parse(vec!["clone", *url]).expect("tag")); - } - if let Some(euc) = euc { - tags.push(Tag::parse(vec!["r", euc, "euc"]).expect("tag")); - } - let event = EventBuilder::new(Kind::GitRepoAnnouncement, "") - .tags(tags) - .finalize(&keys) - .expect("signed"); - Announcement::from_event(&event).expect("parsed") - } - - #[test] - fn resolve_orders_remembered_freshest_first() { - let announcements = vec![announcement("repo", &[], None)]; - let base = addr("repo"); - - let resolved = resolve_associations( - &[ - remembered("/old", "repo", 100), - remembered("/fresh", "repo", 200), - remembered("/other", "unrelated", 300), - ], - &[], - &announcements, - ); - - let paths = resolved.get(&base).expect("associations"); - assert_eq!(paths, &vec![PathBuf::from("/fresh"), PathBuf::from("/old")]); - // Records for repositories without announcements stay inert. - assert_eq!(resolved.len(), 2); - } - - #[test] - fn resolve_deduplicates_paths_remembering_first() { - let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; - let announcements = vec![announcement( - "repo", - &["https://host/npub1x/repo.git"], - Some(euc), - )]; - let base = addr("repo"); - - // The same path is both remembered and scanned, its origin matches. - // The remembered occurrence wins and the path is listed once. - let resolved = resolve_associations( - &[remembered("/shared", "repo", 100)], - &[ - scanned("/shared", Some("https://host/npub1x/repo"), None), - scanned("/scanned-only", Some("https://host/npub1x/repo.git"), None), - ], - &announcements, - ); - - let paths = resolved.get(&base).expect("associations"); - assert_eq!( - paths, - &vec![PathBuf::from("/shared"), PathBuf::from("/scanned-only")] - ); - } - #[test] fn checkout_status_reports_ahead_branches_only() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/signed_state/src/git_store.rs b/crates/signed_state/src/git_store.rs index 00cc6da..141a127 100644 --- a/crates/signed_state/src/git_store.rs +++ b/crates/signed_state/src/git_store.rs @@ -3,6 +3,7 @@ use std::sync::OnceLock; use anyhow::Result; use gix::Repository; +use nostr::prelude::Url; use signed_core::RepoAddr; use signed_git::GitCache; @@ -30,7 +31,7 @@ pub fn open_repo_mirror(addr: &RepoAddr) -> Result> { } /// Open the mirror of `addr`, cloning it first when it does not exist yet. -pub fn ensure_repo_mirror>(addr: &RepoAddr, clone_urls: &[U]) -> Result { +pub fn ensure_repo_mirror(addr: &RepoAddr, clone_urls: &[Url]) -> Result { git_cache().ensure_clone(addr, clone_urls) } diff --git a/crates/signed_state/src/inbox.rs b/crates/signed_state/src/inbox.rs index aed4baa..52839cf 100644 --- a/crates/signed_state/src/inbox.rs +++ b/crates/signed_state/src/inbox.rs @@ -24,41 +24,6 @@ impl Inbox { self.loaded } - pub fn mark_read( - &mut self, - group: &[Event], - all: &[Event], - me: PublicKey, - cx: &mut Context, - ) { - for event in group { - self.state.mark_read(event); - } - self.state.advance_read(all, me, Timestamp::now()); - self.persist(cx); - cx.notify(); - } - - /// Archived events are always read too. - pub fn mark_archived( - &mut self, - group: &[Event], - all: &[Event], - me: PublicKey, - cx: &mut Context, - ) { - for event in group { - self.state.mark_archived(event); - self.state.mark_read(event); - } - - let now = Timestamp::now(); - self.state.advance_archived(all, me, now); - self.state.advance_read(all, me, now); - self.persist(cx); - cx.notify(); - } - pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context) { self.state.mark_all_read(all, me, Timestamp::now()); self.persist(cx); diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 323577f..c8fadb5 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -16,13 +16,13 @@ use git_store::set_git_cache; pub use git_store::{ensure_repo_mirror, open_repo_mirror, repo_mirror_path}; use gpui::{App, AppContext}; pub use inbox::{Inbox, query_inbox}; -pub use local_repos::{LocalReposStore, ResolvedLocalRepo, local_repo_addr, resolve_local_repos}; +pub use local_repos::{LocalReposStore, ResolvedLocalRepo, resolve_local_repos}; pub use nostr_sdk::prelude::Timestamp; pub use profile::{Profile, ProfileStore}; pub use refresh::{RefreshGate, RefreshRequest}; pub use repo::RepoStore; -pub use repos::{RepoActivityCounts, RepoListStore}; -pub use signed_git::{GraspSignals, LocalRepo, Nip34Binding, Nip34Kind}; +pub use repos::RepoListStore; +pub use signed_git::{Nip34Binding, Nip34Kind}; use signed_nostr::new_backend; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/signed_state/src/local_repos.rs b/crates/signed_state/src/local_repos.rs index 2a072e3..19bfbdd 100644 --- a/crates/signed_state/src/local_repos.rs +++ b/crates/signed_state/src/local_repos.rs @@ -235,56 +235,4 @@ mod tests { assert_eq!(resolved.len(), 1); assert_eq!(resolved[0].announcement.as_ref(), Some(&known)); } - - #[test] - fn an_unmatched_repository_keeps_its_binding() { - let repo = bound(KEY, "unlisted"); - - let resolved = resolve_local_repos(&[repo], &[], &[]); - - assert_eq!(resolved.len(), 1); - assert!(resolved[0].announcement.is_none()); - assert_eq!( - resolved[0].nip34.as_ref().map(|binding| binding.kind), - Some(Nip34Kind::Initialized) - ); - } - - #[test] - fn a_plain_repository_is_kept_without_a_binding() { - let repo = LocalRepo { - path: PathBuf::from("plain"), - nip34: None, - }; - - let resolved = resolve_local_repos(&[repo], &[], &[]); - - assert_eq!(resolved.len(), 1); - assert!(resolved[0].nip34.is_none()); - assert!(resolved[0].announcement.is_none()); - } - - #[test] - fn the_name_is_the_directory_name() { - let repo = LocalRepo { - path: PathBuf::from("/tmp/my-repo"), - nip34: None, - }; - - let resolved = resolve_local_repos(&[repo], &[], &[]); - - assert_eq!(resolved[0].name(), SharedString::from("my-repo")); - } - - #[test] - fn a_path_without_a_directory_name_is_untitled() { - let repo = LocalRepo { - path: PathBuf::from("/"), - nip34: None, - }; - - let resolved = resolve_local_repos(&[repo], &[], &[]); - - assert_eq!(resolved[0].name(), SharedString::from("Untitled")); - } } diff --git a/crates/signed_state/src/refresh.rs b/crates/signed_state/src/refresh.rs index 695cb7c..e296d72 100644 --- a/crates/signed_state/src/refresh.rs +++ b/crates/signed_state/src/refresh.rs @@ -42,48 +42,3 @@ impl RefreshGate { self.running = false; } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_request_while_running_folds_into_a_follow_up() { - let mut gate = RefreshGate::default(); - gate.begin(); - - assert_eq!(gate.request(), RefreshRequest::Fold); - assert!(gate.finish()); - } - - #[test] - fn a_request_without_a_run_schedules() { - let mut gate = RefreshGate::default(); - - assert_eq!(gate.request(), RefreshRequest::Schedule); - assert!(!gate.running()); - } - - #[test] - fn a_request_after_a_run_schedules_again() { - let mut gate = RefreshGate::default(); - gate.begin(); - assert_eq!(gate.request(), RefreshRequest::Fold); - assert!(gate.finish()); - - assert_eq!(gate.request(), RefreshRequest::Schedule); - } - - #[test] - fn abort_keeps_the_pending_request() { - let mut gate = RefreshGate::default(); - gate.begin(); - assert_eq!(gate.request(), RefreshRequest::Fold); - - gate.abort(); - assert!(!gate.running()); - - gate.begin(); - assert!(gate.finish()); - } -} diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index ffd9623..0174abd 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -9,8 +9,7 @@ use nostr::event::IntoEventBuilder; use nostr_sdk::prelude::*; use settings::{EventFetchingStrategy, SettingsStore}; use signed_core::{ - Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch, - pull_request_patches, + Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patches, }; use signed_git::Nip34Binding; use signed_nostr::UniversalSigner; @@ -20,7 +19,6 @@ use crate::backend::{ user_grasp_list_servers, }; use crate::checkouts::CheckoutsStore; -use crate::git_store::ensure_repo_mirror; use crate::repos::RepoListStore; /// Maximum size of one patch event. @@ -657,7 +655,7 @@ impl RepoStore { /// Reply to `parent`, a comment on `root`, with a NIP-22 threaded comment. /// /// `None` publishes a top-level comment on the root itself. - pub fn reply( + fn reply( &mut self, root: &Event, parent: Option<&Event>, @@ -1151,7 +1149,7 @@ impl RepoStore { /// Set the status of a root event. /// /// Only the root author or a maintainer may set it, per NIP-34. - pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context) { + fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context) { self.last_error = None; let Some(addr) = self.addr.clone() else { @@ -1192,88 +1190,6 @@ impl RepoStore { self.publish(builder, cx); } - pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context) { - self.last_error = None; - self.last_warning = None; - - let Some(addr) = self.addr.clone() else { - self.not_announced(cx); - return; - }; - - let is_author = Backend::global(cx) - .read(cx) - .current_user() - .is_some_and(|user| self.is_author(&user)); - if !is_author { - self.last_error = Some("Only the repository author can merge pull requests".into()); - return; - } - - let clone_urls: Vec = self - .announcement - .as_ref() - .map(|a| a.clone.clone()) - .unwrap_or_default(); - - let patch = pull_request_patch(root, self.patches.iter()); - - // The applied patch events, for the status tags below. - let patches: Vec = pull_request_patches(root, self.patches.iter()) - .into_iter() - .cloned() - .collect(); - - let relay_hint = self - .announcement - .as_ref() - .and_then(|a| a.relays.first()) - .map(ToString::to_string) - .unwrap_or_default(); - - let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); - let root = root.clone(); - - let apply = cx.background_spawn(async move { - let repo = ensure_repo_mirror(&addr, &clone_urls)?; - let workdir = repo - .workdir() - .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? - .to_path_buf(); - // The commits the apply created. - // Everything between the previous HEAD and the new one, oldest first. - let previous = signed_git::head_commit_id(&workdir)?; - signed_git::apply_patch(&workdir, &patch)?; - let applied = signed_git::commits_since(&workdir, previous.as_deref())?; - Ok::<_, Error>(applied) - }); - - let task: Task> = cx.spawn(async move |this, cx| { - match apply.await { - Ok(applied) => { - this.update(cx, |this, cx| { - this.publish_applied_status( - &root, - &patches, - &applied, - &relay_hint, - euc.as_deref(), - cx, - ); - })?; - } - Err(e) => { - this.update(cx, |this, cx| { - this.last_error = Some(e.to_string()); - cx.notify(); - })?; - } - } - Ok(()) - }); - self.tasks.push(task); - } - /// The latest announcement of this repository, /// for operations that need its clone URLs and relays. fn action_announcement(&self, cx: &App) -> Option { @@ -1495,66 +1411,6 @@ impl RepoStore { Task::ready(Err(anyhow::anyhow!("{message}"))) } - /// Publish a kind-1631 Applied status event for `root` after a merge. - fn publish_applied_status( - &mut self, - root: &Event, - patches: &[Event], - applied: &[String], - relay_hint: &str, - euc: Option<&str>, - cx: &mut Context, - ) { - let Some(addr) = self.addr.clone() else { - return; - }; - - let mut tags = vec![ - Tag::parse(["e", &root.id.to_hex(), "", "root"]).expect("valid root tag"), - Tag::public_key(addr.public_key), - Tag::public_key(root.pubkey), - Tag::coordinate(addr, None), - ]; - - if let Some(euc) = euc - && let Ok(tag) = Tag::parse(["r", euc]) - { - tags.push(tag); - } - - // Tag each applied patch event. - // `q` per event, `e` reply for events beyond the root, chain parts and revisions. - // Their statuses then resolve to Applied too. - for (ix, patch) in patches.iter().enumerate() { - if let Ok(tag) = - Tag::parse(["q", &patch.id.to_hex(), relay_hint, &patch.pubkey.to_hex()]) - { - tags.push(tag); - } - if ix > 0 - && let Ok(tag) = Tag::parse(["e", &patch.id.to_hex(), "", "reply"]) - { - tags.push(tag); - } - } - - // The commits `git am` created on top of the previous HEAD. - if !applied.is_empty() { - let mut applied_tag = vec!["applied-as-commits".to_string()]; - applied_tag.extend(applied.iter().cloned()); - if let Ok(tag) = Tag::parse(applied_tag) { - tags.push(tag); - } - for commit in applied { - if let Ok(tag) = Tag::parse(["r", commit]) { - tags.push(tag); - } - } - } - - self.publish(EventBuilder::new(Kind::GitStatusApplied, "").tags(tags), cx); - } - /// Sign `builder`, broadcast it and track the outcome in [`Self::last_error`]. /// /// Every one-shot repository event (issue, comment, status) goes through diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 825b873..f0485dd 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,7 +1,8 @@ use dock::{DockArea, DockEvent, DockLayout, DockPlacement, SignedDockSkin, panel_handle}; use gpui::prelude::*; use gpui::{Context, Entity, Render, Subscription, Window, div, px}; -use gpui_component::{Root, StyledExt, Theme}; +use gpui_component::notification::NotificationType; +use gpui_component::{Root, StyledExt, Theme, WindowExt}; use settings::{AppearanceMode, SettingsStore}; use signed_state::{Backend, BackendEvent}; @@ -66,6 +67,10 @@ impl Workspace { if matches!(event, BackendEvent::PassphraseRequired) { passphrase_dialog::open(window, cx); } + + if let BackendEvent::Error(message) = event { + window.push_notification((NotificationType::Error, message.clone()), cx); + } }, )); -- 2.54.0 From d00ee33eabe9af5eac4fde080d3d34cb82dfb812 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 3 Oct 2026 11:00:59 +0700 Subject: [PATCH 3/9] consolidate shared helpers into utils crate --- Cargo.lock | 1 + crates/signed_core/Cargo.toml | 1 + crates/signed_core/src/inbox.rs | 13 +---- crates/signed_state/src/backend.rs | 14 +----- crates/signed_state/src/checkouts.rs | 18 +------ crates/signed_state/src/repo.rs | 27 +++-------- crates/signed_ui/src/lib.rs | 2 - crates/signed_ui/src/util.rs | 38 --------------- crates/utils/src/lib.rs | 6 ++- crates/utils/src/pubkey.rs | 47 +++---------------- crates/utils/src/repo_url.rs | 20 ++++++++ crates/utils/src/text.rs | 21 +++++++++ crates/utils/src/time.rs | 39 ++++++++------- .../workspace/src/views/pull_requests/new.rs | 12 +---- crates/workspace/src/views/repo/about.rs | 3 +- crates/workspace/src/views/repo/mod.rs | 4 +- 16 files changed, 89 insertions(+), 177 deletions(-) delete mode 100644 crates/signed_ui/src/util.rs create mode 100644 crates/utils/src/repo_url.rs create mode 100644 crates/utils/src/text.rs diff --git a/Cargo.lock b/Cargo.lock index 758a67a..b025955 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8134,6 +8134,7 @@ version = "0.2.0-alpha" dependencies = [ "nostr", "serde", + "utils", ] [[package]] diff --git a/crates/signed_core/Cargo.toml b/crates/signed_core/Cargo.toml index f4d90a4..a6da80d 100644 --- a/crates/signed_core/Cargo.toml +++ b/crates/signed_core/Cargo.toml @@ -7,3 +7,4 @@ publish.workspace = true [dependencies] nostr.workspace = true serde.workspace = true +utils = { path = "../utils" } diff --git a/crates/signed_core/src/inbox.rs b/crates/signed_core/src/inbox.rs index 9b88a36..7889bba 100644 --- a/crates/signed_core/src/inbox.rs +++ b/crates/signed_core/src/inbox.rs @@ -188,8 +188,8 @@ where .map(|root| { let mut events = groups.remove(&root).unwrap_or_default(); let mut own_events = own_groups.remove(&root).unwrap_or_default(); - sort_newest_first(&mut events); - sort_newest_first(&mut own_events); + utils::sort_newest_first(&mut events); + utils::sort_newest_first(&mut own_events); let root_event = lookup(root); @@ -218,15 +218,6 @@ where items } -/// Sort thread events newest first, ties broken by id. -fn sort_newest_first(events: &mut [Event]) { - events.sort_by(|a, b| { - b.created_at - .cmp(&a.created_at) - .then_with(|| b.id.to_hex().cmp(&a.id.to_hex())) - }); -} - /// Read and archive state of the inbox, a high-water-mark model. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct InboxReadState { diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 23b85d8..589fbc5 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -1418,7 +1418,7 @@ impl PushOutcome { self.failing() .map(|server| { let reason = - flatten_whitespace(server.reason.as_deref().unwrap_or("unknown error")); + utils::flatten_whitespace(server.reason.as_deref().unwrap_or("unknown error")); format!("{}: {reason}", server.relay) }) .collect::>() @@ -1441,18 +1441,6 @@ impl PushOutcome { } } -fn flatten_whitespace(text: &str) -> String { - const MAX_CHARS: usize = 200; - let flat: String = text.split_whitespace().collect::>().join(" "); - if flat.chars().count() <= MAX_CHARS { - flat - } else { - let mut clipped: String = flat.chars().take(MAX_CHARS).collect(); - clipped.push('…'); - clipped - } -} - /// Reasons a push attempt should be retried with a freshly staged state /// event and a fresh git advertisement. /// diff --git a/crates/signed_state/src/checkouts.rs b/crates/signed_state/src/checkouts.rs index 0544492..67f48f5 100644 --- a/crates/signed_state/src/checkouts.rs +++ b/crates/signed_state/src/checkouts.rs @@ -7,6 +7,7 @@ use gpui::{App, AppContext, Context, Entity, Global, Subscription}; use nostr::prelude::*; use settings::{CheckoutRecord, SettingsStore}; use signed_core::{Announcement, RepoAddr}; +use utils::same_repo_url; use crate::backend::{Backend, BackendEvent}; use crate::git_store::repo_mirror_root; @@ -511,23 +512,6 @@ impl CheckoutsStore { } } -fn url_identity(url: &str) -> Option<(String, Option, String)> { - let parsed = Url::parse(url).ok()?; - let host = parsed.host_str()?.to_ascii_lowercase(); - let mut path = parsed.path().trim_matches('/').to_owned(); - if let Some(stripped) = path.strip_suffix(".git") { - path = stripped.to_owned(); - } - Some((host, parsed.port(), path)) -} - -fn same_repo_url(a: &str, b: &str) -> bool { - match (url_identity(a), url_identity(b)) { - (Some(a), Some(b)) => a == b, - _ => a == b, - } -} - fn resolve_associations<'a>( remembered: &[Remembered], scanned: &[(PathBuf, Option, Option)], diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 0174abd..bd2a52e 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -392,12 +392,12 @@ impl RepoStore { .into_iter() .filter(|e| !deletions.is_deleted(e)); - let announcement = latest(all_announcements) + let announcement = utils::latest(all_announcements) .as_ref() .and_then(Announcement::from_event); let all_states = states.into_iter().filter(|e| !deletions.is_deleted(e)); - let state = latest(all_states).map(|state| parse_state(&state)); + let state = utils::latest(all_states).map(|state| parse_state(&state)); let (mut issues, mut patches, mut pull_requests, mut statuses, mut comments) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()); @@ -450,10 +450,10 @@ impl RepoStore { } } - sort_newest_first(&mut issues); - sort_newest_first(&mut patches); - sort_newest_first(&mut pull_requests); - sort_oldest_first(&mut comments); + utils::sort_newest_first(&mut issues); + utils::sort_newest_first(&mut patches); + utils::sort_newest_first(&mut pull_requests); + utils::sort_oldest_first(&mut comments); let maintainers = announcement .as_ref() @@ -1447,13 +1447,6 @@ impl RepoStore { } } -fn latest(events: I) -> Option -where - I: IntoIterator, -{ - events.into_iter().max_by_key(|e| e.created_at) -} - fn status_of(status_by_root: &HashMap, root: &Event) -> RepoStatus { status_by_root .get(&root.id) @@ -1492,14 +1485,6 @@ fn resolve_statuses( .collect() } -fn sort_newest_first(events: &mut [Event]) { - events.sort_by_key(|e| std::cmp::Reverse(e.created_at)); -} - -fn sort_oldest_first(events: &mut [Event]) { - events.sort_by_key(|e| e.created_at); -} - /// The proposed commit of a `git format-patch` output. /// It is the `From ` header on the first line. fn patch_current_commit(patch: &str) -> Option<&str> { diff --git a/crates/signed_ui/src/lib.rs b/crates/signed_ui/src/lib.rs index bad4e4d..e3ab674 100644 --- a/crates/signed_ui/src/lib.rs +++ b/crates/signed_ui/src/lib.rs @@ -11,7 +11,6 @@ mod title_bar; mod tree_row; pub mod copy_row; -pub mod util; pub use avatar::Avatar; pub use copy_row::{copy_row, menu_copy_row}; @@ -25,4 +24,3 @@ pub use setting::{SelectOption, setting_block, setting_row}; pub use status_badge::status_badge; pub use title_bar::title_bar_drag_handlers; pub use tree_row::tree_row; -pub use util::middle_truncate; diff --git a/crates/signed_ui/src/util.rs b/crates/signed_ui/src/util.rs deleted file mode 100644 index 64d187a..0000000 --- a/crates/signed_ui/src/util.rs +++ /dev/null @@ -1,38 +0,0 @@ -/// `[head chars]...[tail chars]` middle truncation. -/// -/// Values too short for the ellipsis to save space are left alone. -pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String { - let len = value.chars().count(); - if len <= head + tail + 3 { - return value.to_string(); - } - let head: String = value.chars().take(head).collect(); - let tail: String = value.chars().skip(len - tail).collect(); - format!("{head}...{tail}") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn middle_truncates_long_values_only() { - assert_eq!( - middle_truncate( - "a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d", - 10, - 10, - ), - "a008def157...ad57a3564d" - ); - assert_eq!( - middle_truncate( - "30617:a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d:ngit", - 10, - 10 - ), - "30617:a008...3564d:ngit" - ); - assert_eq!(middle_truncate("short", 10, 10), "short"); - } -} diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 0fe56c3..14398f5 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -1,5 +1,9 @@ mod pubkey; +mod repo_url; +mod text; mod time; pub use pubkey::shorten_pubkey; -pub use time::{relative_time, relative_time_secs}; +pub use repo_url::same_repo_url; +pub use text::{flatten_whitespace, middle_truncate}; +pub use time::{latest, relative_time, relative_time_secs, sort_newest_first, sort_oldest_first}; diff --git a/crates/utils/src/pubkey.rs b/crates/utils/src/pubkey.rs index 65b7b01..d8d1ea0 100644 --- a/crates/utils/src/pubkey.rs +++ b/crates/utils/src/pubkey.rs @@ -1,49 +1,14 @@ use nostr::prelude::*; -/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form. +use crate::text::middle_truncate; + +const HEAD_CHARS: usize = 9; +const TAIL_CHARS: usize = 4; + pub fn shorten_pubkey(public_key: PublicKey) -> String { let encoded = public_key .to_bech32() .unwrap_or_else(|_| public_key.to_hex()); - truncate_middle(&encoded) -} - -fn truncate_middle(value: &str) -> String { - const HEAD_CHARS: usize = 9; - const TAIL_CHARS: usize = 4; - - let length = value.chars().count(); - if length <= HEAD_CHARS + TAIL_CHARS + 3 { - return value.to_owned(); - } - - let head: String = value.chars().take(HEAD_CHARS).collect(); - let tail: String = value.chars().skip(length - TAIL_CHARS).collect(); - - format!("{head}...{tail}") -} - -#[cfg(test)] -mod tests { - use super::*; - - const PUBLIC_KEY_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272"; - - #[test] - fn shortens_a_valid_pubkey() { - let public_key = PublicKey::from_hex(PUBLIC_KEY_HEX).expect("valid pubkey"); - let npub = public_key.to_bech32().expect("valid pubkey encodes"); - - assert_eq!( - shorten_pubkey(public_key), - format!("{}...{}", &npub[..9], &npub[npub.len() - 4..]) - ); - } - - #[test] - fn leaves_short_values_intact() { - assert_eq!(truncate_middle("npub1short"), "npub1short"); - assert_eq!(truncate_middle("thirteenchars"), "thirteenchars"); - } + middle_truncate(&encoded, HEAD_CHARS, TAIL_CHARS) } diff --git a/crates/utils/src/repo_url.rs b/crates/utils/src/repo_url.rs new file mode 100644 index 0000000..cc9bbbd --- /dev/null +++ b/crates/utils/src/repo_url.rs @@ -0,0 +1,20 @@ +use nostr::prelude::*; + +/// Compare git URLs ignoring scheme, trailing `.git` and path slashes, so a +/// grasp announce URL matches its https origin. Unparseable values compare literally. +pub fn same_repo_url(a: &str, b: &str) -> bool { + match (url_identity(a), url_identity(b)) { + (Some(a), Some(b)) => a == b, + _ => a == b, + } +} + +fn url_identity(url: &str) -> Option<(String, Option, String)> { + let parsed = Url::parse(url).ok()?; + let host = parsed.host_str()?.to_ascii_lowercase(); + let mut path = parsed.path().trim_matches('/').to_owned(); + if let Some(stripped) = path.strip_suffix(".git") { + path = stripped.to_owned(); + } + Some((host, parsed.port(), path)) +} diff --git a/crates/utils/src/text.rs b/crates/utils/src/text.rs new file mode 100644 index 0000000..c79c6b5 --- /dev/null +++ b/crates/utils/src/text.rs @@ -0,0 +1,21 @@ +pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String { + let len = value.chars().count(); + if len <= head + tail + 3 { + return value.to_string(); + } + let head: String = value.chars().take(head).collect(); + let tail: String = value.chars().skip(len - tail).collect(); + format!("{head}...{tail}") +} + +pub fn flatten_whitespace(text: &str) -> String { + const MAX_CHARS: usize = 200; + let flat = text.split_whitespace().collect::>().join(" "); + if flat.chars().count() <= MAX_CHARS { + flat + } else { + let mut clipped: String = flat.chars().take(MAX_CHARS).collect(); + clipped.push('…'); + clipped + } +} diff --git a/crates/utils/src/time.rs b/crates/utils/src/time.rs index 230ddf9..4070cf0 100644 --- a/crates/utils/src/time.rs +++ b/crates/utils/src/time.rs @@ -1,6 +1,24 @@ use nostr::prelude::*; -/// Format a timestamp as a short relative time, e.g. `3h ago`. +pub fn sort_newest_first(events: &mut [Event]) { + events.sort_by(|a, b| { + b.created_at + .cmp(&a.created_at) + .then_with(|| b.id.to_hex().cmp(&a.id.to_hex())) + }); +} + +pub fn sort_oldest_first(events: &mut [Event]) { + events.sort_by_key(|e| e.created_at); +} + +pub fn latest(events: I) -> Option +where + I: IntoIterator, +{ + events.into_iter().max_by_key(|e| e.created_at) +} + pub fn relative_time(timestamp: Timestamp) -> String { let now = Timestamp::now().as_secs(); let secs = now.saturating_sub(timestamp.as_secs()); @@ -20,25 +38,6 @@ pub fn relative_time(timestamp: Timestamp) -> String { } } -/// Format a unix timestamp in seconds as a short relative time, e.g. `3h ago`. pub fn relative_time_secs(secs: i64) -> String { relative_time(Timestamp::from_secs(secs.max(0) as u64)) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn formats_relative_time() { - let now = Timestamp::now(); - - assert_eq!(relative_time(now), "just now"); - assert_eq!(relative_time(now - 300), "5m ago"); - assert_eq!(relative_time(now - 7_200), "2h ago"); - assert_eq!(relative_time(now - 3 * 86_400), "3d ago"); - assert_eq!(relative_time(now - 60 * 86_400), "2mo ago"); - assert_eq!(relative_time(now - 800 * 86_400), "2y ago"); - assert_eq!(relative_time(now + 600), "just now"); - } -} diff --git a/crates/workspace/src/views/pull_requests/new.rs b/crates/workspace/src/views/pull_requests/new.rs index 4d29ba1..88e417c 100644 --- a/crates/workspace/src/views/pull_requests/new.rs +++ b/crates/workspace/src/views/pull_requests/new.rs @@ -30,6 +30,7 @@ use signed_state::{ Backend, CheckoutsStore, RepoListStore, RepoStore, ensure_repo_mirror, repo_mirror_path, }; use signed_ui::{CountBadge, placeholder, ref_selector_trigger}; +use utils::middle_truncate; use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row}; use crate::views::{repo_tab_avatar, tab_title}; @@ -104,17 +105,8 @@ fn shorten_owner(owner: &PublicKey) -> String { hex.chars().take(10).collect() } -/// Truncate a label for the fixed-width controls of the compare bar. fn truncate_label(label: &str) -> SharedString { - const MAX: usize = 18; - let mut chars = label.chars(); - let (prefix, rest) = (chars.by_ref().take(MAX).collect::(), chars.next()); - let label = if rest.is_some() { - format!("{}…", &prefix[..prefix.len().saturating_sub(1)]) - } else { - prefix - }; - SharedString::from(label) + SharedString::from(middle_truncate(label, 18, 0)) } fn checkout_source_item( diff --git a/crates/workspace/src/views/repo/about.rs b/crates/workspace/src/views/repo/about.rs index bfd6fbc..145bcdf 100644 --- a/crates/workspace/src/views/repo/about.rs +++ b/crates/workspace/src/views/repo/about.rs @@ -5,7 +5,8 @@ use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex}; use nostr::prelude::PublicKey; use signed_core::Announcement; use signed_state::ProfileStore; -use signed_ui::{Avatar, middle_truncate}; +use signed_ui::Avatar; +use utils::middle_truncate; pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) { window.open_dialog(cx, move |dialog, _window, cx| { diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 8161042..fcb7a93 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -31,9 +31,9 @@ use signed_state::{ pr_proposes_checkout, }; use signed_ui::{ - Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, middle_truncate, - ref_selector_trigger, + Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, ref_selector_trigger, }; +use utils::middle_truncate; mod about; mod actions; -- 2.54.0 From 023267001d784d020c7a7a4f582cd97602a38f99 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 3 Oct 2026 12:03:29 +0700 Subject: [PATCH 4/9] reorganize signed_core into method-based APIs --- crates/signed_core/src/addr.rs | 120 ++++- crates/signed_core/src/filters.rs | 265 ++++------ crates/signed_core/src/inbox.rs | 289 ++++++----- crates/signed_core/src/lib.rs | 14 +- crates/signed_core/src/model.rs | 491 ++++++++++-------- crates/signed_core/src/state.rs | 92 ++-- crates/signed_core/src/status.rs | 44 +- crates/signed_git/src/cache.rs | 4 +- crates/signed_state/src/backend.rs | 32 +- crates/signed_state/src/inbox.rs | 10 +- crates/signed_state/src/local_repos.rs | 4 +- crates/signed_state/src/repo.rs | 89 ++-- crates/signed_state/src/repos.rs | 28 +- crates/workspace/src/views/issues/detail.rs | 4 +- crates/workspace/src/views/issues/mod.rs | 4 +- .../src/views/pull_requests/detail.rs | 24 +- .../workspace/src/views/pull_requests/mod.rs | 4 +- .../workspace/src/views/pull_requests/new.rs | 4 +- crates/workspace/src/views/repo/mod.rs | 6 +- crates/workspace/src/views/repo_list.rs | 2 +- 20 files changed, 833 insertions(+), 697 deletions(-) diff --git a/crates/signed_core/src/addr.rs b/crates/signed_core/src/addr.rs index 79d57fc..0945259 100644 --- a/crates/signed_core/src/addr.rs +++ b/crates/signed_core/src/addr.rs @@ -1,23 +1,113 @@ +use std::fmt; +use std::str::FromStr; + use nostr::prelude::*; +use serde::{Deserialize, Serialize}; /// Address of a NIP-34 repository announcement, `30617::`. /// -/// The Rust Nostr SDK's [`Coordinate`] parses, formats and hashes this, -/// the alias reuses the SDK type while keeping repository-specific vocabulary. -pub type RepoAddr = Coordinate; +/// Wraps the Rust Nostr SDK's [`Coordinate`], which parses, formats and hashes this. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct RepoAddr(Coordinate); -pub fn repo_addr(owner: PublicKey, id: impl Into) -> RepoAddr { - Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(id) +impl RepoAddr { + pub fn new(owner: PublicKey, identifier: impl Into) -> Self { + Self(Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(identifier)) + } + + /// NIP-34 `d` tag identifier for a user-visible repository name. + pub fn identifier_from_name(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '/' { + c + } else { + '-' + } + }) + .collect() + } + + pub fn kind(&self) -> Kind { + self.0.kind + } + + pub fn public_key(&self) -> PublicKey { + self.0.public_key + } + + pub fn identifier(&self) -> &str { + &self.0.identifier + } + + pub fn coordinate(&self) -> &Coordinate { + &self.0 + } + + /// Latest announcement event for this repository. + pub fn announcement_filter(&self) -> Filter { + Filter::new() + .kind(Kind::GitRepoAnnouncement) + .author(self.public_key()) + .identifier(self.identifier()) + } + + /// Latest state event for this repository, carrying refs and HEAD. + pub fn state_filter(&self) -> Filter { + Filter::new() + .kind(Kind::RepoState) + .author(self.public_key()) + .identifier(self.identifier()) + } + + /// All NIP-34 activity addressed to this repository via its `#a` tag. + /// Covers issues, PRs, patches, statuses and kind-1111 comments. + /// The `a` tag is optional on status events per NIP-34. + /// Statuses published without it are not matched here. + pub fn activity_filter(&self) -> Filter { + Filter::new() + .kinds(crate::filters::ACTIVITY_KINDS) + .coordinate(&self.0) + } + + /// Deletion events relevant to this repository. + /// + /// Requests authored by the repository owner. + /// + /// Requests addressed to the repository coordinate via its `#a` tag. + pub fn deletion_filters(&self) -> Vec { + vec![ + Filter::new() + .kinds([Kind::EventDeletion, Kind::RequestToVanish]) + .author(self.public_key()), + Filter::new().kind(Kind::EventDeletion).coordinate(&self.0), + ] + } } -pub fn identifier_from_name(name: &str) -> String { - name.chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '/' { - c - } else { - '-' - } - }) - .collect() +impl fmt::Display for RepoAddr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +impl FromStr for RepoAddr { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + Coordinate::from_str(s).map(RepoAddr) + } +} + +impl From for RepoAddr { + fn from(coordinate: Coordinate) -> Self { + RepoAddr(coordinate) + } +} + +impl From for Coordinate { + fn from(addr: RepoAddr) -> Self { + addr.0 + } } diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index 01dcaf0..01710e2 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -2,8 +2,6 @@ use std::time::Duration; use nostr::prelude::*; -use crate::RepoAddr; - /// Kinds that make up the activity of a repository. pub const ACTIVITY_KINDS: [Kind; 9] = [ Kind::Comment, @@ -37,7 +35,7 @@ const GIT_ROOT_KINDS: [Kind; 4] = [ Kind::GitRepoAnnouncement, ]; -/// Kinds that carry repository data: announcements, states, activity and deletions. +/// Whether `kind` carries repository data: announcements, states, activity and deletions. pub fn is_repo_kind(kind: Kind) -> bool { kind == Kind::GitRepoAnnouncement || kind == Kind::RepoState @@ -60,170 +58,121 @@ fn tag_kind(event: &Event, name: &str) -> Option { tag_value(event, name)?.parse::().ok() } -/// Latest announcement event for a repository. -pub fn announcement(addr: &RepoAddr) -> Filter { - Filter::new() - .kind(Kind::GitRepoAnnouncement) - .author(addr.public_key) - .identifier(addr.identifier.clone()) -} +/// Namespace for the repository-agnostic filter constructors. +pub struct Filters; -/// Latest state event for a repository, carrying refs and HEAD. -pub fn state(addr: &RepoAddr) -> Filter { - Filter::new() - .kind(Kind::RepoState) - .author(addr.public_key) - .identifier(addr.identifier.clone()) -} +impl Filters { + /// How far back deletion requests are fetched and stored. + const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400); -/// All NIP-34 activity addressed to a repository via its `#a` tag. -/// Covers issues, PRs, patches, statuses and kind-1111 comments. -/// The `a` tag is optional on status events per NIP-34. -/// Statuses published without it are not matched here. -pub fn activity(addr: &RepoAddr) -> Filter { - Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr) -} - -/// Status events, kinds `1630..=1633`, referencing any of the given root events. -/// They are matched via the `#e` tag. One filter covers all roots. -/// -/// A negentropy sync reconciles them in a single session, not one per root. -pub fn statuses_for(roots: impl IntoIterator) -> Filter { - Filter::new() - .kinds([ - Kind::GitStatusOpen, - Kind::GitStatusApplied, - Kind::GitStatusClosed, - Kind::GitStatusDraft, - ]) - .events(roots) -} - -/// A user's grasp list, kind `10317`. -pub fn grasp_list(public_key: PublicKey) -> Filter { - Filter::new() - .kind(Kind::GitUserGraspList) - .author(public_key) -} - -/// NIP-22 comments, kind `1111`, referencing any of the given root events. -/// The roots are issues, patches and PRs. -/// -/// Returns two filters, since combining `#E` and `#e` would AND the conditions. -pub fn comments_for(roots: impl IntoIterator) -> Vec { - let roots: Vec = roots.into_iter().map(|id| id.to_hex()).collect(); - if roots.is_empty() { - return Vec::new(); + /// Status events, kinds `1630..=1633`, referencing any of the given root events. + pub fn statuses_for(roots: impl IntoIterator) -> Filter { + Filter::new() + .kinds([ + Kind::GitStatusOpen, + Kind::GitStatusApplied, + Kind::GitStatusClosed, + Kind::GitStatusDraft, + ]) + .events(roots) } - vec![ + + /// A user's grasp list, kind `10317`. + pub fn grasp_list(public_key: PublicKey) -> Filter { + Filter::new() + .kind(Kind::GitUserGraspList) + .author(public_key) + } + + /// NIP-22 comments, kind `1111`, referencing any of the given root events. + /// The roots are issues, patches and PRs. + /// + /// Returns two filters, since combining `#E` and `#e` would AND the conditions. + pub fn comments_for(roots: impl IntoIterator) -> Vec { + let roots: Vec = roots.into_iter().map(|id| id.to_hex()).collect(); + if roots.is_empty() { + return Vec::new(); + } + vec![ + Filter::new() + .kind(Kind::Comment) + .custom_tags(SingleLetterTag::UPPERCASE_E, roots.clone()), + Filter::new() + .kind(Kind::Comment) + .custom_tags(SingleLetterTag::LOWERCASE_E, roots), + ] + } + + /// NIP-22 comments on our issues, patches and pull requests. + fn notification_comments(me: PublicKey) -> Filter { Filter::new() .kind(Kind::Comment) - .custom_tags(SingleLetterTag::UPPERCASE_E, roots.clone()), + .custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()]) + .custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"]) + } + + /// Activity directed at us: comments on our roots, and git events tagging us + /// via their lowercase `p` tag. `Filter::pubkey` sets that `p` tag. + pub fn notifications(me: PublicKey) -> Vec { + vec![ + Self::notification_comments(me), + Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me), + ] + } + + /// Git activity authored by `me`, for "Continue where you left off". + /// + /// A comment on an unrelated kind matches too, so results must be filtered + /// through `GitEvent::is_git_activity` before display. + pub fn authored_activity(me: PublicKey) -> Filter { + Filter::new().kinds(ACTIVITY_KINDS).author(me) + } + + /// All repository announcements, for global discovery. + pub fn all_announcements() -> Filter { + Filter::new().kind(Kind::GitRepoAnnouncement) + } + + /// All repository state events, carrying each repository's refs and last push time. + pub fn all_states() -> Filter { + Filter::new().kind(Kind::RepoState) + } + + /// `now` minus [`Self::DELETIONS_LOOKBACK`]. + /// Quantized to whole days so identical filters hash the same. + /// + /// This lets the backend's sync dedup match identical filters. + fn deletions_since() -> Timestamp { + let now = Timestamp::now().as_secs(); + Timestamp::from_secs(now - now % 86_400) - Self::DELETIONS_LOOKBACK + } + + /// All deletion-related events within [`Self::DELETIONS_LOOKBACK`]. + /// These are NIP-09 kind `5` and NIP-62 kind `62`. + /// + /// Deletion requests must be known before any other event is shown. + pub fn deletions() -> Filter { Filter::new() - .kind(Kind::Comment) - .custom_tags(SingleLetterTag::LOWERCASE_E, roots), - ] -} - -/// NIP-22 comments on our issues, patches and pull requests. -/// They are matched via the uppercase `P` and `K` tags, not authorship. -fn notification_comments(me: PublicKey) -> Filter { - Filter::new() - .kind(Kind::Comment) - .custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()]) - .custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"]) -} - -/// Activity directed at us: comments on our roots, and git events tagging us -/// via their lowercase `p` tag. `Filter::pubkey` sets that `p` tag. -pub fn notifications(me: PublicKey) -> Vec { - vec![ - notification_comments(me), - Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me), - ] -} - -/// Git activity authored by `me`, for "Continue where you left off". -/// -/// A comment on an unrelated kind is matched too, so results must be filtered -/// through [`is_git_activity`] before display. -pub fn authored_activity(me: PublicKey) -> Filter { - Filter::new().kinds(ACTIVITY_KINDS).author(me) + .kinds([Kind::EventDeletion, Kind::RequestToVanish]) + .since(Self::deletions_since()) + } } /// Whether a kind-1111 comment targets a git root, checked via its `K` tag. -fn is_git_comment(event: &Event) -> bool { +pub(crate) fn is_git_comment(event: &Event) -> bool { event.kind == Kind::Comment && tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind)) } /// Whether a status event references a git root, checked via its `k` tag. -fn is_git_status(event: &Event) -> bool { +pub(crate) fn is_git_status(event: &Event) -> bool { tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind)) } -/// Whether `event` is git activity worth showing in the activity list. -pub fn is_git_activity(event: &Event) -> bool { - match event.kind { - Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true, - Kind::Comment => is_git_comment(event), - Kind::GitStatusOpen - | Kind::GitStatusApplied - | Kind::GitStatusClosed - | Kind::GitStatusDraft => is_git_status(event), - _ => false, - } -} - -/// All repository announcements, for global discovery. -pub fn all_announcements() -> Filter { - Filter::new().kind(Kind::GitRepoAnnouncement) -} - -/// All repository state events, carrying each repository's refs and last push time. -pub fn all_states() -> Filter { - Filter::new().kind(Kind::RepoState) -} - -/// How far back deletion requests are fetched and stored. -const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400); - -/// `now` minus [`DELETIONS_LOOKBACK`]. -/// Quantized to whole days so identical filters hash the same. -/// -/// This lets the backend's sync dedup match identical filters. -fn deletions_since() -> Timestamp { - let now = Timestamp::now().as_secs(); - Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK -} - -/// All deletion-related events within [`DELETIONS_LOOKBACK`]. -/// These are NIP-09 kind `5` and NIP-62 kind `62`. -/// -/// Deletion requests must be known before any other event is shown. -pub fn deletions() -> Filter { - Filter::new() - .kinds([Kind::EventDeletion, Kind::RequestToVanish]) - .since(deletions_since()) -} - -/// Deletion events relevant to a single repository. -/// -/// Requests authored by the repository owner. -/// -/// Requests addressed to the repository coordinate via its `#a` tag. -pub fn deletions_for_repo(addr: &RepoAddr) -> Vec { - vec![ - Filter::new() - .kinds([Kind::EventDeletion, Kind::RequestToVanish]) - .author(addr.public_key), - Filter::new().kind(Kind::EventDeletion).coordinate(addr), - ] -} - #[cfg(test)] mod tests { use super::*; + use crate::GitEvent; fn keys(seed: u8) -> Keys { let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string(); @@ -252,14 +201,10 @@ mod tests { ); let on_note = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::TextNote)]); - assert!(is_git_activity(&on_git)); - assert!(is_git_activity(&on_repo)); - assert!(!is_git_activity(&on_note)); - assert!(!is_git_activity(&signed( - &keys(1), - Kind::Comment, - Vec::new() - ))); + assert!(on_git.is_git_activity()); + assert!(on_repo.is_git_activity()); + assert!(!on_note.is_git_activity()); + assert!(!signed(&keys(1), Kind::Comment, Vec::new()).is_git_activity()); } #[test] @@ -275,12 +220,8 @@ mod tests { vec![kind_tag("k", Kind::Metadata)], ); - assert!(is_git_activity(&status)); - assert!(!is_git_activity(&unrelated)); - assert!(!is_git_activity(&signed( - &keys(1), - Kind::GitStatusClosed, - Vec::new() - ))); + assert!(status.is_git_activity()); + assert!(!unrelated.is_git_activity()); + assert!(!signed(&keys(1), Kind::GitStatusClosed, Vec::new()).is_git_activity()); } } diff --git a/crates/signed_core/src/inbox.rs b/crates/signed_core/src/inbox.rs index 7889bba..6f9cbaa 100644 --- a/crates/signed_core/src/inbox.rs +++ b/crates/signed_core/src/inbox.rs @@ -4,7 +4,7 @@ use std::time::Duration; use nostr::prelude::*; use serde::{Deserialize, Serialize}; -use crate::{RepoAddr, activity_subject}; +use crate::{GitEvent, RepoAddr}; /// Window before `now` that an advanced cutoff retreats to. const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60); @@ -38,7 +38,7 @@ impl InboxItem { .as_ref() .or_else(|| self.own_events.first()) .or_else(|| self.events.first()) - .map(activity_subject) + .map(|event| event.activity_subject()) .unwrap_or_else(|| "Untitled".to_string()) } @@ -112,40 +112,148 @@ impl InboxItem { } } -/// Root issue, patch or pull request of a notification event. -/// -/// Returns `None` when the event is not git-related, or when its root is a -/// coordinate rather than an event. -/// -/// - issue (1621) / PR (1618): itself -/// - patch (1617): its `e` parent patch, else itself -/// - NIP-22 comment (1111): uppercase `E` root pointer -/// - PR update (1619): uppercase `E` -/// - statuses (1630-1633): NIP-10 root `e` -fn notification_root(event: &Event, lookup: &L) -> Option +/// Resolves thread roots by following parent pointers through known events. +pub struct ThreadResolver<'a, L: ?Sized> { + lookup: &'a L, +} + +impl<'a, L> ThreadResolver<'a, L> where - L: Fn(EventId) -> Option, + L: Fn(EventId) -> Option + ?Sized, { - match event.kind { - Kind::GitIssue | Kind::GitPullRequest => Some(event.id), - Kind::GitPatch => Some(match first_e_id(event) { - Some(parent) => resolve_thread_root(parent, lookup), - None => event.id, - }), - Kind::Comment => match nip22::extract_root(event) { - Some(CommentTarget::Event { id, .. }) => Some(resolve_thread_root(id, lookup)), + pub fn new(lookup: &'a L) -> Self { + Self { lookup } + } + + /// Root issue, patch or pull request of a notification event. + /// + /// Returns `None` when the event is not git-related, or when its root is a + /// coordinate rather than an event. + /// + /// - issue (1621) / PR (1618): itself + /// - patch (1617): its `e` parent patch, else itself + /// - NIP-22 comment (1111): uppercase `E` root pointer + /// - PR update (1619): uppercase `E` + /// - statuses (1630-1633): NIP-10 root `e` + pub fn notification_root(&self, event: &Event) -> Option { + match event.kind { + Kind::GitIssue | Kind::GitPullRequest => Some(event.id), + Kind::GitPatch => Some(match self.first_e_id(event) { + Some(parent) => self.resolve_thread_root(parent), + None => event.id, + }), + Kind::Comment => match nip22::extract_root(event) { + Some(CommentTarget::Event { id, .. }) => Some(self.resolve_thread_root(id)), + _ => None, + }, + Kind::GitPullRequestUpdate => self + .first_uppercase_e_id(event) + .map(|root| self.resolve_thread_root(root)), + Kind::GitStatusOpen + | Kind::GitStatusApplied + | Kind::GitStatusClosed + | Kind::GitStatusDraft => self + .nip10_root_id(event) + .map(|root| self.resolve_thread_root(root)), _ => None, - }, - Kind::GitPullRequestUpdate => { - first_uppercase_e_id(event).map(|root| resolve_thread_root(root, lookup)) } - Kind::GitStatusOpen - | Kind::GitStatusApplied - | Kind::GitStatusClosed - | Kind::GitStatusDraft => { - nip10_root_id(event).map(|root| resolve_thread_root(root, lookup)) + } + + /// Follow NIP-10/NIP-22 parent pointers until a root item is reached. + pub fn resolve_thread_root(&self, id: EventId) -> EventId { + let mut seen = HashSet::new(); + let mut root = id; + + loop { + if !seen.insert(root) { + return id; + } + + let Some(event) = (self.lookup)(root) else { + return root; + }; + + if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) { + return root; + } + + match self.parent_id(&event) { + Some(parent) => root = parent, + None => return root, + } } - _ => None, + } + + /// Parent of a thread event, mirroring gitworkshop's `getParentId`. + fn parent_id(&self, event: &Event) -> Option { + for marker in ["reply", "root"] { + if let Some(id) = event + .tags + .iter() + .find_map(|tag| self.e_tag_with_marker(tag, marker)) + { + return Some(id); + } + } + + if let Some(id) = event.tags.iter().find_map(|tag| { + if tag.kind() != "e" { + return None; + } + + let slice = tag.as_slice(); + let is_mention = slice.len() == 4 && slice[3] == "mention"; + + if is_mention { + return None; + } + + tag.content() + .and_then(|content| EventId::from_hex(content).ok()) + }) { + return Some(id); + } + + self.first_uppercase_e_id(event) + } + + /// NIP-10 root of an event: the `e` tag marked `root`, else the first `e` tag. + fn nip10_root_id(&self, event: &Event) -> Option { + event + .tags + .iter() + .find_map(|tag| self.e_tag_with_marker(tag, "root")) + .or_else(|| self.first_e_id(event)) + } + + /// First `e` tag id, in document order. + fn first_e_id(&self, event: &Event) -> Option { + self.first_tag_id(event, "e") + } + + /// First uppercase `E` tag id, in document order. + fn first_uppercase_e_id(&self, event: &Event) -> Option { + self.first_tag_id(event, "E") + } + + fn first_tag_id(&self, event: &Event, name: &str) -> Option { + event.tags.iter().find_map(|tag| { + if tag.kind() != name { + return None; + } + tag.content() + .and_then(|content| EventId::from_hex(content).ok()) + }) + } + + /// Event id from a four-element `e` tag carrying `marker`. + fn e_tag_with_marker(&self, tag: &Tag, marker: &str) -> Option { + let slice = tag.as_slice(); + if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker { + return None; + } + tag.content() + .and_then(|content| EventId::from_hex(content).ok()) } } @@ -162,12 +270,13 @@ where O: IntoIterator, L: Fn(EventId) -> Option, { + let resolver = ThreadResolver::new(lookup); let mut groups: HashMap> = HashMap::new(); for event in events { if event.pubkey == me { continue; } - let Some(root) = notification_root(&event, lookup) else { + let Some(root) = resolver.notification_root(&event) else { continue; }; groups.entry(root).or_default().push(event); @@ -175,7 +284,7 @@ where let mut own_groups: HashMap> = HashMap::new(); for event in own { - let root = notification_root(&event, lookup).unwrap_or(event.id); + let root = resolver.notification_root(&event).unwrap_or(event.id); own_groups.entry(root).or_default().push(event); } @@ -197,7 +306,8 @@ where root, address: root_event .as_ref() - .and_then(|event| event.tags.coordinates().next()), + .and_then(|event| event.tags.coordinates().next()) + .map(RepoAddr::from), root_event, events, own_events, @@ -321,103 +431,6 @@ fn prune_ids(ids: &mut HashSet, all: &[Event], cutoff: Timestamp) { ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff)); } -/// Follow NIP-10/NIP-22 parent pointers until a root item is reached. -fn resolve_thread_root(id: EventId, lookup: &impl Fn(EventId) -> Option) -> EventId { - let mut seen = HashSet::new(); - let mut root = id; - - loop { - if !seen.insert(root) { - return id; - } - - let Some(event) = lookup(root) else { - return root; - }; - - if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) { - return root; - } - - match parent_id(&event) { - Some(parent) => root = parent, - None => return root, - } - } -} - -/// Parent of a thread event, mirroring gitworkshop's `getParentId`. -fn parent_id(event: &Event) -> Option { - for marker in ["reply", "root"] { - if let Some(id) = event - .tags - .iter() - .find_map(|tag| e_tag_with_marker(tag, marker)) - { - return Some(id); - } - } - - if let Some(id) = event.tags.iter().find_map(|tag| { - if tag.kind() != "e" { - return None; - } - - let slice = tag.as_slice(); - let is_mention = slice.len() == 4 && slice[3] == "mention"; - - if is_mention { - return None; - } - - tag.content() - .and_then(|content| EventId::from_hex(content).ok()) - }) { - return Some(id); - } - - first_uppercase_e_id(event) -} - -/// NIP-10 root of an event: the `e` tag marked `root`, else the first `e` tag. -fn nip10_root_id(event: &Event) -> Option { - event - .tags - .iter() - .find_map(|tag| e_tag_with_marker(tag, "root")) - .or_else(|| first_e_id(event)) -} - -/// First `e` tag id, in document order. -fn first_e_id(event: &Event) -> Option { - first_tag_id(event, "e") -} - -/// First uppercase `E` tag id, in document order. -fn first_uppercase_e_id(event: &Event) -> Option { - first_tag_id(event, "E") -} - -fn first_tag_id(event: &Event, name: &str) -> Option { - event.tags.iter().find_map(|tag| { - if tag.kind() != name { - return None; - } - tag.content() - .and_then(|content| EventId::from_hex(content).ok()) - }) -} - -/// Event id from a four-element `e` tag carrying `marker`. -fn e_tag_with_marker(tag: &Tag, marker: &str) -> Option { - let slice = tag.as_slice(); - if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker { - return None; - } - tag.content() - .and_then(|content| EventId::from_hex(content).ok()) -} - #[cfg(test)] mod tests { use super::*; @@ -480,7 +493,7 @@ mod tests { ); let events = [issue.clone(), comment.clone()]; assert_eq!( - notification_root(&comment, &lookup(&events)), + ThreadResolver::new(&lookup(&events)).notification_root(&comment), Some(issue.id) ); } @@ -491,7 +504,7 @@ mod tests { let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200); let events = [root_patch.clone(), child_patch.clone()]; assert_eq!( - notification_root(&child_patch, &lookup(&events)), + ThreadResolver::new(&lookup(&events)).notification_root(&child_patch), Some(root_patch.id) ); } @@ -506,7 +519,10 @@ mod tests { 200, ); let events = [issue.clone(), status.clone()]; - assert_eq!(notification_root(&status, &lookup(&events)), Some(issue.id)); + assert_eq!( + ThreadResolver::new(&lookup(&events)).notification_root(&status), + Some(issue.id) + ); } #[test] @@ -515,7 +531,10 @@ mod tests { let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200); let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300); let events = [issue.clone(), reply, nested.clone()]; - assert_eq!(notification_root(&nested, &lookup(&events)), Some(issue.id)); + assert_eq!( + ThreadResolver::new(&lookup(&events)).notification_root(&nested), + Some(issue.id) + ); } #[test] diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index 645d04c..328e3fc 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -6,12 +6,10 @@ pub mod model; pub mod state; pub mod status; -pub use addr::{RepoAddr, identifier_from_name, repo_addr}; +pub use addr::RepoAddr; pub use deletions::Deletions; -pub use inbox::{InboxItem, InboxReadState}; -pub use model::{ - Announcement, activity_subject, branch_name_of, clone_urls_of, current_commit_of, - fork_candidates, latest_update, merge_base_of, pull_request_patch, pull_request_patches, -}; -pub use state::{build_state, parse_state}; -pub use status::{RepoStatus, references_root, resolve_status}; +pub use filters::Filters; +pub use inbox::{InboxItem, InboxReadState, ThreadResolver}; +pub use model::{Announcement, GitEvent, PullRequest}; +pub use state::RepoState; +pub use status::RepoStatus; diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 365864f..479e6a2 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use nostr::prelude::*; -use crate::{RepoAddr, repo_addr}; +use crate::RepoAddr; /// Parsed NIP-34 repository announcement, plain data ready for the UI. #[derive(Debug, Clone, PartialEq, Eq)] @@ -49,7 +49,8 @@ impl Upstream { let addr = coordinate .parse::() .ok() - .filter(|c| c.kind == Kind::GitRepoAnnouncement); + .filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement) + .map(RepoAddr::from); Self { raw: raw.to_owned(), addr, @@ -64,216 +65,288 @@ impl Upstream { } } -/// Subject of a NIP-34 issue or pull request event. -/// Taken from the `subject` tag, else the first non-empty line of the content. -pub fn activity_subject(event: &Event) -> String { - let subject = event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Subject(subject)) => Some(subject), - _ => None, - }); +/// Tag accessors for NIP-34 git collaboration events. +pub trait GitEvent { + /// Subject of an issue or pull request event. + fn activity_subject(&self) -> String; - subject - .or_else(|| { - event - .content - .lines() - .map(str::trim) - .find(|line| !line.is_empty()) - .map(|value| value.to_string()) - }) - .unwrap_or("Untitled".to_string()) + /// The `c` tag, the tip of the proposed branch, as hex. + fn current_commit(&self) -> Option; + + /// The `merge-base` tag, the base commit a pull request diffs against. + fn merge_base(&self) -> Option; + + /// The `clone` tag, URLs the tip commit can be fetched from. + fn clone_urls(&self) -> Option>; + + /// The `branch-name` tag, the proposed branch's name. + fn branch_name(&self) -> Option; + + /// Whether the event is git activity worth showing in the activity list. + fn is_git_activity(&self) -> bool; + + /// Whether the event carries an `e`/`E` tag pointing at `root`. + fn references_root(&self, root: &EventId) -> bool; } -/// The patch set of a pull request. -/// -/// Returns an empty list when no patch event can be linked to the PR. -pub fn pull_request_patches<'a>( - pr: &Event, - patches: impl IntoIterator, -) -> Vec<&'a Event> { - let patches: Vec<&'a Event> = patches.into_iter().collect(); +impl GitEvent for Event { + fn activity_subject(&self) -> String { + let subject = self + .tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Subject(subject)) => Some(subject), + _ => None, + }); - // The PR references its root patch via an `e` tag. - // Follow the NIP-10 reply chain forward from there. - // Each patch replies to the previous one, and among several replies the newest wins. - if let Some(root_id) = pr.tags.event_ids().next() - && let Some(root) = patches.iter().find(|patch| patch.id == root_id) - { - return forward_series(root, &patches); + subject + .or_else(|| { + self.content + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(|value| value.to_string()) + }) + .unwrap_or("Untitled".to_string()) } - // The PR has no `e` tag. - // The last patch of the set carries the tip commit in its `commit` or `r` tag. - // Walk the reply chain backward to the root. - let Some(tip) = current_commit_of(pr) else { - return Vec::new(); - }; - let Some(last) = patches - .iter() - .filter(|patch| patch_produces_commit(patch, &tip)) - .max_by_key(|patch| patch.created_at) - .copied() - else { - return Vec::new(); - }; - - let mut series = vec![last]; - loop { - let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else { - break; - }; - let Some(prev) = patches + fn current_commit(&self) -> Option { + self.tags .iter() - .find(|patch| patch.id == prev_id && !series.contains(patch)) + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()), + _ => None, + }) + } + + fn merge_base(&self) -> Option { + self.tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()), + _ => None, + }) + } + + fn clone_urls(&self) -> Option> { + self.tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Clone(urls)) => Some(urls), + _ => None, + }) + } + + fn branch_name(&self) -> Option { + self.tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::BranchName(name)) => Some(name), + _ => None, + }) + } + + fn is_git_activity(&self) -> bool { + match self.kind { + Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true, + Kind::Comment => crate::filters::is_git_comment(self), + Kind::GitStatusOpen + | Kind::GitStatusApplied + | Kind::GitStatusClosed + | Kind::GitStatusDraft => crate::filters::is_git_status(self), + _ => false, + } + } + + fn references_root(&self, root: &EventId) -> bool { + let root = root.to_hex(); + self.tags + .iter() + .any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str())) + } +} + +impl GitEvent for &T { + fn activity_subject(&self) -> String { + (*self).activity_subject() + } + + fn current_commit(&self) -> Option { + (*self).current_commit() + } + + fn merge_base(&self) -> Option { + (*self).merge_base() + } + + fn clone_urls(&self) -> Option> { + (*self).clone_urls() + } + + fn branch_name(&self) -> Option { + (*self).branch_name() + } + + fn is_git_activity(&self) -> bool { + (*self).is_git_activity() + } + + fn references_root(&self, root: &EventId) -> bool { + (*self).references_root(root) + } +} + +/// A NIP-34 pull request root event, with its patch set and update history. +pub struct PullRequest<'a>(pub &'a Event); + +impl<'a> PullRequest<'a> { + pub fn new(event: &'a Event) -> Self { + Self(event) + } + + /// The patch set of the pull request. + /// + /// Returns an empty list when no patch event can be linked to the PR. + pub fn patches(&self, patches: impl IntoIterator) -> Vec<&'a Event> { + let pr = self.0; + let patches: Vec<&'a Event> = patches.into_iter().collect(); + + // The PR references its root patch via an `e` tag. + if let Some(root_id) = pr.tags.event_ids().next() + && let Some(root) = patches.iter().find(|patch| patch.id == root_id) + { + return Self::forward_series(root, &patches); + } + + // The PR has no `e` tag. + // The last patch of the set carries the tip commit in its `commit` or `r` tag. + let Some(tip) = pr.current_commit() else { + return Vec::new(); + }; + let Some(last) = patches + .iter() + .filter(|patch| Self::patch_produces_commit(patch, &tip)) + .max_by_key(|patch| patch.created_at) .copied() else { - break; + return Vec::new(); }; - series.push(prev); - } - series.reverse(); - series -} -pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator) -> String { - let patches: Vec<&'a Event> = patches.into_iter().collect(); - let series = pull_request_patches(pr, patches.iter().copied()); - if series.is_empty() { - return pr.content.clone(); - } - series - .iter() - .map(|patch| patch.content.as_str()) - .collect::>() - .join("\n") -} - -/// The chain of patches replying to `root` via NIP-10 `e` tags, oldest first. -fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> { - let mut series = vec![root]; - loop { - let next = patches - .iter() - .filter(|patch| !series.contains(patch)) - .filter(|patch| { - patch - .tags - .event_ids() - .any(|id| id == series.last().unwrap().id) - }) - .max_by_key(|patch| patch.created_at); - let Some(next) = next else { - break; - }; - series.push(next); - } - series -} - -/// The `c` tag of an event, the tip of the proposed branch, as hex. -pub fn current_commit_of(event: &Event) -> Option { - event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()), - _ => None, - }) -} - -/// The `merge-base` tag of an event, the base commit a pull request diffs against. -pub fn merge_base_of(event: &Event) -> Option { - event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()), - _ => None, - }) -} - -/// The `clone` tag of an event, URLs the tip commit can be fetched from. -pub fn clone_urls_of(event: &Event) -> Option> { - event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Clone(urls)) => Some(urls), - _ => None, - }) -} - -/// The `branch-name` tag of an event, the proposed branch's name. -pub fn branch_name_of(event: &Event) -> Option { - event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::BranchName(name)) => Some(name), - _ => None, - }) -} - -/// The newest `GitPullRequestUpdate` revising `root`, from the root's own author. -/// -/// A pull request's tip is only mutable by its author, per NIP-34; updates -/// from anyone else are ignored even if they are newer. -pub fn latest_update<'a>( - events: impl Iterator, - root: &Event, -) -> Option<&'a Event> { - let root_hex = root.id.to_hex(); - events - .filter(|e| e.kind == Kind::GitPullRequestUpdate) - .filter(|e| e.pubkey == root.pubkey) - .filter(|e| { - e.tags + let mut series = vec![last]; + loop { + let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else { + break; + }; + let Some(prev) = patches .iter() - .any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str())) - }) - .max_by_key(|e| e.created_at) -} - -/// The announced forks of `base` a new pull request compare can be built from. -/// -/// The user's own forks are listed first. -pub fn fork_candidates<'a>( - announcements: &'a [Announcement], - base: &RepoAddr, - base_euc: Option<&str>, - user: Option, -) -> Vec<&'a Announcement> { - let (mut own, mut others) = (Vec::new(), Vec::new()); - for announcement in announcements { - if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) { - continue; - } - if Some(announcement.owner) == user { - own.push(announcement); - } else { - others.push(announcement); + .find(|patch| patch.id == prev_id && !series.contains(patch)) + .copied() + else { + break; + }; + series.push(prev); } + series.reverse(); + series } - own.into_iter().chain(others).collect() -} -/// Whether `patch` produces `commit`, found via its `commit` or `r` tag. -/// -/// It lets clients find existing patches for a specific commit. -fn patch_produces_commit(patch: &Event, commit: &str) -> bool { - patch - .tags - .iter() - .any(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit, - _ => false, - }) + /// The patch set of the pull request joined into one diff text. + /// + /// Falls back to the root event's content when no patch set is found. + pub fn patch(&self, patches: impl IntoIterator) -> String { + let pr = self.0; + let patches: Vec<&'a Event> = patches.into_iter().collect(); + let series = self.patches(patches.iter().copied()); + if series.is_empty() { + return pr.content.clone(); + } + series + .iter() + .map(|patch| patch.content.as_str()) + .collect::>() + .join("\n") + } + + /// The newest `GitPullRequestUpdate` revising `root`, from the root's own author. + /// + /// A pull request's tip is only mutable by its author per NIP-34, updates + /// from anyone else are ignored even if they are newer. + pub fn latest_update( + events: impl Iterator, + root: &Event, + ) -> Option<&'a Event> { + let root_hex = root.id.to_hex(); + events + .filter(|e| e.kind == Kind::GitPullRequestUpdate) + .filter(|e| e.pubkey == root.pubkey) + .filter(|e| { + e.tags + .iter() + .any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str())) + }) + .max_by_key(|e| e.created_at) + } + + /// The chain of patches replying to `root` via NIP-10 `e` tags, oldest first. + fn forward_series(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> { + let mut series = vec![root]; + loop { + let next = patches + .iter() + .filter(|patch| !series.contains(patch)) + .filter(|patch| { + patch + .tags + .event_ids() + .any(|id| id == series.last().unwrap().id) + }) + .max_by_key(|patch| patch.created_at); + let Some(next) = next else { + break; + }; + series.push(next); + } + series + } + + /// Whether `patch` produces `commit`, found via its `commit` or `r` tag. + /// + /// It lets clients find existing patches for a specific commit. + fn patch_produces_commit(patch: &Event, commit: &str) -> bool { + patch + .tags + .iter() + .any(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit, + _ => false, + }) + } } impl Announcement { + /// The announced forks of `base` a new pull request compare can be built from. + /// + /// The user's own forks are listed first. + pub fn forks_in<'a>( + announcements: &'a [Announcement], + base: &RepoAddr, + base_euc: Option<&str>, + user: Option, + ) -> Vec<&'a Announcement> { + let (mut own, mut others) = (Vec::new(), Vec::new()); + for announcement in announcements { + if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) { + continue; + } + if Some(announcement.owner) == user { + own.push(announcement); + } else { + others.push(announcement); + } + } + own.into_iter().chain(others).collect() + } + /// Parse a kind `30617` event. /// /// Returns `None` when the kind is wrong or the `d` tag is missing. @@ -337,7 +410,7 @@ impl Announcement { } pub fn addr(&self) -> RepoAddr { - repo_addr(self.owner, self.id.clone()) + RepoAddr::new(self.owner, self.id.clone()) } /// The name of the repository, or a default if none is provided. @@ -499,7 +572,7 @@ mod tests { // The raw value keeps the `|git-url` suffix. assert_eq!( upstream.addr, - Some(crate::repo_addr( + Some(RepoAddr::new( PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"), "upstream" )) @@ -517,7 +590,7 @@ mod tests { #[test] fn is_fork_of_matches_the_u_tag_coordinate() { // The base repository, announced by the `u` tag's owner. - let base = crate::repo_addr( + let base = RepoAddr::new( PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"), "upstream", ); @@ -559,7 +632,7 @@ mod tests { fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() { // A permanent fork re-announces its EUC, the first commit after the fork. // Only the `u` tag still relates it to the base. - let base = crate::repo_addr( + let base = RepoAddr::new( PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"), "upstream", ); @@ -632,11 +705,11 @@ mod tests { let pr = pr_event("description", vec![Tag::event(root.id)]); assert_eq!( - pull_request_patch(&pr, [&root, &second]), + PullRequest::new(&pr).patch([&root, &second]), "patch-one\npatch-two" ); assert_eq!( - pull_request_patches(&pr, [&root, &second]), + PullRequest::new(&pr).patches([&root, &second]), vec![&root, &second] ); } @@ -648,7 +721,7 @@ mod tests { let third = patch_event("patch-three", vec![Tag::event(second.id)], 300); let pr = pr_event("description", vec![Tag::event(root.id)]); - let series = pull_request_patches(&pr, [&third, &root, &second]); + let series = PullRequest::new(&pr).patches([&third, &root, &second]); assert_eq!( series .iter() @@ -677,7 +750,7 @@ mod tests { vec![Tag::parse(["c", tip]).expect("valid tag")], ); - let series = pull_request_patches(&pr, [&root, &last]); + let series = PullRequest::new(&pr).patches([&root, &last]); assert_eq!( series .iter() @@ -729,7 +802,7 @@ mod tests { ); let events = [unrelated, revision(200), root.clone(), revision(300)]; - let latest = latest_update(events.iter(), &root).expect("an update"); + let latest = PullRequest::latest_update(events.iter(), &root).expect("an update"); assert_eq!(latest.created_at.as_secs(), 300); assert_eq!(latest.kind, Kind::GitPullRequestUpdate); @@ -751,7 +824,7 @@ mod tests { // The tip of a PR is only mutable by its author. // A newer update from anyone else must not win. - assert!(latest_update([&stranger, &root].into_iter(), &root).is_none()); + assert!(PullRequest::latest_update([&stranger, &root].into_iter(), &root).is_none()); } const OWNER_KEYS: [&str; 3] = [ @@ -784,7 +857,7 @@ mod tests { let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; let clone = "https://grasp.example/npub1x/my-fork.git"; - let base_addr = crate::repo_addr( + let base_addr = RepoAddr::new( PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"), "upstream", ); @@ -819,7 +892,7 @@ mod tests { ]; let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey"); - let forks = fork_candidates(&all, &base_addr, Some(euc), Some(user)); + let forks = Announcement::forks_in(&all, &base_addr, Some(euc), Some(user)); let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect(); assert_eq!(ids, vec!["my-fork", "their-fork"]); @@ -829,7 +902,7 @@ mod tests { fn fork_candidates_excludes_base_unrelated_and_unfetchable() { let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; let base_owner = PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"); - let base_addr = crate::repo_addr(base_owner, "upstream"); + let base_addr = RepoAddr::new(base_owner, "upstream"); let mut all = vec![ owned_announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]]) @@ -859,7 +932,7 @@ mod tests { .unwrap(), ]; - let forks = fork_candidates(&all, &base_addr, Some(euc), Some(base_owner)); + let forks = Announcement::forks_in(&all, &base_addr, Some(euc), Some(base_owner)); assert_eq!(forks.len(), 1); assert_eq!(forks[0].id, "mirror"); @@ -876,7 +949,7 @@ mod tests { .pop() .unwrap(), ); - let forks = fork_candidates(&all, &base_addr, None, Some(base_owner)); + let forks = Announcement::forks_in(&all, &base_addr, None, Some(base_owner)); let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect(); assert_eq!(ids, vec!["u-fork"]); } diff --git a/crates/signed_core/src/state.rs b/crates/signed_core/src/state.rs index 1e36932..fcf488f 100644 --- a/crates/signed_core/src/state.rs +++ b/crates/signed_core/src/state.rs @@ -1,44 +1,52 @@ use nostr::prelude::*; -/// Build a kind `30618` repository state event from refs and HEAD, -/// it is published as `ref: refs/heads/`. -/// -/// The `d` tag matches the repository id. -pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder { - let mut tags: Vec = vec![Tag::identifier(id.to_owned())]; - for (name, commit) in refs { - tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag")); - } - if let Some(head) = head { - tags.push( - Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"), - ); - } - EventBuilder::new(Kind::RepoState, "").tags(tags) +/// Refs and HEAD parsed from a kind `30618` repository state event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepoState { + /// `(refname, commit-id)` pairs. + pub refs: Vec<(String, String)>, + /// Branch pointed to by the `HEAD` tag, if any. + pub head: Option, } -/// Parse a kind `30618` repository state event into refs and HEAD. -/// -/// `refs` are `(refname, commit-id)` pairs. -/// `head` is the branch pointed to by the `HEAD` tag, if any. -pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option) { - let mut refs = Vec::new(); - let mut head = None; - - for tag in event.tags.iter() { - match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Head(branch)) => head = Some(branch), - Ok(Nip34Tag::RefHead { branch, commit }) => { - refs.push((format!("refs/heads/{branch}"), commit.to_string())); - } - Ok(Nip34Tag::RefTag { name, commit }) => { - refs.push((format!("refs/tags/{name}"), commit.to_string())); - } - _ => {} +impl RepoState { + /// Build a kind `30618` repository state event from refs and HEAD, + /// it is published as `ref: refs/heads/`. + /// + /// The `d` tag matches the repository id. + pub fn build(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder { + let mut tags: Vec = vec![Tag::identifier(id.to_owned())]; + for (name, commit) in refs { + tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag")); } + if let Some(head) = head { + tags.push( + Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"), + ); + } + EventBuilder::new(Kind::RepoState, "").tags(tags) } - (refs, head) + /// Parse a kind `30618` repository state event into refs and HEAD. + pub fn parse(event: &Event) -> Self { + let mut refs = Vec::new(); + let mut head = None; + + for tag in event.tags.iter() { + match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Head(branch)) => head = Some(branch), + Ok(Nip34Tag::RefHead { branch, commit }) => { + refs.push((format!("refs/heads/{branch}"), commit.to_string())); + } + Ok(Nip34Tag::RefTag { name, commit }) => { + refs.push((format!("refs/tags/{name}"), commit.to_string())); + } + _ => {} + } + } + + Self { refs, head } + } } #[cfg(test)] @@ -76,11 +84,11 @@ mod tests { &["refs/tags/v1.0", COMMIT_A], ]); - let (refs, head) = parse_state(&event); + let state = RepoState::parse(&event); - assert_eq!(head.as_deref(), Some("main")); + assert_eq!(state.head.as_deref(), Some("main")); assert_eq!( - refs, + state.refs, vec![ ("refs/heads/main".to_owned(), COMMIT_A.to_owned()), ("refs/heads/dev".to_owned(), COMMIT_B.to_owned()), @@ -90,22 +98,22 @@ mod tests { } #[test] - fn build_state_round_trips_through_parse() { + fn build_round_trips_through_parse() { let refs = [ ("refs/heads/main".to_owned(), COMMIT_A.to_owned()), ("refs/heads/dev".to_owned(), COMMIT_B.to_owned()), ("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()), ]; - let event = build_state("my-repo", &refs, Some("main")) + let event = RepoState::build("my-repo", &refs, Some("main")) .finalize(&keys()) .expect("signed event"); assert_eq!(event.kind, Kind::RepoState); assert_eq!(event.tags.identifier().as_deref(), Some("my-repo")); - let (parsed_refs, head) = parse_state(&event); - assert_eq!(parsed_refs, refs); - assert_eq!(head.as_deref(), Some("main")); + let state = RepoState::parse(&event); + assert_eq!(state.refs, refs); + assert_eq!(state.head.as_deref(), Some("main")); } } diff --git a/crates/signed_core/src/status.rs b/crates/signed_core/src/status.rs index 4ce9e57..1537465 100644 --- a/crates/signed_core/src/status.rs +++ b/crates/signed_core/src/status.rs @@ -28,6 +28,26 @@ impl RepoStatus { Self::Draft => Kind::GitStatusDraft, } } + + /// Resolve the status of a root event per NIP-34. + /// + /// Defaults to [`RepoStatus::Open`]. + pub fn resolve<'a, I>( + status_events: I, + root_author: &PublicKey, + maintainers: &[PublicKey], + ) -> Self + where + I: IntoIterator, + { + status_events + .into_iter() + .filter(|e| RepoStatus::from_kind(e.kind).is_some()) + .filter(|e| &e.pubkey == root_author || maintainers.contains(&e.pubkey)) + .max_by_key(|e| e.created_at) + .and_then(|e| RepoStatus::from_kind(e.kind)) + .unwrap_or(RepoStatus::Open) + } } /// NIP-10 and NIP-34 use the lowercase `e` tag. @@ -41,26 +61,6 @@ pub fn references_root(event: &Event, root: &EventId) -> bool { .any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str())) } -/// Resolve the status of a root event per NIP-34. -/// -/// Defaults to [`RepoStatus::Open`]. -pub fn resolve_status<'a, I>( - status_events: I, - root_author: &PublicKey, - maintainers: &[PublicKey], -) -> RepoStatus -where - I: IntoIterator, -{ - status_events - .into_iter() - .filter(|e| RepoStatus::from_kind(e.kind).is_some()) - .filter(|e| &e.pubkey == root_author || maintainers.contains(&e.pubkey)) - .max_by_key(|e| e.created_at) - .and_then(|e| RepoStatus::from_kind(e.kind)) - .unwrap_or(RepoStatus::Open) -} - #[cfg(test)] mod tests { use super::*; @@ -115,7 +115,7 @@ mod tests { ]; assert_eq!( - resolve_status( + RepoStatus::resolve( statuses.iter(), &owner.public_key(), &[maintainer.public_key()] @@ -140,7 +140,7 @@ mod tests { ]; assert_eq!( - resolve_status( + RepoStatus::resolve( statuses.iter(), &owner.public_key(), &[maintainer.public_key()] diff --git a/crates/signed_git/src/cache.rs b/crates/signed_git/src/cache.rs index 2ae2e78..fdfa932 100644 --- a/crates/signed_git/src/cache.rs +++ b/crates/signed_git/src/cache.rs @@ -22,8 +22,8 @@ impl GitCache { pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf { self.root - .join(addr.public_key.to_hex()) - .join(sanitize_path_component(&addr.identifier)) + .join(addr.public_key().to_hex()) + .join(sanitize_path_component(addr.identifier())) } pub fn open(&self, addr: &RepoAddr) -> Result> { diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 589fbc5..2929ac3 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -11,7 +11,7 @@ use nostr::nips::nip19::Nip19Coordinate; use nostr_connect::prelude::*; use nostr_sdk::client::SyncSummary; use nostr_sdk::prelude::*; -use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_name, repo_addr}; +use signed_core::{Announcement, Filters, RepoAddr, RepoState, filters}; use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; use crate::git_store::repo_mirror_path; @@ -388,7 +388,7 @@ impl Backend { return Task::ready(Err(anyhow!("Sign in to create a repository"))); }; - let repo_id = identifier_from_name(&name); + let repo_id = RepoAddr::identifier_from_name(&name); if repo_id.is_empty() || repo_id.len() > 100 { return Task::ready(Err(anyhow!( @@ -563,7 +563,7 @@ impl Backend { return Task::ready(Err(anyhow!("Sign in to publish a repository"))); }; - let repo_id = identifier_from_name(&name); + let repo_id = RepoAddr::identifier_from_name(&name); if repo_id.is_empty() || repo_id.len() > 100 { return Task::ready(Err(anyhow!( @@ -694,8 +694,8 @@ impl Backend { // Record the ngit-compatible `nostr.repo` marker, // so the next scan detects the repository instead of offering to publish it again. - let coordinate = repo_addr(event.pubkey, repo_id.clone()); - match Nip19Coordinate::new(coordinate, servers.clone()).to_bech32() { + let coordinate = RepoAddr::new(event.pubkey, repo_id.clone()); + match Nip19Coordinate::new(coordinate.into(), servers.clone()).to_bech32() { Ok(naddr) => { let path = path.clone(); cx.background_spawn(async move { @@ -876,7 +876,7 @@ impl Backend { let Some(public_key) = self.current_user else { return Task::ready(Err(anyhow!("Sign in to delete a repository"))); }; - if public_key != addr.public_key { + if public_key != addr.public_key() { return Task::ready(Err(anyhow!("Only the repository owner can delete it"))); } @@ -888,9 +888,9 @@ impl Backend { let db = client.database(); let mut events = Vec::new(); for filter in [ - filters::announcement(&addr), - filters::state(&addr), - filters::activity(&addr), + addr.announcement_filter(), + addr.state_filter(), + addr.activity_filter(), ] { events.extend(db.query(filter).await?); } @@ -914,7 +914,7 @@ impl Backend { let result = async { sync_bootstrap_only( &client, - filters::grasp_list(public_key), + Filters::grasp_list(public_key), SyncOptions::default(), ) .await?; @@ -961,8 +961,8 @@ impl Backend { let me = self.current_user; if let Some(me) = me { - self.subscribe_bootstrap(filters::notifications(me), cx); - self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx); + self.subscribe_bootstrap(Filters::notifications(me), cx); + self.subscribe_bootstrap(vec![Filters::authored_activity(me)], cx); let relays: HashSet = RepoListStore::global(cx) .read(cx) @@ -973,8 +973,8 @@ impl Backend { if !relays.is_empty() { let relays: Vec = relays.into_iter().collect(); - self.connect_repo_relays(relays.clone(), filters::notifications(me), cx); - self.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx); + self.connect_repo_relays(relays.clone(), Filters::notifications(me), cx); + self.connect_repo_relays(relays, vec![Filters::authored_activity(me)], cx); } } @@ -1357,7 +1357,7 @@ pub async fn user_grasp_list_servers( ) -> Result, Error> { let events: Vec = client .database() - .query(filters::grasp_list(user)) + .query(Filters::grasp_list(user)) .await? .into_iter() .collect(); @@ -1517,7 +1517,7 @@ async fn sign_state_event( last_created_at + 1 }; - let event = build_state(repo_id, refs, head) + let event = RepoState::build(repo_id, refs, head) .custom_created_at(Timestamp::from_secs(created_at)) .finalize_async(signer) .await diff --git a/crates/signed_state/src/inbox.rs b/crates/signed_state/src/inbox.rs index 52839cf..fa557be 100644 --- a/crates/signed_state/src/inbox.rs +++ b/crates/signed_state/src/inbox.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use anyhow::Error; use gpui::{AppContext, Context, Task}; use nostr_sdk::prelude::*; -use signed_core::{Deletions, InboxItem, InboxReadState, filters, inbox}; +use signed_core::{Deletions, Filters, InboxItem, InboxReadState, GitEvent, inbox}; use crate::backend::Backend; @@ -97,7 +97,7 @@ pub async fn query_inbox( me: PublicKey, state: &InboxReadState, ) -> Result<(Vec, usize), Error> { - let deletion_events = client.database().query(filters::deletions()).await?; + let deletion_events = client.database().query(Filters::deletions()).await?; let deletions = Deletions::from_events(deletion_events); let (notification_events, mut by_id) = fetch_notifications(client, me, &deletions).await?; @@ -105,10 +105,10 @@ pub async fn query_inbox( let mut activity = Vec::new(); for event in client .database() - .query(filters::authored_activity(me)) + .query(Filters::authored_activity(me)) .await? { - if deletions.is_deleted(&event) || !filters::is_git_activity(&event) { + if deletions.is_deleted(&event) || !event.is_git_activity() { continue; } by_id.entry(event.id).or_insert_with(|| event.clone()); @@ -169,7 +169,7 @@ async fn fetch_notifications( let mut notifications: Vec = Vec::new(); let mut by_id: HashMap = HashMap::new(); - for filter in filters::notifications(me) { + for filter in Filters::notifications(me) { for event in client.database().query(filter).await? { if deletions.is_deleted(&event) { continue; diff --git a/crates/signed_state/src/local_repos.rs b/crates/signed_state/src/local_repos.rs index 19bfbdd..c1ad4fb 100644 --- a/crates/signed_state/src/local_repos.rs +++ b/crates/signed_state/src/local_repos.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use anyhow::Error; use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task}; -use signed_core::{Announcement, RepoAddr, repo_addr}; +use signed_core::{Announcement, RepoAddr}; use signed_git::{LocalRepo, Nip34Binding, find_git_repos}; struct GlobalLocalReposStore(Entity); @@ -113,7 +113,7 @@ pub fn local_repo_addr(repo: &LocalRepo) -> Option { let owner = binding.owner?; let identifier = binding.identifier.as_deref()?; - Some(repo_addr(owner, identifier)) + Some(RepoAddr::new(owner, identifier)) } /// A scanned repository resolved against the known announcements. diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index bd2a52e..b1e09bf 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -9,7 +9,8 @@ use nostr::event::IntoEventBuilder; use nostr_sdk::prelude::*; use settings::{EventFetchingStrategy, SettingsStore}; use signed_core::{ - Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patches, + Announcement, Deletions, Filters, GitEvent, PullRequest, RepoAddr, RepoState, RepoStatus, + filters, }; use signed_git::Nip34Binding; use signed_nostr::UniversalSigner; @@ -210,8 +211,8 @@ impl RepoStore { let deletion = update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish; - let coordinate = update.coordinate.as_ref() == Some(addr); - let author = update.author == addr.public_key; + let coordinate = update.coordinate.as_ref() == Some(addr.coordinate()); + let author = update.author == addr.public_key(); let authored = (update.kind == Kind::GitRepoAnnouncement || update.kind == Kind::RepoState) @@ -251,12 +252,12 @@ impl RepoStore { let mut filters = vec![ Filter::new() .kinds([Kind::GitRepoAnnouncement, Kind::RepoState]) - .author(addr.public_key) - .identifier(addr.identifier.clone()), - filters::activity(addr), + .author(addr.public_key()) + .identifier(addr.identifier()), + addr.activity_filter(), ]; // Deletion requests, NIP-09/62, must be known before any event is shown. - filters.extend(filters::deletions_for_repo(addr)); + filters.extend(addr.deletion_filters()); filters } @@ -290,8 +291,8 @@ impl RepoStore { let mut pubkeys = maintainers.to_vec(); // NIP-34 events tag the announcement author, // which may not be a maintainer for subordinate forks. - if !pubkeys.contains(&addr.public_key) { - pubkeys.push(addr.public_key); + if !pubkeys.contains(&addr.public_key()) { + pubkeys.push(addr.public_key()); } vec![ @@ -299,16 +300,16 @@ impl RepoStore { Filter::new() .kinds([Kind::GitRepoAnnouncement, Kind::RepoState]) .authors(pubkeys.clone()) - .identifier(addr.identifier.clone()), + .identifier(addr.identifier()), // Activity tagging a maintainer, resolved to their read relays. Filter::new() .kinds(filters::ACTIVITY_KINDS) - .coordinate(addr) + .coordinate(addr.coordinate()) .pubkeys(pubkeys.clone()), // Activity authored by a maintainer, resolved to their write relays. Filter::new() .kinds(filters::ACTIVITY_KINDS) - .coordinate(addr) + .coordinate(addr.coordinate()) .authors(pubkeys.clone()), // Deletions authored by a maintainer. Filter::new() @@ -377,10 +378,10 @@ impl RepoStore { let work = cx.background_spawn(async move { let (announcements, states, activity, deletion_events) = async { let db = client.database(); - let announcements = db.query(filters::announcement(&addr)).await?; - let states = db.query(filters::state(&addr)).await?; - let activity = db.query(filters::activity(&addr)).await?; - let deletion_events = db.query(filters::deletions()).await?; + let announcements = db.query(addr.announcement_filter()).await?; + let states = db.query(addr.state_filter()).await?; + let activity = db.query(addr.activity_filter()).await?; + let deletion_events = db.query(Filters::deletions()).await?; Ok::<_, Error>((announcements, states, activity, deletion_events)) } @@ -397,7 +398,7 @@ impl RepoStore { .and_then(Announcement::from_event); let all_states = states.into_iter().filter(|e| !deletions.is_deleted(e)); - let state = utils::latest(all_states).map(|state| parse_state(&state)); + let state = utils::latest(all_states).map(|state| RepoState::parse(&state)); let (mut issues, mut patches, mut pull_requests, mut statuses, mut comments) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()); @@ -425,7 +426,7 @@ impl RepoStore { .chain(&pull_requests) .map(|e| e.id); - for filter in filters::comments_for(roots) { + for filter in Filters::comments_for(roots) { for event in db.query(filter).await? { if seen_comments.insert(event.id) { comments.push(event); @@ -443,7 +444,7 @@ impl RepoStore { .map(|e| e.id); for root in roots { - for event in db.query(filters::statuses_for([root])).await? { + for event in db.query(Filters::statuses_for([root])).await? { if seen_statuses.insert(event.id) { statuses.push(event); } @@ -516,7 +517,7 @@ impl RepoStore { let head_changed = state .as_ref() - .is_some_and(|(_, head)| this.head.as_deref() != head.as_deref()); + .is_some_and(|state| this.head.as_deref() != state.head.as_deref()); let changed = first_pass || (!keep_hint && this.announcement != announcement) @@ -549,8 +550,8 @@ impl RepoStore { this.sync_maintainer_relays(&maintainers, cx); - if let Some((_, head)) = state { - this.head = head; + if let Some(state) = state { + this.head = state.head; } this.issues = issues; @@ -579,8 +580,8 @@ impl RepoStore { if !new_roots.is_empty() { this.root_fetches.extend(new_roots.iter().copied()); - let mut root_filters = filters::comments_for(new_roots.clone()); - root_filters.push(filters::statuses_for(new_roots.iter().copied())); + let mut root_filters = Filters::comments_for(new_roots.clone()); + root_filters.push(Filters::statuses_for(new_roots.iter().copied())); let announced: Vec = this.repo_relays.iter().cloned().collect(); let backend = Backend::global(cx); @@ -621,7 +622,7 @@ impl RepoStore { pub fn is_author(&self, user: &PublicKey) -> bool { self.addr .as_ref() - .is_some_and(|addr| &addr.public_key == user) + .is_some_and(|addr| addr.public_key() == *user) } pub fn open_issue(&mut self, subject: Option, content: String, cx: &mut Context) { @@ -631,7 +632,7 @@ impl RepoStore { }; let builder = GitIssue { - repository: addr, + repository: addr.into(), content, subject, labels: Vec::new(), @@ -644,7 +645,7 @@ impl RepoStore { pub fn comments_of(&self, root: &EventId) -> impl Iterator { self.comments .iter() - .filter(move |e| signed_core::references_root(e, root)) + .filter(move |e| e.references_root(root)) } /// Comment on a root event, an issue or PR, per NIP-34, kind 1111. @@ -745,9 +746,9 @@ impl RepoStore { // The author's npub names their GRASP-06 namespace, `/prs/...`. let author_npub = user.to_bech32().unwrap(); - let owner = addr.public_key; + let owner = addr.public_key(); let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); - let repo_id = addr.identifier.clone(); + let repo_id = addr.identifier().to_owned(); let base_npub = owner.to_bech32().unwrap(); let push_relays = self .announcement @@ -857,7 +858,7 @@ impl RepoStore { let clone = pr_clone_urls(prs_urls, base_clone); let builder = GitPullRequest { - repository: addr.clone(), + repository: addr.clone().into(), content: description, subject, labels: Vec::new(), @@ -1069,7 +1070,8 @@ impl RepoStore { // The first revision patch replies to the original root patch, NIP-34. // Use the PR's `e` tag, or the oldest patch of the linked set if the PR has none. let root_patch_id = root.tags.event_ids().next().or_else(|| { - pull_request_patches(root, self.patches.iter()) + PullRequest::new(root) + .patches(self.patches.iter()) .first() .map(|p| p.id) }); @@ -1079,7 +1081,7 @@ impl RepoStore { return; }; - let owner = addr.public_key; + let owner = addr.public_key(); let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); let root = root.clone(); @@ -1110,7 +1112,7 @@ impl RepoStore { let builder = { let builder = GitPullRequestUpdate { - repository: addr.clone(), + repository: addr.clone().into(), pull_request_event: root.id, pull_request_author: root.pubkey, current_commit, @@ -1182,9 +1184,9 @@ impl RepoStore { let builder = EventBuilder::new(status.kind(), "").tags([ root_ref, - Tag::public_key(addr.public_key), + Tag::public_key(addr.public_key()), Tag::public_key(root.pubkey), - Tag::coordinate(addr, None), + Tag::coordinate(addr.clone().into(), None), ]); self.publish(builder, cx); @@ -1479,7 +1481,7 @@ fn resolve_statuses( .map(|root| { let events = by_root.get(&root.id).map(Vec::as_slice).unwrap_or(&[]); let status = - signed_core::resolve_status(events.iter().copied(), &root.pubkey, maintainers); + signed_core::RepoStatus::resolve(events.iter().copied(), &root.pubkey, maintainers); (root.id, status) }) .collect() @@ -1518,7 +1520,10 @@ async fn publish_patch_series( )); }; - let mut tags = vec![Tag::coordinate(addr.clone(), None), Tag::public_key(owner)]; + let mut tags = vec![ + Tag::coordinate(addr.clone().into(), None), + Tag::public_key(owner), + ]; if ix == 0 { if let Ok(tag) = Tag::parse(["t", first_marker]) { @@ -1586,11 +1591,13 @@ fn comment_builder( CommentBuilder::new(content, parent_target) .root(root_target) .into_event_builder() - .tags([Tag::coordinate(addr.clone(), None)]) + .tags([Tag::coordinate(addr.clone().into(), None)]) } #[cfg(test)] mod tests { + use signed_core::GitEvent; + use std::collections::HashSet; use nostr_sdk::prelude::*; @@ -1612,7 +1619,7 @@ mod tests { let root = EventBuilder::new(Kind::GitIssue, "issue body") .finalize(&keys) .expect("signed event"); - let addr = Coordinate::new(Kind::GitRepoAnnouncement, root.pubkey).identifier("my-repo"); + let addr = signed_core::RepoAddr::new(root.pubkey, "my-repo"); let relay = RelayUrl::parse("wss://relay.example.com").expect("valid relay URL"); let event = comment_builder(&root, None, Some(&relay), &addr, "hi".into()) @@ -1639,14 +1646,14 @@ mod tests { assert_eq!(e.as_slice()[1], root.id.to_hex()); // Signed's own `references_root` must keep matching the comment. - assert!(signed_core::references_root(&event, &root.id)); + assert!(event.references_root(&root.id)); } #[test] fn maintainer_filters_name_owner_and_maintainers() { let owner = Keys::generate().public_key(); let maintainer = Keys::generate().public_key(); - let addr = Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier("my-repo"); + let addr = signed_core::RepoAddr::new(owner, "my-repo"); // The owner is not among the maintainers, as on a subordinate fork. let filters = RepoStore::maintainer_filters(&addr, &[maintainer]); diff --git a/crates/signed_state/src/repos.rs b/crates/signed_state/src/repos.rs index be79c6d..469974b 100644 --- a/crates/signed_state/src/repos.rs +++ b/crates/signed_state/src/repos.rs @@ -5,7 +5,7 @@ use std::time::Duration; use anyhow::Error; use gpui::{App, AppContext, Context, Entity, Global, Subscription}; use nostr_sdk::prelude::*; -use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr}; +use signed_core::{Announcement, Deletions, Filters, RepoAddr, filters}; use crate::backend::{Backend, BackendEvent}; use crate::refresh::{RefreshGate, RefreshRequest}; @@ -115,10 +115,10 @@ impl RepoListStore { backend.update(cx, |backend, cx| { backend.sync_bootstraps( vec![ - filters::all_announcements(), - filters::all_states(), + Filters::all_announcements(), + Filters::all_states(), // Deletion requests, NIP-09/62, must be known before any announcement is shown. - filters::deletions(), + Filters::deletions(), ], cx, ); @@ -144,7 +144,7 @@ impl RepoListStore { self.state_synced_repos.insert(addr.clone()); backend.update(cx, |backend, cx| { - backend.connect_repo_relays(relays, vec![filters::state(&addr)], cx); + backend.connect_repo_relays(relays, vec![addr.state_filter()], cx); }); } } @@ -168,10 +168,10 @@ impl RepoListStore { let client = backend.read(cx).client(); let work = cx.background_spawn(async move { - let filter = filters::all_announcements(); + let filter = Filters::all_announcements(); let events = client.database().query(filter).await?; - let deletion_events = client.database().query(filters::deletions()).await?; + let deletion_events = client.database().query(Filters::deletions()).await?; let deletions = Deletions::from_events(deletion_events); // Dedup and sort off the main thread. @@ -213,7 +213,7 @@ impl RepoListStore { let Some(id) = event.tags.identifier() else { continue; }; - let addr = repo_addr(event.pubkey, id); + let addr = RepoAddr::new(event.pubkey, id); let Some(entry) = last_activity.get_mut(&addr) else { continue; }; @@ -230,10 +230,11 @@ impl RepoListStore { if deletions.is_deleted(&event) { continue; } - for addr in event.tags.coordinates() { - if addr.kind != Kind::GitRepoAnnouncement { + for coordinate in event.tags.coordinates() { + if coordinate.kind != Kind::GitRepoAnnouncement { continue; } + let addr = RepoAddr::from(coordinate.clone()); let Some(entry) = last_activity.get_mut(&addr) else { continue; }; @@ -251,12 +252,13 @@ impl RepoListStore { if deletions.is_deleted(&event) { continue; } - for addr in event.tags.coordinates() { - if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr) + for coordinate in event.tags.coordinates() { + if coordinate.kind != Kind::GitRepoAnnouncement + || !last_activity.contains_key(&RepoAddr::from(coordinate.clone())) { continue; } - let entry = counts.entry(addr).or_default(); + let entry = counts.entry(RepoAddr::from(coordinate)).or_default(); match event.kind { Kind::GitIssue => entry.issues += 1, Kind::GitPullRequest => entry.pull_requests += 1, diff --git a/crates/workspace/src/views/issues/detail.rs b/crates/workspace/src/views/issues/detail.rs index a401252..c5a7131 100644 --- a/crates/workspace/src/views/issues/detail.rs +++ b/crates/workspace/src/views/issues/detail.rs @@ -8,7 +8,7 @@ use gpui_component::input::TextareaState; use gpui_component::scroll::ScrollableElement; use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex}; use nostr::prelude::EventId; -use signed_core::activity_subject; +use signed_core::GitEvent; use signed_state::{ProfileStore, RepoStore}; use signed_ui::{Avatar, placeholder, status_badge}; use utils::relative_time; @@ -103,7 +103,7 @@ impl Render for IssueDetailView { }; ( - activity_subject(issue), + issue.activity_subject(), profile.name(), profile.picture(), store.status_of(issue), diff --git a/crates/workspace/src/views/issues/mod.rs b/crates/workspace/src/views/issues/mod.rs index b082548..edfba02 100644 --- a/crates/workspace/src/views/issues/mod.rs +++ b/crates/workspace/src/views/issues/mod.rs @@ -16,7 +16,7 @@ use gpui_component::{ ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, }; use nostr::prelude::EventId; -use signed_core::{RepoStatus, activity_subject}; +use signed_core::{GitEvent, RepoStatus}; use signed_state::{ProfileStore, RepoStore}; use signed_ui::{Avatar, SegmentButton, placeholder, status_badge}; use utils::relative_time; @@ -146,7 +146,7 @@ impl IssuesView { fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context) -> AnyElement { let issue = &self.store.read(cx).issues[issue_ix]; - let title = activity_subject(issue); + let title = issue.activity_subject(); let id_hex = issue.id.to_hex(); let profile = ProfileStore::global(cx).read(cx).get(&issue.pubkey); let author = profile.name(); diff --git a/crates/workspace/src/views/pull_requests/detail.rs b/crates/workspace/src/views/pull_requests/detail.rs index d1385c3..c32c71d 100644 --- a/crates/workspace/src/views/pull_requests/detail.rs +++ b/crates/workspace/src/views/pull_requests/detail.rs @@ -21,10 +21,7 @@ use gpui_component::{ v_virtual_list, }; use nostr::prelude::{Event, EventId, Kind, Url}; -use signed_core::{ - RepoAddr, activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update, - merge_base_of, pull_request_patch, -}; +use signed_core::{GitEvent, PullRequest, RepoAddr}; use signed_git::{FileCommit, patch_commits, patch_diffs}; use signed_state::{Backend, ProfileStore, RepoStore, ensure_repo_mirror}; use signed_ui::{Avatar, CountBadge, placeholder, status_badge}; @@ -145,23 +142,24 @@ impl PullRequestDetailView { .iter() .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) .map(|root| { - let update = latest_update(store.pull_requests.iter(), root); + let update = PullRequest::latest_update(store.pull_requests.iter(), root); let tip = update - .and_then(current_commit_of) - .or_else(|| current_commit_of(root)); + .and_then(GitEvent::current_commit) + .or_else(|| root.current_commit()); let base = update - .and_then(merge_base_of) - .or_else(|| merge_base_of(root)); + .and_then(GitEvent::merge_base) + .or_else(|| root.merge_base()); - let clone_urls = clone_urls_of(root) + let clone_urls = root + .clone_urls() .or_else(|| store.announcement.as_ref().map(|a| a.clone.clone())) .unwrap_or_default(); PrBinding { description: root.content.clone(), - patch: pull_request_patch(root, store.patches.iter()), + patch: PullRequest::new(root).patch(store.patches.iter()), tip, base, clone_urls, @@ -620,9 +618,9 @@ impl PullRequestDetailView { return div().into_any_element(); }; ( - activity_subject(root), + root.activity_subject(), store.status_of(root), - branch_name_of(root), + root.branch_name(), root.pubkey, ) }; diff --git a/crates/workspace/src/views/pull_requests/mod.rs b/crates/workspace/src/views/pull_requests/mod.rs index b14bf99..beb3a93 100644 --- a/crates/workspace/src/views/pull_requests/mod.rs +++ b/crates/workspace/src/views/pull_requests/mod.rs @@ -14,7 +14,7 @@ use gpui_component::{ ActiveTheme, Icon, IconName, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; use nostr::prelude::{EventId, Kind}; -use signed_core::{RepoStatus, activity_subject}; +use signed_core::{GitEvent, RepoStatus}; use signed_state::{ProfileStore, RepoStore}; use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge}; use utils::relative_time; @@ -159,7 +159,7 @@ impl PullRequestsView { fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context) -> AnyElement { let pr = &self.store.read(cx).pull_requests[pr_ix]; let pr_id = pr.id; - let title = activity_subject(pr); + let title = pr.activity_subject(); let id_hex = pr.id.to_hex(); let age = relative_time(pr.created_at); diff --git a/crates/workspace/src/views/pull_requests/new.rs b/crates/workspace/src/views/pull_requests/new.rs index 88e417c..2762655 100644 --- a/crates/workspace/src/views/pull_requests/new.rs +++ b/crates/workspace/src/views/pull_requests/new.rs @@ -21,7 +21,7 @@ use gpui_component::{ v_virtual_list, }; use nostr::prelude::*; -use signed_core::{Announcement, RepoAddr, fork_candidates}; +use signed_core::{Announcement, RepoAddr}; use signed_git::{ delete_refs_with_prefix, fetch_repo_refs, fork_namespace, merge_base, refs_with_prefix, worktree_commit_range_commits, worktree_commit_range_diff, @@ -512,7 +512,7 @@ impl NewPullRequestView { }; let user = Backend::global(cx).read(cx).current_user(); let announcements = RepoListStore::global(cx).read(cx).announcements.clone(); - fork_candidates(&announcements, &base, euc.as_deref(), user) + Announcement::forks_in(&announcements, &base, euc.as_deref(), user) .into_iter() .cloned() .collect() diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index fcb7a93..23f9dac 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -531,7 +531,7 @@ impl RepoDetailView { .as_ref() .map(|name| name.to_string()) .filter(|name| !name.trim().is_empty()) - .unwrap_or_else(|| addr.identifier.clone()); + .unwrap_or_else(|| addr.identifier().to_owned()); let name = signed_git::sanitize_path_component(&name); if name.is_empty() { @@ -1857,7 +1857,7 @@ fn fork_row(announcement: &Announcement, cx: &mut Context) -> Op .map(SharedString::from) .unwrap_or_else(|| SharedString::from(a.id.clone())) }) - .unwrap_or_else(|| SharedString::from(addr.identifier.clone())); + .unwrap_or_else(|| SharedString::from(addr.identifier().to_owned())); (SharedString::from(format!("Forked from {name}")), true) } None => (SharedString::from(upstream.display().as_str()), false), @@ -1900,7 +1900,7 @@ impl ShareTargets { fn from_announcement(announcement: &Announcement) -> Self { let addr = announcement.addr(); let coordinate = addr.to_string(); - let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned()) + let naddr = Nip19Coordinate::new(addr.into(), announcement.relays.iter().cloned()) .to_bech32() .expect("a complete coordinate always encodes to naddr"); diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 2fde423..16fc379 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -228,7 +228,7 @@ impl RepoListView { .map(SharedString::from) .unwrap_or_else(|| SharedString::from(a.id.clone())) }) - .unwrap_or_else(|| SharedString::from(addr.identifier.clone())); + .unwrap_or_else(|| SharedString::from(addr.identifier().to_owned())); Some(SharedString::from(format!("forked from {name}"))) }); -- 2.54.0 From 4656f1340bb050051fa4a8d69cf500ec3da0e0c8 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 3 Oct 2026 12:07:49 +0700 Subject: [PATCH 5/9] consolidate backend init into NostrBackend struct --- crates/signed_nostr/src/backend.rs | 56 +++++++++++++++++------------- crates/signed_nostr/src/lib.rs | 2 +- crates/signed_state/src/inbox.rs | 2 +- crates/signed_state/src/lib.rs | 26 ++++---------- 4 files changed, 40 insertions(+), 46 deletions(-) diff --git a/crates/signed_nostr/src/backend.rs b/crates/signed_nostr/src/backend.rs index 5a1f805..1ab564c 100644 --- a/crates/signed_nostr/src/backend.rs +++ b/crates/signed_nostr/src/backend.rs @@ -8,32 +8,40 @@ use nostr_sdk::prelude::*; use crate::signer::UniversalSigner; -pub async fn new_backend(db_path: impl AsRef) -> Result<(Client, UniversalSigner)> { - let signer = UniversalSigner::new(Keys::generate()); - let database = NostrLmdb::open(db_path) - .await - .context("failed to open nostr database")?; - Ok(with_database(signer, database)) +pub struct NostrBackend { + pub client: Client, + pub signer: UniversalSigner, } -fn with_database(signer: UniversalSigner, database: D) -> (Client, UniversalSigner) -where - D: IntoNostrDatabase, -{ - let authenticator = SignerAuthenticator::new(signer.clone()); +impl NostrBackend { + pub async fn open(db_path: impl AsRef) -> Result { + let db_path = db_path.as_ref(); + let signer = UniversalSigner::new(Keys::generate()); + let database = NostrLmdb::open(db_path) + .await + .context("failed to open nostr database")?; + Ok(Self::with_database(signer, database)) + } - let client = ClientBuilder::default() - .database(database) - .authenticator(authenticator) - .gossip(NostrGossipMemory::unbounded()) - .gossip_config(GossipConfig::default().no_background_refresh()) - .connect_timeout(Duration::from_secs(10)) - .verify_subscriptions(true) - .ban_relay_on_mismatch(true) - .sleep_when_idle(SleepWhenIdle::Enabled { - timeout: Duration::from_secs(600), - }) - .build(); + fn with_database(signer: UniversalSigner, database: D) -> Self + where + D: IntoNostrDatabase, + { + let authenticator = SignerAuthenticator::new(signer.clone()); - (client, signer) + let client = ClientBuilder::default() + .database(database) + .authenticator(authenticator) + .gossip(NostrGossipMemory::unbounded()) + .gossip_config(GossipConfig::default().no_background_refresh()) + .connect_timeout(Duration::from_secs(10)) + .verify_subscriptions(true) + .ban_relay_on_mismatch(true) + .sleep_when_idle(SleepWhenIdle::Enabled { + timeout: Duration::from_secs(600), + }) + .build(); + + Self { client, signer } + } } diff --git a/crates/signed_nostr/src/lib.rs b/crates/signed_nostr/src/lib.rs index 3c99075..1328bf4 100644 --- a/crates/signed_nostr/src/lib.rs +++ b/crates/signed_nostr/src/lib.rs @@ -2,6 +2,6 @@ mod backend; mod signer; mod update; -pub use backend::new_backend; +pub use backend::NostrBackend; pub use signer::{SignedAuthUrlHandler, UniversalSigner}; pub use update::Update; diff --git a/crates/signed_state/src/inbox.rs b/crates/signed_state/src/inbox.rs index fa557be..d7be438 100644 --- a/crates/signed_state/src/inbox.rs +++ b/crates/signed_state/src/inbox.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use anyhow::Error; use gpui::{AppContext, Context, Task}; use nostr_sdk::prelude::*; -use signed_core::{Deletions, Filters, InboxItem, InboxReadState, GitEvent, inbox}; +use signed_core::{Deletions, Filters, GitEvent, InboxItem, InboxReadState, inbox}; use crate::backend::Backend; diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index c8fadb5..217cc56 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -23,9 +23,8 @@ pub use refresh::{RefreshGate, RefreshRequest}; pub use repo::RepoStore; pub use repos::RepoListStore; pub use signed_git::{Nip34Binding, Nip34Kind}; -use signed_nostr::new_backend; +use signed_nostr::NostrBackend; -#[cfg(not(target_arch = "wasm32"))] pub fn init( db_path: impl AsRef, repos_root: impl Into, @@ -36,12 +35,11 @@ pub fn init( let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); // Initialize the nostr client and signer - let (client, signer) = cx.foreground_executor().block_on(async move { - let path = db_path.as_ref().to_path_buf(); - new_backend(path) - .await - .expect("failed to initialize nostr backend") - }); + let backend = cx + .foreground_executor() + .block_on(async move { NostrBackend::open(db_path.as_ref()).await }); + let backend = backend.expect("failed to initialize nostr backend"); + let (client, signer) = (backend.client, backend.signer); // Set Git cache for the repos root set_git_cache(repos_root); @@ -61,15 +59,3 @@ pub fn init( // Set global stores for the checkouts CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx); } - -/// Initialize the backend with an in-memory database on wasm. -#[cfg(target_arch = "wasm32")] -pub fn init(cx: &mut App) { - let (client, signer) = new_backend().expect("failed to initialize nostr backend"); - set_git_cache(PathBuf::new()); - Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx); - ProfileStore::set_global(cx.new(ProfileStore::new), cx); - RepoListStore::set_global(cx.new(RepoListStore::new), cx); - LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx); - CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx); -} -- 2.54.0 From 49d351fe0643b2bb1b69e65ef123c86caf8b588c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 3 Oct 2026 12:46:44 +0700 Subject: [PATCH 6/9] consolidate git operations into a `Repo` type --- crates/signed_git/src/cache.rs | 74 +- crates/signed_git/src/diff.rs | 305 +++--- crates/signed_git/src/history.rs | 374 ++++---- crates/signed_git/src/lib.rs | 39 +- crates/signed_git/src/nip34.rs | 398 ++++---- crates/signed_git/src/patch.rs | 612 ++++++------ crates/signed_git/src/remote.rs | 632 ++++++------ crates/signed_git/src/repo.rs | 898 ++++++++++-------- crates/signed_git/src/scan.rs | 5 +- crates/signed_git/src/tests.rs | 223 +++-- crates/signed_git/src/worktree.rs | 600 ++++++------ crates/signed_state/src/backend.rs | 40 +- crates/signed_state/src/checkouts.rs | 44 +- crates/signed_state/src/git_store.rs | 7 +- crates/signed_state/src/repo.rs | 21 +- crates/workspace/src/views/commit_diff/mod.rs | 8 +- .../src/views/pull_requests/detail.rs | 20 +- .../workspace/src/views/pull_requests/new.rs | 44 +- crates/workspace/src/views/repo/files.rs | 8 +- crates/workspace/src/views/repo/history.rs | 6 +- crates/workspace/src/views/repo/mod.rs | 88 +- 21 files changed, 2243 insertions(+), 2203 deletions(-) diff --git a/crates/signed_git/src/cache.rs b/crates/signed_git/src/cache.rs index fdfa932..106dd01 100644 --- a/crates/signed_git/src/cache.rs +++ b/crates/signed_git/src/cache.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use signed_core::{Announcement, RepoAddr}; -use crate::remote::{clone_repo, fetch_all}; +use crate::repo::Repo; /// On-disk cache of cloned repositories, keyed by owner pubkey / repo id. #[derive(Debug, Clone)] @@ -23,13 +23,13 @@ impl GitCache { pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf { self.root .join(addr.public_key().to_hex()) - .join(sanitize_path_component(addr.identifier())) + .join(Self::sanitize_path_component(addr.identifier())) } - pub fn open(&self, addr: &RepoAddr) -> Result> { + pub fn open(&self, addr: &RepoAddr) -> Result> { let path = self.repo_path(addr); match gix::open(&path) { - Ok(repo) => Ok(Some(repo)), + Ok(repo) => Ok(Some(Repo { inner: repo })), Err(gix::open::Error::NotARepository { .. }) => Ok(None), Err(gix::open::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e.into()), @@ -37,15 +37,11 @@ impl GitCache { } /// Open the existing clone, fetching it first. - pub fn ensure_clone>( - &self, - addr: &RepoAddr, - clone_urls: &[U], - ) -> Result { + pub fn ensure_clone>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result { let path = self.repo_path(addr); if let Some(repo) = self.open(addr)? { - fetch_all(&repo).ok(); + repo.fetch().ok(); return Ok(repo); } @@ -54,37 +50,35 @@ impl GitCache { .with_context(|| format!("failed to create {}", parent.display()))?; } - clone_repo(clone_urls, &path)?; - self.open(addr)? - .ok_or_else(|| anyhow::anyhow!("clone finished but the repository cannot be opened")) - } -} - -/// Map an untrusted repository id or display name to a safe single path component. -pub fn sanitize_path_component(id: &str) -> String { - let sanitized: String = id - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { - c - } else { - '_' - } - }) - .collect(); - - if sanitized == "." || sanitized == ".." { - return "_".to_owned(); + Repo::clone(clone_urls, &path) } - sanitized -} + /// Map an untrusted repository id or display name to a safe single path component. + pub fn sanitize_path_component(id: &str) -> String { + let sanitized: String = id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + }) + .collect(); -/// The refs namespace of a fork's import in the target mirror. -pub fn fork_namespace(announcement: &Announcement) -> String { - format!( - "{}/{}", - announcement.owner.to_hex(), - sanitize_path_component(&announcement.id) - ) + if sanitized == "." || sanitized == ".." { + return "_".to_owned(); + } + + sanitized + } + + /// The refs namespace of a fork's import in the target mirror. + pub fn fork_namespace(announcement: &Announcement) -> String { + format!( + "{}/{}", + announcement.owner.to_hex(), + Self::sanitize_path_component(&announcement.id) + ) + } } diff --git a/crates/signed_git/src/diff.rs b/crates/signed_git/src/diff.rs index 8e0afb3..ed8a394 100644 --- a/crates/signed_git/src/diff.rs +++ b/crates/signed_git/src/diff.rs @@ -1,8 +1,8 @@ -use std::path::Path; - use anyhow::Result; use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader}; +use crate::repo::Repo; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DiffLineKind { /// An unchanged context line, present on both sides. @@ -66,159 +66,162 @@ pub struct CommitDiff { pub files: Vec, } -/// The changes of the commit `id`, short or full, in the repository at `workdir`. -/// -/// Compared against its first parent, the empty tree for the root commit. -pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result { - commit_diff(&gix::open(workdir)?, id) -} - -fn commit_diff(repo: &gix::Repository, id: &str) -> Result { - let commit_id = repo.rev_parse_single(id.as_bytes())?; - let commit = commit_id.object()?.into_commit(); - let new_tree = commit.tree()?; - let old_tree = match commit.parent_ids().next() { - Some(parent) => Some(parent.object()?.into_commit().tree()?), - None => None, - }; - tree_diff(repo, old_tree.as_ref(), &new_tree) -} - -/// The changes between two commits, `base`..`tip`, like `git diff base tip`. -/// -/// Directories and submodules are skipped, files are sorted by path. -pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result { - let repo = gix::open(workdir)?; - let base_tree = repo - .rev_parse_single(base.as_bytes())? - .object()? - .into_commit() - .tree()?; - let tip_tree = repo - .rev_parse_single(tip.as_bytes())? - .object()? - .into_commit() - .tree()?; - tree_diff(&repo, Some(&base_tree), &tip_tree) -} - -fn tree_diff( - repo: &gix::Repository, - old_tree: Option<&gix::Tree<'_>>, - new_tree: &gix::Tree<'_>, -) -> Result { - use gix::diff::blob::platform::prepare_diff::Operation; - use gix::object::tree::diff::Change; - use gix::objs::tree::EntryKind; - - let changes = repo.diff_tree_to_tree(old_tree, Some(new_tree), None)?; - - let mut cache = repo.diff_resource_cache_for_tree_diff()?; - let mut files = Vec::new(); - - for change in changes { - let attached = Change::from_change_ref(change.to_ref(), repo, repo); - - // Skip directory trees and submodule gitlinks, only files are listed. - let (path, old_path, status) = match attached { - Change::Addition { - location, - entry_mode, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { - (location.to_owned(), None, DiffStatus::Added) - } - Change::Deletion { - location, - entry_mode, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { - (location.to_owned(), None, DiffStatus::Deleted) - } - Change::Modification { - location, - previous_entry_mode, - entry_mode, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) - && !matches!( - previous_entry_mode.kind(), - EntryKind::Tree | EntryKind::Commit - ) => - { - (location.to_owned(), None, DiffStatus::Modified) - } - Change::Rewrite { - location, - source_location, - source_entry_mode, - entry_mode, - copy, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) - && !matches!( - source_entry_mode.kind(), - EntryKind::Tree | EntryKind::Commit - ) => - { - let status = if copy { - DiffStatus::Copied - } else { - DiffStatus::Renamed - }; - ( - location.to_owned(), - Some(source_location.to_owned()), - status, - ) - } - _ => continue, +impl Repo { + /// The changes of the commit `id`, short or full. + /// + /// Compared against its first parent, the empty tree for the root commit. + pub fn commit_diff(&self, id: &str) -> Result { + let commit_id = self.inner.rev_parse_single(id.as_bytes())?; + let commit = commit_id.object()?.into_commit(); + let new_tree = commit.tree()?; + let old_tree = match commit.parent_ids().next() { + Some(parent) => Some(parent.object()?.into_commit().tree()?), + None => None, }; - - // Always diff with the built-in algorithm. - // External diff drivers would shell out, out of scope for a read-only viewer. - let platform = attached.diff(&mut cache)?; - platform - .resource_cache - .options - .skip_internal_diff_if_external_is_configured = true; - let outcome = platform.resource_cache.prepare_diff()?; - - let (binary, hunks, insertions, deletions) = match outcome.operation { - Operation::InternalDiff { algorithm } => { - let input = outcome.interned_input(); - let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input); - - let mut hunks = Vec::new(); - let mut insertions = 0usize; - let mut deletions = 0usize; - let collector = HunkCollector { - hunks: &mut hunks, - insertions: &mut insertions, - deletions: &mut deletions, - }; - gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default()) - .consume()?; - (false, hunks, insertions, deletions) - } - Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0), - Operation::ExternalCommand { .. } => unreachable!("external diff drivers are disabled"), - }; - - files.push(FileDiff { - path: String::from_utf8_lossy(&path).into_owned(), - old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()), - status, - insertions, - deletions, - binary, - hunks, - }); + Self::tree_diff(self, old_tree.as_ref(), &new_tree) } - files.sort_by(|a, b| a.path.cmp(&b.path)); + /// The changes between two commits, `base`..`tip`, like `git diff base tip`. + /// + /// Directories and submodules are skipped, files are sorted by path. + pub fn range_diff(&self, base: &str, tip: &str) -> Result { + let base_tree = self + .inner + .rev_parse_single(base.as_bytes())? + .object()? + .into_commit() + .tree()?; + let tip_tree = self + .inner + .rev_parse_single(tip.as_bytes())? + .object()? + .into_commit() + .tree()?; + Self::tree_diff(self, Some(&base_tree), &tip_tree) + } - Ok(CommitDiff { files }) + fn tree_diff( + repo: &Repo, + old_tree: Option<&gix::Tree<'_>>, + new_tree: &gix::Tree<'_>, + ) -> Result { + use gix::diff::blob::platform::prepare_diff::Operation; + use gix::object::tree::diff::Change; + use gix::objs::tree::EntryKind; + + let changes = repo + .inner + .diff_tree_to_tree(old_tree, Some(new_tree), None)?; + + let mut cache = repo.inner.diff_resource_cache_for_tree_diff()?; + let mut files = Vec::new(); + + for change in changes { + let attached = Change::from_change_ref(change.to_ref(), &repo.inner, &repo.inner); + + // Skip directory trees and submodule gitlinks, only files are listed. + let (path, old_path, status) = match attached { + Change::Addition { + location, + entry_mode, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { + (location.to_owned(), None, DiffStatus::Added) + } + Change::Deletion { + location, + entry_mode, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { + (location.to_owned(), None, DiffStatus::Deleted) + } + Change::Modification { + location, + previous_entry_mode, + entry_mode, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) + && !matches!( + previous_entry_mode.kind(), + EntryKind::Tree | EntryKind::Commit + ) => + { + (location.to_owned(), None, DiffStatus::Modified) + } + Change::Rewrite { + location, + source_location, + source_entry_mode, + entry_mode, + copy, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) + && !matches!( + source_entry_mode.kind(), + EntryKind::Tree | EntryKind::Commit + ) => + { + let status = if copy { + DiffStatus::Copied + } else { + DiffStatus::Renamed + }; + ( + location.to_owned(), + Some(source_location.to_owned()), + status, + ) + } + _ => continue, + }; + + // Always diff with the built-in algorithm. + // External diff drivers would shell out, out of scope for a read-only viewer. + let platform = attached.diff(&mut cache)?; + platform + .resource_cache + .options + .skip_internal_diff_if_external_is_configured = true; + let outcome = platform.resource_cache.prepare_diff()?; + + let (binary, hunks, insertions, deletions) = match outcome.operation { + Operation::InternalDiff { algorithm } => { + let input = outcome.interned_input(); + let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input); + + let mut hunks = Vec::new(); + let mut insertions = 0usize; + let mut deletions = 0usize; + let collector = HunkCollector { + hunks: &mut hunks, + insertions: &mut insertions, + deletions: &mut deletions, + }; + gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default()) + .consume()?; + (false, hunks, insertions, deletions) + } + Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0), + Operation::ExternalCommand { .. } => { + unreachable!("external diff drivers are disabled") + } + }; + + files.push(FileDiff { + path: String::from_utf8_lossy(&path).into_owned(), + old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()), + status, + insertions, + deletions, + binary, + hunks, + }); + } + + files.sort_by(|a, b| a.path.cmp(&b.path)); + + Ok(CommitDiff { files }) + } } /// Collects the hunks of one blob diff while tracking per-line numbers. diff --git a/crates/signed_git/src/history.rs b/crates/signed_git/src/history.rs index f99a105..2669c4f 100644 --- a/crates/signed_git/src/history.rs +++ b/crates/signed_git/src/history.rs @@ -3,11 +3,7 @@ use std::path::{Path, PathBuf}; use anyhow::Result; -/// In-memory object cache for history walks, see [`open_with_cache`]. -/// -/// Without one, a walk re-decodes the same commit objects from the object database. -/// Sized generously: a walk can cover a large portion of the repository's history. -const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024; +use crate::repo::Repo; /// Metadata of a commit, as shown in the repository browser's file header. #[derive(Debug, Clone)] @@ -25,121 +21,192 @@ pub struct FileCommit { pub time: i64, } -/// Open the repository at `workdir` with an in-memory object cache. -/// -/// Only history walks use it, they re-decode the same commit objects repeatedly. -/// Single-object reads open the repository plain. -pub(crate) fn open_with_cache(workdir: &Path) -> Result { - let mut repo = gix::open(workdir)?; - repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES); - Ok(repo) -} - -/// A [`FileCommit`] with author, message title, body and shortened id. -/// -/// The diff panel fetches the full commit on demand. -fn file_commit(commit: &gix::Commit<'_>) -> Result { - file_commit_with_description(commit, true) -} - -/// A [`FileCommit`] without the message body, for history lists that never display it. -/// -/// Skipping the body saves an allocation per listed commit. -fn file_commit_summary(commit: &gix::Commit<'_>) -> Result { - file_commit_with_description(commit, false) -} - -fn file_commit_with_description( - commit: &gix::Commit<'_>, - include_description: bool, -) -> Result { - let author = commit.author()?; - let message = commit.message()?; - - Ok(FileCommit { - id: commit.id().shorten_or_id().to_string(), - summary: String::from_utf8_lossy(message.title).trim().to_string(), - description: if include_description { - message - .body - .map(|body| String::from_utf8_lossy(body).trim().to_string()) - .filter(|body| !body.is_empty()) - } else { - None - }, - author: String::from_utf8_lossy(author.name).trim().to_string(), - time: author.time()?.seconds, - }) -} - -/// Newest commit touching each of `rels`, like `git log -1 -- ` per path. -/// `rels` are paths relative to the worktree. -/// -/// Paths without any commit, like untracked files, are absent from the result. -pub fn worktree_last_commits( - workdir: &Path, - rels: &[PathBuf], -) -> Result> { - last_commits(&open_with_cache(workdir)?, rels) -} - -/// Stops as soon as every pending path has its commit. -fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result> { - use gix::traverse::commit::simple::CommitTimeOrder; - - let Some(head) = repo.head_id().ok() else { - return Ok(Vec::new()); - }; - - let mut pending: Vec = Vec::with_capacity(rels.len()); - let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len()); - - for rel in rels { - if seen.insert(rel.as_path()) { - pending.push(rel.clone()); - } +impl FileCommit { + /// A [`FileCommit`] with author, message title, body and shortened id. + /// + /// The diff panel fetches the full commit on demand. + fn from_commit(commit: &gix::Commit<'_>) -> Result { + Self::from_commit_with_description(commit, true) } - let walk = repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - CommitTimeOrder::NewestFirst, - )); + /// A [`FileCommit`] without the message body, for history lists that never display it. + /// + /// Skipping the body saves an allocation per listed commit. + fn from_commit_summary(commit: &gix::Commit<'_>) -> Result { + Self::from_commit_with_description(commit, false) + } - let mut found = Vec::new(); - for info in walk.all()? { - if pending.is_empty() { - break; - } - let info = info?; - let commit = info.object()?; - let tree = commit.tree()?; - let parent_tree = match info.parent_ids().next() { - Some(parent) => Some(parent.object()?.into_commit().tree()?), - None => None, + fn from_commit_with_description( + commit: &gix::Commit<'_>, + include_description: bool, + ) -> Result { + let author = commit.author()?; + let message = commit.message()?; + + Ok(FileCommit { + id: commit.id().shorten_or_id().to_string(), + summary: String::from_utf8_lossy(message.title).trim().to_string(), + description: if include_description { + message + .body + .map(|body| String::from_utf8_lossy(body).trim().to_string()) + .filter(|body| !body.is_empty()) + } else { + None + }, + author: String::from_utf8_lossy(author.name).trim().to_string(), + time: author.time()?.seconds, + }) + } +} + +impl Repo { + /// Newest commit touching each of `rels`, like `git log -1 -- ` per path. + /// `rels` are paths relative to the worktree. + /// + /// Paths without any commit, like untracked files, are absent from the result. + pub fn last_commits(&self, rels: &[PathBuf]) -> Result> { + use gix::traverse::commit::simple::CommitTimeOrder; + + let Some(head) = self.inner.head_id().ok() else { + return Ok(Vec::new()); }; - // Compare each unresolved path against this commit and its first parent. - let mut ix = 0; - while ix < pending.len() { - let rel = &pending[ix]; - let blob = tree.lookup_entry_by_path(rel)?; - let parent_blob = match &parent_tree { - Some(tree) => tree.lookup_entry_by_path(rel)?, + let mut pending: Vec = Vec::with_capacity(rels.len()); + let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len()); + + for rel in rels { + if seen.insert(rel.as_path()) { + pending.push(rel.clone()); + } + } + + let walk = self + .inner + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + CommitTimeOrder::NewestFirst, + )); + + let mut found = Vec::new(); + for info in walk.all()? { + if pending.is_empty() { + break; + } + let info = info?; + let commit = info.object()?; + let tree = commit.tree()?; + let parent_tree = match info.parent_ids().next() { + Some(parent) => Some(parent.object()?.into_commit().tree()?), None => None, }; - if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach()) - { - found.push((rel.clone(), file_commit(&commit)?)); - pending.swap_remove(ix); - } else { - ix += 1; + // Compare each unresolved path against this commit and its first parent. + let mut ix = 0; + while ix < pending.len() { + let rel = &pending[ix]; + let blob = tree.lookup_entry_by_path(rel)?; + let parent_blob = match &parent_tree { + Some(tree) => tree.lookup_entry_by_path(rel)?, + None => None, + }; + + if blob.map(|entry| entry.id().detach()) + != parent_blob.map(|entry| entry.id().detach()) + { + found.push((rel.clone(), FileCommit::from_commit(&commit)?)); + pending.swap_remove(ix); + } else { + ix += 1; + } } } + + Ok(found) } - Ok(found) + /// All commits reachable from `HEAD`, newest first, with author and summary. + /// + /// Returns an empty list for a repository without any commits yet. + pub fn all_commits(&self) -> Result { + use gix::traverse::commit::simple::CommitTimeOrder; + + let Some(head) = self.inner.head_id().ok() else { + return Ok(CommitList { + total: 0, + commits: Vec::new(), + }); + }; + + let walk = self + .inner + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + CommitTimeOrder::NewestFirst, + )); + + let mut commits = Vec::new(); + let mut total = 0; + + for info in walk.all()? { + let info = info?; + total += 1; + if commits.len() < MAX_LISTED_COMMITS { + commits.push(FileCommit::from_commit_summary(&info.object()?)?); + } + } + + Ok(CommitList { total, commits }) + } + + /// Commits in the range `base`..`tip`, newest first, like `git log base..tip`. + pub fn commit_range(&self, base: &str, tip: &str) -> Result> { + use gix::traverse::commit::simple::CommitTimeOrder; + + let base_id = self.inner.rev_parse_single(base.as_bytes())?; + let tip_id = self.inner.rev_parse_single(tip.as_bytes())?; + let walk = self + .inner + .rev_walk([tip_id]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + CommitTimeOrder::NewestFirst, + )) + .with_hidden([base_id]); + + let mut commits = Vec::new(); + + for info in walk.all()? { + let info = info?; + commits.push(FileCommit::from_commit_summary(&info.object()?)?); + } + + Ok(commits) + } + + /// The commit HEAD points to, like `git log -1`. + /// + /// `Ok(None)` for a repository without commits yet, an unborn HEAD. + pub fn head_commit(&self) -> Result> { + let Some(head) = self.inner.head_id().ok() else { + return Ok(None); + }; + let commit = head.object()?.into_commit(); + Ok(Some(FileCommit::from_commit(&commit)?)) + } + + /// Full metadata of the commit `id`, short or full. + /// Like [`Repo::head_commit`] for an arbitrary commit. + /// + /// `Ok(None)` when the id cannot be resolved. + pub fn commit(&self, id: &str) -> Result> { + match self.inner.rev_parse_single(id.as_bytes()) { + Ok(commit_id) => { + let commit = commit_id.object()?.into_commit(); + Ok(Some(FileCommit::from_commit(&commit)?)) + } + Err(_) => Ok(None), + } + } } /// Cap on [`CommitList::commits`]. The virtual list renders a window at a time, @@ -155,94 +222,3 @@ pub struct CommitList { /// Newest commits, capped at [`MAX_LISTED_COMMITS`]. pub commits: Vec, } - -/// All commits reachable from `HEAD`, newest first, with author and summary. -/// -/// Returns an empty list for a repository without any commits yet. -pub fn all_commits(repo: &gix::Repository) -> Result { - use gix::traverse::commit::simple::CommitTimeOrder; - - let Some(head) = repo.head_id().ok() else { - return Ok(CommitList { - total: 0, - commits: Vec::new(), - }); - }; - - let walk = repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - CommitTimeOrder::NewestFirst, - )); - - let mut commits = Vec::new(); - let mut total = 0; - - for info in walk.all()? { - let info = info?; - total += 1; - if commits.len() < MAX_LISTED_COMMITS { - commits.push(file_commit_summary(&info.object()?)?); - } - } - - Ok(CommitList { total, commits }) -} - -/// For non-bare clones the clone root is the worktree. -pub fn worktree_all_commits(workdir: &Path) -> Result { - all_commits(&open_with_cache(workdir)?) -} - -/// Commits in the range `base`..`tip`, newest first, like `git log base..tip`. -pub fn worktree_commit_range_commits( - workdir: &Path, - base: &str, - tip: &str, -) -> Result> { - use gix::traverse::commit::simple::CommitTimeOrder; - - let repo = open_with_cache(workdir)?; - let base_id = repo.rev_parse_single(base.as_bytes())?; - let tip_id = repo.rev_parse_single(tip.as_bytes())?; - let walk = repo - .rev_walk([tip_id]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - CommitTimeOrder::NewestFirst, - )) - .with_hidden([base_id]); - - let mut commits = Vec::new(); - - for info in walk.all()? { - let info = info?; - commits.push(file_commit_summary(&info.object()?)?); - } - - Ok(commits) -} -/// The commit HEAD points to, like `git log -1`. -/// -/// `Ok(None)` for a repository without commits yet, an unborn HEAD. -pub fn head_commit(repo: &gix::Repository) -> Result> { - let Some(head) = repo.head_id().ok() else { - return Ok(None); - }; - let commit = head.object()?.into_commit(); - Ok(Some(file_commit(&commit)?)) -} - -/// Full metadata of the commit `id`, short or full, in the repository at `workdir`. -/// Like [`head_commit`] for an arbitrary commit. -/// -/// `Ok(None)` when the id cannot be resolved. -pub fn worktree_commit(workdir: &Path, id: &str) -> Result> { - let repo = gix::open(workdir)?; - match repo.rev_parse_single(id.as_bytes()) { - Ok(commit_id) => { - let commit = commit_id.object()?.into_commit(); - Ok(Some(file_commit(&commit)?)) - } - Err(_) => Ok(None), - } -} diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 470aaee..c967fe4 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -11,41 +11,18 @@ mod worktree; #[cfg(test)] mod tests; -pub use cache::{GitCache, fork_namespace, sanitize_path_component}; -pub use diff::{ - CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff, worktree_commit_diff, - worktree_commit_range_diff, -}; -pub use history::{ - CommitList, FileCommit, MAX_LISTED_COMMITS, all_commits, head_commit, worktree_all_commits, - worktree_commit, worktree_commit_range_commits, worktree_last_commits, -}; -pub use nip34::{ - GraspSignals, Nip34Binding, Nip34Kind, detect_nip34, is_grasp_url, set_nostr_repo, -}; -pub use patch::{ - apply_patch, format_patch_between, patch_commits, patch_diffs, split_patch_series, -}; -pub use remote::{ - clone_repo, ensure_origin, fetch_all, fetch_repo_refs, origin_url, push_all, push_commit_ref, - push_main, remote_has_refs, set_origin, -}; -pub use repo::{ - RepoRefState, commits_since, current_branch, delete_refs_with_prefix, fast_forward_branches, - head_commit_id, init_repository, merge_base, refs_with_prefix, repo_branches, repo_ref_state, - repo_tags, root_commit, worktree_branches, worktree_current_branch, worktree_ref_exists, - worktree_ref_state, -}; +pub use cache::GitCache; +pub use diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff}; +pub use history::{CommitList, FileCommit, MAX_LISTED_COMMITS}; +pub use nip34::{GraspSignals, Nip34Binding, Nip34Kind}; +pub use patch::PatchParser; +pub use repo::{Repo, RepoRefState}; pub use scan::{LocalRepo, find_git_repos}; -pub use worktree::{ - WorktreeSnapshot, find_readme, worktree_checkout_branch, worktree_checkout_tag, - worktree_commits_ahead, worktree_dirty, worktree_entries, worktree_read, worktree_snapshot, -}; +pub use worktree::WorktreeSnapshot; -/// The terminal prompt is disabled so a credential request fails instead of hanging. #[cfg(test)] fn git_in(dir: &std::path::Path, args: &[&str]) -> anyhow::Result { - let output = remote::git_output(dir, args, "git")?; + let output = Repo::run_git(dir, args, "git")?; if !output.status.success() { anyhow::bail!( diff --git a/crates/signed_git/src/nip34.rs b/crates/signed_git/src/nip34.rs index 7c50c13..36aa032 100644 --- a/crates/signed_git/src/nip34.rs +++ b/crates/signed_git/src/nip34.rs @@ -1,9 +1,9 @@ -use std::path::Path; - use anyhow::Result; use gix::bstr::ByteSlice; use nostr::prelude::*; +use crate::repo::Repo; + /// The kind of NIP-34 relationship a local repository has on disk. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Nip34Kind { @@ -53,222 +53,225 @@ struct Nip34Json { owner: Option, } -pub fn detect_nip34(repo_path: &Path) -> Option { - let repo = gix::open(repo_path).ok()?; - let common_dir = repo.common_dir().to_path_buf(); - let workdir = repo.workdir().map(Path::to_path_buf); +impl Repo { + /// What the repository's on-disk state says about its NIP-34 binding. + /// + /// `None` when no Nostr tooling left a marker. + pub fn nip34_binding(&self) -> Option { + let common_dir = self.inner.common_dir().to_path_buf(); + let workdir = self.inner.workdir().map(std::path::Path::to_path_buf); - let mut signals = GraspSignals::default(); - let mut owner: Option = None; - let mut identifier: Option = None; - let mut grasp_urls: Vec = Vec::new(); + let mut signals = GraspSignals::default(); + let mut owner: Option = None; + let mut identifier: Option = None; + let mut grasp_urls: Vec = Vec::new(); - if let Some(workdir) = &workdir { - if let Ok(bytes) = std::fs::read(workdir.join("nip34.json")) - && let Ok(config) = serde_json::from_slice::(&bytes) - { - signals.nip34_json = true; - identifier = config.identifier.and_then(non_empty); - owner = config - .owner - .as_deref() - .and_then(|value| PublicKey::parse(value).ok()); - } + if let Some(workdir) = &workdir { + if let Ok(bytes) = std::fs::read(workdir.join("nip34.json")) + && let Ok(config) = serde_json::from_slice::(&bytes) + { + signals.nip34_json = true; + identifier = config.identifier.and_then(Self::non_empty); + owner = config + .owner + .as_deref() + .and_then(|value| PublicKey::parse(value).ok()); + } - if workdir.join("maintainers.yaml").is_file() { - signals.maintainers_yaml = true; - } - } - - if let Ok(exclude) = std::fs::read_to_string(common_dir.join("info/exclude")) - && exclude.contains("nip34.json") - { - signals.nip34_excluded = true; - } - - // `ngit` keeps its repository event cache in the Git common directory. - if common_dir.join("nostr-cache.lmdb").is_file() { - signals.nostr_cache = true; - } - - // `ngit` reads and writes `nostr.repo` at repository-local scope only. - if let Ok(config) = gix::config::File::from_path_no_includes( - common_dir.join("config"), - gix::config::Source::Local, - ) { - if let Some(value) = config.string("nostr.repo") - && let Some((key, id)) = coordinate_from_naddr(&value.to_str_lossy()) - { - signals.nostr_repo_config = true; - owner = Some(key); - identifier = Some(id); - } - - for key in ["nostr.repo-relay-only", "nostr.nostate", "nostr.private"] { - if config.string(key).is_some() { - signals.nostr_aux_config = true; + if workdir.join("maintainers.yaml").is_file() { + signals.maintainers_yaml = true; } } - if let Some(sections) = config.sections_by_name("remote") { - for section in sections { - let Some(name) = section.header().subsection_name() else { - continue; - }; - let nak_grasp_remote = name.to_str_lossy().starts_with("nip34/grasp/"); + if let Ok(exclude) = std::fs::read_to_string(common_dir.join("info/exclude")) + && exclude.contains("nip34.json") + { + signals.nip34_excluded = true; + } - for url in section.values("url") { - let url = url.to_str_lossy(); + // `ngit` keeps its repository event cache in the Git common directory. + if common_dir.join("nostr-cache.lmdb").is_file() { + signals.nostr_cache = true; + } - if url.starts_with("nostr://") { - signals.nostr_remote = true; - // Strong markers win; only fill an empty binding. - if owner.is_none() - && identifier.is_none() - && let Some((key, id)) = parse_nostr_url(&url) - { - owner = Some(key); - identifier = Some(id); + // `ngit` reads and writes `nostr.repo` at repository-local scope only. + if let Ok(config) = gix::config::File::from_path_no_includes( + common_dir.join("config"), + gix::config::Source::Local, + ) { + if let Some(value) = config.string("nostr.repo") + && let Some((key, id)) = Self::coordinate_from_naddr(&value.to_str_lossy()) + { + signals.nostr_repo_config = true; + owner = Some(key); + identifier = Some(id); + } + + for key in ["nostr.repo-relay-only", "nostr.nostate", "nostr.private"] { + if config.string(key).is_some() { + signals.nostr_aux_config = true; + } + } + + if let Some(sections) = config.sections_by_name("remote") { + for section in sections { + let Some(name) = section.header().subsection_name() else { + continue; + }; + let nak_grasp_remote = name.to_str_lossy().starts_with("nip34/grasp/"); + + for url in section.values("url") { + let url = url.to_str_lossy(); + + if url.starts_with("nostr://") { + signals.nostr_remote = true; + // Strong markers win; only fill an empty binding. + if owner.is_none() + && identifier.is_none() + && let Some((key, id)) = Self::parse_nostr_url(&url) + { + owner = Some(key); + identifier = Some(id); + } } - } - if is_grasp_url(&url) { - signals.grasp_remote = true; - signals.nip34_grasp_remote |= nak_grasp_remote; - grasp_urls.push(url.to_string()); + if Self::is_grasp_url(&url) { + signals.grasp_remote = true; + signals.nip34_grasp_remote |= nak_grasp_remote; + grasp_urls.push(url.to_string()); - if owner.is_none() - && identifier.is_none() - && let Some((key, id)) = grasp_parts(&url) - { - owner = Some(key); - identifier = Some(id); + if owner.is_none() + && identifier.is_none() + && let Some((key, id)) = Self::grasp_parts(&url) + { + owner = Some(key); + identifier = Some(id); + } } } } } } + + // `nak` materializes a kind-30618 state as `refs/heads/nip34/state/*`. + if let Ok(platform) = self.inner.references() + && let Ok(mut refs) = platform.prefixed(b"refs/heads/nip34/state/") + && refs.next().is_some() + { + signals.nip34_state_refs = true; + } + + if !signals.any() { + return None; + } + + let kind = if signals.nip34_json + || signals.nostr_repo_config + || signals.nip34_grasp_remote + || signals.nip34_state_refs + { + Nip34Kind::Initialized + } else if signals.nostr_remote { + Nip34Kind::Cloned + } else { + Nip34Kind::ToolingOnly + }; + + Some(Nip34Binding { + kind, + signals, + owner, + identifier, + grasp_urls, + }) } - // `nak` materializes a kind-30618 state as `refs/heads/nip34/state/*`. - if let Ok(platform) = repo.references() - && let Ok(mut refs) = platform.prefixed(b"refs/heads/nip34/state/") - && refs.next().is_some() - { - signals.nip34_state_refs = true; + /// Record the repository's NIP-34 coordinate in its local `nostr.repo` config. + pub fn set_nostr_repo(&self, naddr: &str) -> Result<()> { + self.edit_local_config(|config| { + config.set_raw_value("nostr.repo", naddr)?; + Ok(()) + }) } - if !signals.any() { - return None; + /// Mirrors `nak`'s `IsGraspURL`: two path segments, a path of at least 65 bytes, + /// and a first segment that decodes as an `npub`. + fn is_grasp_url(url: &str) -> bool { + let Ok(parsed) = Url::parse(url) else { + return false; + }; + + if !matches!(parsed.scheme(), "http" | "https" | "grasp") { + return false; + } + + let path = parsed.path(); + if path.matches('/').count() != 2 || path.len() < 65 { + return false; + } + + Self::grasp_parts(url).is_some() } - let kind = if signals.nip34_json - || signals.nostr_repo_config - || signals.nip34_grasp_remote - || signals.nip34_state_refs - { - Nip34Kind::Initialized - } else if signals.nostr_remote { - Nip34Kind::Cloned - } else { - Nip34Kind::ToolingOnly - }; + fn grasp_parts(url: &str) -> Option<(PublicKey, String)> { + let parsed = Url::parse(url).ok()?; + let mut segments = parsed.path_segments()?.filter(|part| !part.is_empty()); - Some(Nip34Binding { - kind, - signals, - owner, - identifier, - grasp_urls, - }) -} + let owner = PublicKey::parse(segments.next()?).ok()?; + let identifier = Self::non_empty(segments.next()?.trim_end_matches(".git"))?; -/// Record a repository's NIP-34 coordinate in its local `nostr.repo` config. -pub fn set_nostr_repo(repo_path: &Path, naddr: &str) -> Result<()> { - let repo = gix::open(repo_path)?; - - crate::remote::edit_local_config(&repo, |config| { - config.set_raw_value("nostr.repo", naddr)?; - Ok(()) - }) -} - -/// Mirrors `nak`'s `IsGraspURL`: two path segments, a path of at least 65 bytes, -/// and a first segment that decodes as an `npub`. -pub fn is_grasp_url(url: &str) -> bool { - let Ok(parsed) = Url::parse(url) else { - return false; - }; - - if !matches!(parsed.scheme(), "http" | "https" | "grasp") { - return false; + Some((owner, identifier)) } - let path = parsed.path(); - if path.matches('/').count() != 2 || path.len() < 65 { - return false; + fn coordinate_from_naddr(value: &str) -> Option<(PublicKey, String)> { + let coordinate = Nip19Coordinate::from_bech32(value).ok()?; + if coordinate.kind != Kind::GitRepoAnnouncement { + return None; + } + + let identifier = Self::non_empty(coordinate.identifier.clone())?; + Some((coordinate.public_key, identifier)) } - grasp_parts(url).is_some() -} + /// Handles a bare `naddr`, an `npub`, and the optional `[ssh-key-file@]`, + /// `[protocol/]` and `[relay/]` components. An `nip05` owner yields no binding. + fn parse_nostr_url(url: &str) -> Option<(PublicKey, String)> { + let rest = url.strip_prefix("nostr://")?; -fn grasp_parts(url: &str) -> Option<(PublicKey, String)> { - let parsed = Url::parse(url).ok()?; - let mut segments = parsed.path_segments()?.filter(|part| !part.is_empty()); + if rest.starts_with("naddr1") { + return Self::coordinate_from_naddr(rest); + } - let owner = PublicKey::parse(segments.next()?).ok()?; - let identifier = non_empty(segments.next()?.trim_end_matches(".git"))?; + let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after); + let mut parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect(); - Some((owner, identifier)) -} + if parts + .first() + .is_some_and(|first| matches!(*first, "ssh" | "https" | "http")) + { + parts.remove(0); + } -fn coordinate_from_naddr(value: &str) -> Option<(PublicKey, String)> { - let coordinate = Nip19Coordinate::from_bech32(value).ok()?; - if coordinate.kind != Kind::GitRepoAnnouncement { - return None; + // `[owner, (relay), identifier]`. + if parts.len() < 2 { + return None; + } + + let owner = PublicKey::parse(parts[0]).ok()?; + let identifier = Self::non_empty(parts.last()?.trim_end_matches(".git"))?; + + Some((owner, identifier)) } - let identifier = non_empty(coordinate.identifier.clone())?; - Some((coordinate.public_key, identifier)) -} - -/// Handles a bare `naddr`, an `npub`, and the optional `[ssh-key-file@]`, -/// `[protocol/]` and `[relay/]` components. An `nip05` owner yields no binding. -fn parse_nostr_url(url: &str) -> Option<(PublicKey, String)> { - let rest = url.strip_prefix("nostr://")?; - - if rest.starts_with("naddr1") { - return coordinate_from_naddr(rest); + fn non_empty(value: impl Into) -> Option { + let value = value.into(); + (!value.is_empty()).then_some(value) } - - let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after); - let mut parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect(); - - if parts - .first() - .is_some_and(|first| matches!(*first, "ssh" | "https" | "http")) - { - parts.remove(0); - } - - // `[owner, (relay), identifier]`. - if parts.len() < 2 { - return None; - } - - let owner = PublicKey::parse(parts[0]).ok()?; - let identifier = non_empty(parts.last()?.trim_end_matches(".git"))?; - - Some((owner, identifier)) -} - -fn non_empty(value: impl Into) -> Option { - let value = value.into(); - (!value.is_empty()).then_some(value) } #[cfg(test)] mod tests { + use std::path::Path; use std::process::Command; use super::*; @@ -306,10 +309,14 @@ mod tests { .expect("naddr") } + fn binding_of(path: &Path) -> Option { + Repo::open(path).ok()?.nip34_binding() + } + #[test] fn plain_repository_has_no_binding() { let (_dir, path) = init_repo(); - assert!(detect_nip34(&path).is_none()); + assert!(binding_of(&path).is_none()); } #[test] @@ -323,7 +330,7 @@ mod tests { ) .expect("write"); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Initialized); assert!(binding.signals.nip34_json); assert_eq!(binding.owner, Some(owner)); @@ -335,7 +342,7 @@ mod tests { let (_dir, path) = init_repo(); std::fs::write(path.join("nip34.json"), b"not json").expect("write"); - assert!(detect_nip34(&path).is_none()); + assert!(binding_of(&path).is_none()); } #[test] @@ -351,7 +358,7 @@ mod tests { &["update-ref", "refs/heads/nip34/state/HEAD", "HEAD"], ); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Initialized); assert!(binding.signals.nip34_excluded); assert!(binding.signals.nip34_state_refs); @@ -364,7 +371,7 @@ mod tests { let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo"); git(&path, &["config", "nostr.repo", &naddr]); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Initialized); assert!(binding.signals.nostr_repo_config); assert_eq!(binding.owner, Some(owner)); @@ -377,9 +384,12 @@ mod tests { let owner = key(); let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo"); - set_nostr_repo(&path, &naddr).expect("write marker"); + Repo::open(&path) + .expect("open") + .set_nostr_repo(&naddr) + .expect("write marker"); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Initialized); assert!(binding.signals.nostr_repo_config); assert_eq!(binding.owner, Some(owner)); @@ -394,7 +404,7 @@ mod tests { let url = format!("nostr://{npub}/relay.ngit.dev/my-repo"); git(&path, &["remote", "add", "origin", &url]); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Cloned); assert!(binding.signals.nostr_remote); assert_eq!(binding.owner, Some(owner)); @@ -412,7 +422,7 @@ mod tests { &["config", "remote.nip34/grasp/gitnostr.com.url", &url], ); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Initialized); assert!(binding.signals.nip34_grasp_remote); assert!(binding.signals.grasp_remote); @@ -426,7 +436,7 @@ mod tests { let (_dir, path) = init_repo(); std::fs::write(path.join(".git/nostr-cache.lmdb"), b"cache").expect("write"); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::ToolingOnly); assert!(binding.signals.nostr_cache); } @@ -436,18 +446,18 @@ mod tests { let owner = key(); let npub = owner.to_bech32().expect("npub"); - assert!(is_grasp_url(&format!( + assert!(Repo::is_grasp_url(&format!( "https://gitnostr.com/{npub}/my-repo.git" ))); - assert!(is_grasp_url(&format!( + assert!(Repo::is_grasp_url(&format!( "grasp://gitnostr.com/{npub}/my-repo.git" ))); - assert!(!is_grasp_url("https://gitnostr.com/my-repo.git")); - assert!(!is_grasp_url( + assert!(!Repo::is_grasp_url("https://gitnostr.com/my-repo.git")); + assert!(!Repo::is_grasp_url( "https://gitnostr.com/not-a-pubkey/my-repo.git" )); - assert!(!is_grasp_url(&format!( + assert!(!Repo::is_grasp_url(&format!( "ssh://gitnostr.com/{npub}/my-repo.git" ))); } diff --git a/crates/signed_git/src/patch.rs b/crates/signed_git/src/patch.rs index cf67bdc..05a1216 100644 --- a/crates/signed_git/src/patch.rs +++ b/crates/signed_git/src/patch.rs @@ -1,6 +1,5 @@ use std::io::Write; -use std::path::Path; -use std::process::{Command, Stdio}; +use std::process::Stdio; use anyhow::{Context, Result, bail}; use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet}; @@ -8,315 +7,372 @@ use diffy::{Hunk, Line}; use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff}; use crate::history::FileCommit; +use crate::repo::Repo; -/// Apply a `git format-patch` patch or series with `git am`. -/// -/// Uses the git CLI because it handles the mbox format natively. -/// -/// TODO: replace with a pure-Rust implementation later without changing callers. -pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> { - let mut child = Command::new("git") - .arg("am") - .current_dir(repo_path) - .stdin(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .context("failed to spawn `git am`")?; +/// Pure parsing of `git format-patch` output, no repository involved. +pub struct PatchParser; - child - .stdin - .as_mut() - .expect("stdin piped") - .write_all(patch.as_bytes())?; - - let output = child.wait_with_output()?; - - if !output.status.success() { - bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr)); +impl PatchParser { + /// Split a `git format-patch` series into its individual patches, mbox messages. + /// + /// A single patch yields one element. + /// A malformed input yields one element covering it. + pub fn split_patch_series(patch: &str) -> Vec<&str> { + Self::envelopes(patch) + .into_iter() + .map(|message| message.text) + .collect() } - Ok(()) -} - -/// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`. -/// Fails when the range has no commits. -/// -/// The mbox is returned untrimmed. Trailing newlines are part of the format. -pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result { - let output = Command::new("git") - .arg("-C") - .arg(repo_path) - .args(["format-patch", "--stdout", &format!("{base}..{tip}")]) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .context("failed to spawn `git format-patch`")?; - - if !output.status.success() { - bail!( - "git format-patch failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - - let patch = String::from_utf8_lossy(&output.stdout).into_owned(); - - if patch.trim().is_empty() { - bail!("no commits between {base} and {tip}"); - } - - Ok(patch) -} - -/// Split a `git format-patch` series into its individual patches, mbox messages. -/// -/// A single patch yields one element. -/// A malformed input yields one element covering it. -pub fn split_patch_series(patch: &str) -> Vec<&str> { - let mut starts = vec![0usize]; - let mut search_from = 1; - - while let Some(rel) = patch[search_from..].find("\nFrom ") { - let ix = search_from + rel + 1; - let hex = patch[ix + 5..] - .split(|c: char| !c.is_ascii_hexdigit()) - .next() - .unwrap_or(""); - if hex.len() == 40 { - starts.push(ix); + /// Parse `git format-patch` output, a single patch or a series. + /// + /// Backed by [`diffy::patch_set`], which implements git's extended diff format: + /// `diff --git` headers, rename and copy detection, binary detection, and + /// C-style quoted or octal-escaped paths. + pub fn patch_diffs(patch: &str) -> Result { + if !patch.lines().any(|line| line.starts_with("diff --git ")) { + return Ok(CommitDiff { files: Vec::new() }); } - search_from = ix + 1; + + let mut files = Vec::new(); + + for file in PatchSet::parse(patch, ParseOptions::gitdiff()) { + files.push(Self::file_diff(file?)?); + } + + Ok(CommitDiff { files }) } - starts - .iter() - .enumerate() - .map(|(i, &start)| { - let end = starts.get(i + 1).copied().unwrap_or(patch.len()); - &patch[start..end] + /// Commits of a `git format-patch` output, a single patch or a series. + /// + /// Entries appear in patch order, oldest first as `git format-patch` produces them. + pub fn patch_commits(patch: &str) -> Vec { + Self::envelopes(patch) + .into_iter() + .filter(|message| !message.id.is_empty()) + .map(|message| FileCommit { + id: message.id.to_string(), + summary: message + .header("Subject") + .map(Self::strip_patch_prefix) + .unwrap_or_default(), + description: None, + author: message + .header("From") + .map(Self::name_from_address) + .unwrap_or_default(), + time: message + .header("Date") + .and_then(|value| gix::date::parse(value.trim(), None).ok()) + .map(|time| time.seconds) + .unwrap_or(0), + }) + .collect() + } + + /// Splits a `git format-patch` mbox into its messages by their envelopes, + /// the one parser [`Self::split_patch_series`] and [`Self::patch_commits`] share. + /// + /// A malformed input yields one message covering the whole input. + fn envelopes(patch: &str) -> Vec> { + let mut messages: Vec> = Vec::new(); + // Byte offset of the current envelope line, its commit id, and its headers. + let mut current: Option<(usize, &str, Vec<&str>)> = None; + // Headers run up to the blank line before the commit message. + let mut headers_closed = false; + + let mut offset = 0usize; + for line in patch.lines() { + let line_start = offset; + offset += line.len() + 1; + + // A message starts at its `From ` envelope line. + let is_envelope = line + .strip_prefix("From ") + .and_then(|rest| rest.split_whitespace().next()) + .is_some_and(|id| id.len() == 40); + + if is_envelope { + if let Some((start, id, headers)) = current.take() { + messages.push(Envelope { + text: &patch[start..], + id, + headers, + }); + } + + let id = line + .strip_prefix("From ") + .and_then(|rest| rest.split_whitespace().next()) + .unwrap_or(""); + + current = Some((line_start, id, Vec::new())); + headers_closed = false; + continue; + } + + if let Some((_, _, headers)) = &mut current { + if headers_closed { + continue; + } + if line.is_empty() { + headers_closed = true; + } else { + headers.push(line); + } + } + } + + if let Some((start, id, headers)) = current.take() { + messages.push(Envelope { + text: &patch[start..], + id, + headers, + }); + } + + if messages.is_empty() { + // Not an mbox at all: one message covering the whole input. + messages.push(Envelope { + text: patch, + id: "", + headers: Vec::new(), + }); + } + + messages + } + + fn name_from_address(from: &str) -> String { + match from.trim().find('<') { + Some(ix) => from[..ix].trim().to_string(), + None => from.trim().to_string(), + } + } + + /// Strip the patch prefix from a `Subject:` header. + /// + /// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`. + fn strip_patch_prefix(subject: &str) -> String { + let trimmed = subject.trim(); + let Some(rest) = trimmed.strip_prefix('[') else { + return trimmed.to_string(); + }; + let Some(end) = rest.find(']') else { + return trimmed.to_string(); + }; + if rest[..end].to_ascii_lowercase().contains("patch") { + rest[end + 1..].trim().to_string() + } else { + trimmed.to_string() + } + } + + fn file_diff(file: FilePatch<'_, str>) -> Result { + // The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path + // component is dropped, the same way `git apply -p1` does. + // Rename and copy paths come from their own headers, unprefixed. + let stripped; + let operation = match file.operation() { + operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation, + operation => { + stripped = operation.strip_prefix(1); + &stripped + } + }; + + let (path, old_path, status) = match operation { + FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added), + FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted), + FileOperation::Modify { modified, .. } => { + (modified.as_ref(), None, DiffStatus::Modified) + } + FileOperation::Rename { from, to } => { + (to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed) + } + FileOperation::Copy { from, to } => { + (to.as_ref(), Some(from.as_ref()), DiffStatus::Copied) + } + }; + + let mut insertions = 0usize; + let mut deletions = 0usize; + let mut hunks = Vec::new(); + + let patch = file.patch(); + + if let Some(text) = patch.as_text() { + for hunk in text.hunks() { + let hunk = Self::hunk_diff(hunk); + insertions += hunk + .lines + .iter() + .filter(|line| line.kind == DiffLineKind::Addition) + .count(); + deletions += hunk + .lines + .iter() + .filter(|line| line.kind == DiffLineKind::Deletion) + .count(); + hunks.push(hunk); + } + } + + Ok(FileDiff { + path: path.to_owned(), + old_path: old_path.map(str::to_owned), + status, + insertions, + deletions, + binary: patch.is_binary(), + hunks, }) - .collect() -} - -/// Parse `git format-patch` output, a single patch or a series. -/// -/// Backed by [`diffy::patch_set`], which implements git's extended diff format: -/// `diff --git` headers, rename and copy detection, binary detection, and -/// C-style quoted or octal-escaped paths. -pub fn patch_diffs(patch: &str) -> Result { - if !patch.lines().any(|line| line.starts_with("diff --git ")) { - return Ok(CommitDiff { files: Vec::new() }); } - let mut files = Vec::new(); + /// The [`DiffHunk`] of one parsed hunk, including the line number of every line. + /// + /// `diffy` reports only the hunk header ranges. The per-line numbers are + /// counted from them the way the header encodes them: context lines advance + /// both sides, deletions only the old, insertions only the new. + fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk { + let old_range = hunk.old_range(); + let new_range = hunk.new_range(); - for file in PatchSet::parse(patch, ParseOptions::gitdiff()) { - files.push(file_diff(file?)?); - } + let mut old = old_range.start() as u32; + let mut new = new_range.start() as u32; + let mut lines = Vec::with_capacity(hunk.lines().len()); - Ok(CommitDiff { files }) -} + for line in hunk.lines() { + let (kind, text) = match line { + Line::Context(text) => (DiffLineKind::Context, *text), + Line::Delete(text) => (DiffLineKind::Deletion, *text), + Line::Insert(text) => (DiffLineKind::Addition, *text), + }; -fn file_diff(file: FilePatch<'_, str>) -> Result { - // The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path - // component is dropped, the same way `git apply -p1` does. - // Rename and copy paths come from their own headers, unprefixed. - let stripped; - let operation = match file.operation() { - operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation, - operation => { - stripped = operation.strip_prefix(1); - &stripped + let (old_no, new_no) = match kind { + DiffLineKind::Context => { + let numbers = (Some(old), Some(new)); + old += 1; + new += 1; + numbers + } + DiffLineKind::Addition => { + let number = Some(new); + new += 1; + (None, number) + } + DiffLineKind::Deletion => { + let number = Some(old); + old += 1; + (number, None) + } + }; + + lines.push(DiffLine { + kind, + old: old_no, + new: new_no, + text: Self::line_text(text), + }); } - }; - let (path, old_path, status) = match operation { - FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added), - FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted), - FileOperation::Modify { modified, .. } => (modified.as_ref(), None, DiffStatus::Modified), - FileOperation::Rename { from, to } => { - (to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed) - } - FileOperation::Copy { from, to } => (to.as_ref(), Some(from.as_ref()), DiffStatus::Copied), - }; - - let mut insertions = 0usize; - let mut deletions = 0usize; - let mut hunks = Vec::new(); - - let patch = file.patch(); - - if let Some(text) = patch.as_text() { - for hunk in text.hunks() { - let hunk = hunk_diff(hunk); - insertions += hunk - .lines - .iter() - .filter(|line| line.kind == DiffLineKind::Addition) - .count(); - deletions += hunk - .lines - .iter() - .filter(|line| line.kind == DiffLineKind::Deletion) - .count(); - hunks.push(hunk); + DiffHunk { + old_start: old_range.start() as u32, + old_lines: old_range.len() as u32, + new_start: new_range.start() as u32, + new_lines: new_range.len() as u32, + lines, } } - Ok(FileDiff { - path: path.to_owned(), - old_path: old_path.map(str::to_owned), - status, - insertions, - deletions, - binary: patch.is_binary(), - hunks, - }) -} - -/// The [`DiffHunk`] of one parsed hunk, including the line number of every line. -/// -/// `diffy` reports only the hunk header ranges. The per-line numbers are -/// counted from them the way the header encodes them: context lines advance -/// both sides, deletions only the old, insertions only the new. -fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk { - let old_range = hunk.old_range(); - let new_range = hunk.new_range(); - - let mut old = old_range.start() as u32; - let mut new = new_range.start() as u32; - let mut lines = Vec::with_capacity(hunk.lines().len()); - - for line in hunk.lines() { - let (kind, text) = match line { - Line::Context(text) => (DiffLineKind::Context, *text), - Line::Delete(text) => (DiffLineKind::Deletion, *text), - Line::Insert(text) => (DiffLineKind::Addition, *text), - }; - - let (old_no, new_no) = match kind { - DiffLineKind::Context => { - let numbers = (Some(old), Some(new)); - old += 1; - new += 1; - numbers - } - DiffLineKind::Addition => { - let number = Some(new); - new += 1; - (None, number) - } - DiffLineKind::Deletion => { - let number = Some(old); - old += 1; - (number, None) - } - }; - - lines.push(DiffLine { - kind, - old: old_no, - new: new_no, - text: line_text(text), - }); - } - - DiffHunk { - old_start: old_range.start() as u32, - old_lines: old_range.len() as u32, - new_start: new_range.start() as u32, - new_lines: new_range.len() as u32, - lines, + /// The content of a parsed line without its line ending. + /// + /// `diffy` keeps the trailing `\n`, the way `str::lines` splits it off. + fn line_text(text: &str) -> String { + let text = text.strip_suffix('\n').unwrap_or(text); + text.strip_suffix('\r').unwrap_or(text).to_owned() } } -/// The content of a parsed line without its line ending. -/// -/// `diffy` keeps the trailing `\n`, the way `str::lines` splits it off. -fn line_text(text: &str) -> String { - let text = text.strip_suffix('\n').unwrap_or(text); - text.strip_suffix('\r').unwrap_or(text).to_owned() +/// A `git format-patch` mbox message, split on its `From <40-hex> ` envelope. +struct Envelope<'a> { + /// The whole message, envelope and diff. + text: &'a str, + /// Commit id from the `From ` line. + id: &'a str, + /// Header lines between the envelope and the commit message. + headers: Vec<&'a str>, } -/// Commits of a `git format-patch` output, a single patch or a series. -/// -/// Entries appear in patch order, oldest first as `git format-patch` produces them. -pub fn patch_commits(patch: &str) -> Vec { - let lines: Vec<&str> = patch.lines().collect(); +impl Envelope<'_> { + fn header(&self, name: &str) -> Option<&str> { + let prefix = format!("{name}: "); + self.headers + .iter() + .find(|line| line.starts_with(&prefix)) + .map(|line| &line[prefix.len()..]) + } +} - let mut commits = Vec::new(); - let mut i = 0; +impl Repo { + /// Apply a `git format-patch` patch or series with `git am`. + /// + /// Uses the git CLI because it handles the mbox format natively. + /// + /// TODO: replace with a pure-Rust implementation later without changing callers. + pub fn apply_patch(&self, patch: &str) -> Result<()> { + let workdir = self + .inner + .workdir() + .context("repository has no worktree")? + .to_path_buf(); - while i < lines.len() { - // A patch starts with its `From ` envelope line. - let Some(rest) = lines[i].strip_prefix("From ") else { - i += 1; - continue; - }; + let mut child = Repo::git_command(&workdir) + .args(["am"]) + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("failed to spawn `git am`")?; - let Some(id) = rest.split_whitespace().next() else { - i += 1; - continue; - }; + child + .stdin + .as_mut() + .context("git am has no stdin pipe")? + .write_all(patch.as_bytes())?; - if id.len() != 40 { - i += 1; - continue; + let output = child.wait_with_output()?; + + if !output.status.success() { + bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr)); } - let mut author = String::new(); - let mut summary = String::new(); - let mut time = 0i64; + Ok(()) + } - // Envelope headers run up to the blank line before the commit message. - i += 1; - while i < lines.len() && !lines[i].is_empty() { - let header = lines[i]; - if let Some(value) = header.strip_prefix("From: ") { - author = name_from_address(value); - } else if let Some(value) = header.strip_prefix("Subject: ") { - summary = strip_patch_prefix(value); - } else if let Some(value) = header.strip_prefix("Date: ") { - time = gix::date::parse(value.trim(), None) - .map(|t| t.seconds) - .unwrap_or(0); - } - i += 1; + /// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`. + /// Fails when the range has no commits. + /// + /// The mbox is returned untrimmed. Trailing newlines are part of the format. + pub fn format_patch_between(&self, base: &str, tip: &str) -> Result { + let output = Repo::run_git( + self.workdir_or_dot(), + &["format-patch", "--stdout", &format!("{base}..{tip}")], + "git format-patch", + )?; + + if !output.status.success() { + bail!( + "git format-patch failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); } - commits.push(FileCommit { - id: id.to_string(), - summary, - description: None, - author, - time, - }); - } + let patch = String::from_utf8_lossy(&output.stdout).into_owned(); - commits -} + if patch.trim().is_empty() { + bail!("no commits between {base} and {tip}"); + } -fn name_from_address(from: &str) -> String { - match from.trim().find('<') { - Some(ix) => from[..ix].trim().to_string(), - None => from.trim().to_string(), - } -} - -/// Strip the patch prefix from a `Subject:` header. -/// -/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`. -fn strip_patch_prefix(subject: &str) -> String { - let trimmed = subject.trim(); - let Some(rest) = trimmed.strip_prefix('[') else { - return trimmed.to_string(); - }; - let Some(end) = rest.find(']') else { - return trimmed.to_string(); - }; - if rest[..end].to_ascii_lowercase().contains("patch") { - rest[end + 1..].trim().to_string() - } else { - trimmed.to_string() + Ok(patch) } } diff --git a/crates/signed_git/src/remote.rs b/crates/signed_git/src/remote.rs index 78d2452..3c78ffe 100644 --- a/crates/signed_git/src/remote.rs +++ b/crates/signed_git/src/remote.rs @@ -6,346 +6,304 @@ use anyhow::{Context, Result, bail}; use gix::interrupt::IS_INTERRUPTED; use gix::progress::Discard; -/// Clone into `path` from the first working URL in `clone_urls`. -/// -/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache. -pub fn clone_repo>(clone_urls: &[U], path: &Path) -> Result<()> { - if path.exists() { - bail!("destination {} already exists", path.display()); - } +use crate::repo::Repo; - try_each_url(clone_urls, "clone", |url| { - let repo = clone(url, path)?; - // The initial clone uses the default refspecs. Also fetch the `refs/nostr/*` PR refs. - fetch_all(&repo).ok(); - Ok(()) - }) -} - -/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace. -pub fn fetch_all(repo: &gix::Repository) -> Result<()> { - let options = gix::remote::ref_map::Options { - extra_refspecs: vec![ - gix::refspec::parse( - gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"), - gix::refspec::parse::Operation::Fetch, - )? - .to_owned(), - ], - ..Default::default() - }; - repo.find_remote("origin")? - .connect(gix::remote::Direction::Fetch)? - .prepare_fetch(Discard, options)? - .receive(Discard, &IS_INTERRUPTED)?; - Ok(()) -} - -pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> { - let output = Command::new("git") - .arg("-C") - .arg(repo_path) - .args(["push"]) - .arg(url) - .arg(format!("{commit}:{reference}")) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .context("failed to spawn `git push`")?; - - if !output.status.success() { - bail!( - "git push failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(()) -} - -/// Rewrite a grasp server URL to the https URL the git transport actually uses. -/// -/// GRASP servers announce `grasp:////` clone URLs. -/// The transport is git smart HTTP, so the scheme is rewritten for gix. -fn transport_url(url: &str) -> String { - url.strip_prefix("grasp://") - .map(|rest| format!("https://{rest}")) - .unwrap_or_else(|| url.to_owned()) -} - -/// Run `attempt` against each URL in `urls` until one succeeds. -/// -/// Returns the last error wrapped in `failed to {verb} from any mirror`, -/// or `no clone URLs provided` when the list is empty. -fn try_each_url, F>(urls: &[U], verb: &str, mut attempt: F) -> Result<()> -where - F: FnMut(&str) -> Result<()>, -{ - let mut last_err: Option = None; - - for url in urls { - match attempt(url.as_ref()) { - Ok(()) => return Ok(()), - Err(e) => last_err = Some(e), - } - } - - match last_err { - Some(e) => Err(e).context(format!("failed to {verb} from any mirror")), - None => bail!("no clone URLs provided"), - } -} - -fn clone(url: &str, path: &Path) -> Result { - let url = transport_url(url); - let url = gix::url::parse(url).context("invalid clone URL")?; - - let mut prepare = gix::prepare_clone(url, path)?; - let (mut checkout, _fetch) = prepare.fetch_then_checkout(Discard, &IS_INTERRUPTED)?; - let (repo, _checkout) = checkout.main_worktree(Discard, &IS_INTERRUPTED)?; - - Ok(repo) -} - -pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { - push_refspecs( - repo_path, - base_url, - owner, - repo_id, - &["refs/heads/main:refs/heads/main"], - ) -} - -/// Push every local branch and tag of the repository at `repo_path` to a grasp server. -/// -/// This mirrors an initialized repository's whole history. -pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { - push_refspecs( - repo_path, - base_url, - owner, - repo_id, - &["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], - ) -} - -fn push_refspecs( - repo_path: &Path, - base_url: &str, - owner: &str, - repo_id: &str, - refspecs: &[&str], -) -> Result<()> { - let url = format!("{base_url}/{owner}/{repo_id}.git"); - - let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2); - args.push("push"); - args.push(&url); - args.extend_from_slice(refspecs); - - let output = git_output(repo_path, &args, "git push")?; - - if !output.status.success() { - bail!( - "git push to {base_url} failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(()) -} - -/// Whether `url` advertises every ref in `expected` at the given commit. -/// -/// Extra advertised refs are ignored: the question is whether the data this -/// push wanted to land is already there, not whether the remote is an exact mirror. -/// This is the convergence probe for a push that lost the compare-and-swap race -/// to the grasp server's own background ref alignment. -pub fn remote_has_refs(repo_path: &Path, url: &str, expected: &[(String, String)]) -> Result { - if expected.is_empty() { - return Ok(true); - } - - let repo = gix::open(repo_path)?; - let url = transport_url(url); - - // A URL-created remote has no configured fetch refspecs, and `ref_map` only - // keeps refs that match one. Match each expected ref by its exact name, - // like `git ls-remote ` would; ref maps never write to the repository. - let refspecs = expected - .iter() - .map(|(name, _)| { - gix::refspec::parse( - gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()), - gix::refspec::parse::Operation::Fetch, - ) - .map(|spec| spec.to_owned()) - }) - .collect::, _>>() - .context("invalid refspec")?; - - let options = gix::remote::ref_map::Options { - extra_refspecs: refspecs, - ..Default::default() - }; - - let (refs, _) = repo - .remote_at(url.as_str()) - .with_context(|| format!("cannot use remote {url}"))? - .connect(gix::remote::Direction::Fetch) - .with_context(|| format!("cannot connect to {url}"))? - .ref_map(Discard, options) - .with_context(|| format!("listing refs of {url} failed"))?; - - // Peeled tag entries carry the tag object in their direct oid, so mapping - // each advertised ref to its direct oid matches `git ls-remote` while - // skipping the duplicated `^{}` lines. - let advertised: HashMap = refs - .remote_refs - .iter() - .filter_map(|reference| { - let (name, object, _peeled) = reference.unpack(); - object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string())) - }) - .collect(); - - Ok(expected - .iter() - .all(|(name, oid)| advertised.get(name.as_str()) == Some(oid))) -} - -/// Add `origin` pointing at `url` when the repository has no remote yet. -/// -/// No-op if `origin` already exists. -pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> { - let repo = gix::open(repo_path)?; - if repo.find_remote("origin").is_ok() { - return Ok(()); - } - - // `git remote add` also configures the default fetch refspec. - edit_local_config(&repo, |config| { - config.set_raw_value("remote.origin.url", url)?; - config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; - Ok(()) - }) -} - -/// Point `origin` at `url`, replacing an existing remote, -/// used after a clone whose `origin` points at the cloned-from path. -/// -/// A working copy cloned from a local mirror is re-targeted at the grasp server. -pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> { - let repo = gix::open(repo_path)?; - let had_origin = repo.find_remote("origin").is_ok(); - - edit_local_config(&repo, |config| { - // Replaces the existing url, like `git remote set-url origin `. - // A pre-existing fetch refspec is left untouched. - config.set_raw_value("remote.origin.url", url)?; - - if !had_origin { - config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; - } - - Ok(()) - }) -} - -/// Apply `edit` to the repository-local configuration and persist it. -pub(crate) fn edit_local_config( - repo: &gix::Repository, - edit: impl FnOnce(&mut gix::config::File) -> Result<()>, -) -> Result<()> { - let config_path = repo.common_dir().join("config"); - - let mut lock = gix::lock::File::acquire_to_update_resource( - &config_path, - gix::lock::acquire::Fail::Immediately, - None, - ) - .context("failed to lock repository config")?; - - let mut config = - match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local) { - Ok(config) => config, - // A repository without a config file yet starts from scratch. - Err(gix::config::file::init::from_paths::Error::Io { source, .. }) - if source.kind() == std::io::ErrorKind::NotFound => - { - gix::config::File::default() - } - Err(error) => return Err(error).context("failed to read repository config"), - }; - - edit(&mut config)?; - - config - .write_to(&mut lock) - .context("failed to write repository config")?; - - lock.commit().context("failed to save repository config")?; - - Ok(()) -} - -/// Fetch `refspec` into `repo_path` from the first working URL in `urls`. -/// When no URL works, the last error is returned. -/// -/// Never touches the checked-out refs or the worktree. -pub fn fetch_repo_refs>(repo_path: &Path, urls: &[U], refspec: &str) -> Result<()> { - let repo = gix::open(repo_path)?; - let refspec = gix::refspec::parse( - gix::bstr::BStr::new(refspec), - gix::refspec::parse::Operation::Fetch, - ) - .context("invalid fetch refspec")? - .to_owned(); - - try_each_url(urls, "fetch", |url| { - let url = transport_url(url); +impl Repo { + /// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace. + pub fn fetch(&self) -> Result<()> { let options = gix::remote::ref_map::Options { - extra_refspecs: vec![refspec.clone()], + extra_refspecs: vec![ + gix::refspec::parse( + gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"), + gix::refspec::parse::Operation::Fetch, + )? + .to_owned(), + ], ..Default::default() }; - repo.remote_at(url.as_str()) - .with_context(|| format!("fetch from {url} failed"))? - .connect(gix::remote::Direction::Fetch) - .with_context(|| format!("fetch from {url} failed"))? - .prepare_fetch(Discard, options) - .with_context(|| format!("fetch from {url} failed"))? - .receive(Discard, &IS_INTERRUPTED) - .with_context(|| format!("fetch from {url} failed"))?; + self.inner + .find_remote("origin")? + .connect(gix::remote::Direction::Fetch)? + .prepare_fetch(Discard, options)? + .receive(Discard, &IS_INTERRUPTED)?; Ok(()) - }) -} - -/// The URL of the `origin` remote of the repository at `workdir`. -/// -/// `None` when it has no `origin` yet. -pub fn origin_url(workdir: &Path) -> Result> { - let Ok(repo) = gix::open(workdir) else { - return Ok(None); - }; - - let Ok(remote) = repo.find_remote("origin") else { - return Ok(None); - }; - - Ok(remote - .url(gix::remote::Direction::Fetch) - .map(|url| url.to_string())) -} - -/// Run `git -C dir args`, disabling the terminal prompt and capturing stderr. -/// -/// `what` names the command in the spawn error. -pub(crate) fn git_output(dir: &Path, args: &[&str], what: &str) -> Result { - Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .with_context(|| format!("failed to spawn `{what}`")) + } + + /// Push `commit` to `reference` at `url`. + pub fn push_ref(&self, url: &str, commit: &str, reference: &str) -> Result<()> { + let output = Self::run_git( + self.workdir_or_dot(), + &["push", url, &format!("{commit}:{reference}")], + "git push", + )?; + + if !output.status.success() { + bail!( + "git push failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) + } + + /// Push the local `main` branch to a grasp server. + pub fn push_main(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { + self.push_refspecs( + base_url, + owner, + repo_id, + &["refs/heads/main:refs/heads/main"], + ) + } + + /// Push every local branch and tag to a grasp server. + /// + /// This mirrors an initialized repository's whole history. + pub fn push_all(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { + self.push_refspecs( + base_url, + owner, + repo_id, + &["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], + ) + } + + fn push_refspecs( + &self, + base_url: &str, + owner: &str, + repo_id: &str, + refspecs: &[&str], + ) -> Result<()> { + let url = format!("{base_url}/{owner}/{repo_id}.git"); + + let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2); + args.push("push"); + args.push(&url); + args.extend_from_slice(refspecs); + + let output = Self::run_git(self.workdir_or_dot(), &args, "git push")?; + + if !output.status.success() { + bail!( + "git push to {base_url} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) + } + + /// Whether `url` advertises every ref in `expected` at the given commit. + /// + /// Extra advertised refs are ignored: the question is whether the data this + /// push wanted to land is already there, not whether the remote is an exact mirror. + /// This is the convergence probe for a push that lost the compare-and-swap race + /// to the grasp server's own background ref alignment. + pub fn remote_has_refs(&self, url: &str, expected: &[(String, String)]) -> Result { + if expected.is_empty() { + return Ok(true); + } + + let url = Self::transport_url(url); + + // A URL-created remote has no configured fetch refspecs, and `ref_map` only + // keeps refs that match one. Match each expected ref by its exact name, + // like `git ls-remote ` would; ref maps never write to the repository. + let refspecs = expected + .iter() + .map(|(name, _)| { + gix::refspec::parse( + gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()), + gix::refspec::parse::Operation::Fetch, + ) + .map(|spec| spec.to_owned()) + }) + .collect::, _>>() + .context("invalid refspec")?; + + let options = gix::remote::ref_map::Options { + extra_refspecs: refspecs, + ..Default::default() + }; + + let (refs, _) = self + .inner + .remote_at(url.as_str()) + .with_context(|| format!("cannot use remote {url}"))? + .connect(gix::remote::Direction::Fetch) + .with_context(|| format!("cannot connect to {url}"))? + .ref_map(Discard, options) + .with_context(|| format!("listing refs of {url} failed"))?; + + // Peeled tag entries carry the tag object in their direct oid, so mapping + // each advertised ref to its direct oid matches `git ls-remote` while + // skipping the duplicated `^{}` lines. + let advertised: HashMap = refs + .remote_refs + .iter() + .filter_map(|reference| { + let (name, object, _peeled) = reference.unpack(); + object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string())) + }) + .collect(); + + Ok(expected + .iter() + .all(|(name, oid)| advertised.get(name.as_str()) == Some(oid))) + } + + /// Add `origin` pointing at `url` when the repository has no remote yet. + /// + /// No-op if `origin` already exists. + pub fn ensure_origin(&self, url: &str) -> Result<()> { + if self.inner.find_remote("origin").is_ok() { + return Ok(()); + } + + // `git remote add` also configures the default fetch refspec. + self.edit_local_config(|config| { + config.set_raw_value("remote.origin.url", url)?; + config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; + Ok(()) + }) + } + + /// Point `origin` at `url`, replacing an existing remote, + /// used after a clone whose `origin` points at the cloned-from path. + /// + /// A working copy cloned from a local mirror is re-targeted at the grasp server. + pub fn set_origin(&self, url: &str) -> Result<()> { + let had_origin = self.inner.find_remote("origin").is_ok(); + + self.edit_local_config(|config| { + // Replaces the existing url, like `git remote set-url origin `. + // A pre-existing fetch refspec is left untouched. + config.set_raw_value("remote.origin.url", url)?; + + if !had_origin { + config + .set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; + } + + Ok(()) + }) + } + + /// The URL of the `origin` remote. + /// + /// `None` when the repository has no `origin` yet. + pub fn origin_url(&self) -> Result> { + let Ok(remote) = self.inner.find_remote("origin") else { + return Ok(None); + }; + + Ok(remote + .url(gix::remote::Direction::Fetch) + .map(|url| url.to_string())) + } + + /// Fetch `refspec` from the first working URL in `urls`. + /// When no URL works, the last error is returned. + /// + /// Never touches the checked-out refs or the worktree. + pub fn fetch_refs>(&self, urls: &[U], refspec: &str) -> Result<()> { + let refspec = gix::refspec::parse( + gix::bstr::BStr::new(refspec), + gix::refspec::parse::Operation::Fetch, + ) + .context("invalid fetch refspec")? + .to_owned(); + + let mut last_error = None; + + for url in urls { + let url = Self::transport_url(url.as_ref()); + let outcome = (|| -> Result<()> { + let options = gix::remote::ref_map::Options { + extra_refspecs: vec![refspec.clone()], + ..Default::default() + }; + self.inner + .remote_at(url.as_str()) + .with_context(|| format!("fetch from {url} failed"))? + .connect(gix::remote::Direction::Fetch) + .with_context(|| format!("fetch from {url} failed"))? + .prepare_fetch(Discard, options) + .with_context(|| format!("fetch from {url} failed"))? + .receive(Discard, &IS_INTERRUPTED) + .with_context(|| format!("fetch from {url} failed"))?; + Ok(()) + })(); + + match outcome { + Ok(()) => return Ok(()), + Err(error) => last_error = Some(error), + } + } + + match last_error { + Some(error) => Err(error).context("failed to fetch from any mirror"), + None => bail!("no fetch URLs provided"), + } + } + + /// Apply `edit` to the repository-local configuration and persist it. + pub(crate) fn edit_local_config( + &self, + edit: impl FnOnce(&mut gix::config::File) -> Result<()>, + ) -> Result<()> { + let config_path = self.inner.common_dir().join("config"); + + let mut lock = gix::lock::File::acquire_to_update_resource( + &config_path, + gix::lock::acquire::Fail::Immediately, + None, + ) + .context("failed to lock repository config")?; + + let mut config = + match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local) + { + Ok(config) => config, + // A repository without a config file yet starts from scratch. + Err(gix::config::file::init::from_paths::Error::Io { source, .. }) + if source.kind() == std::io::ErrorKind::NotFound => + { + gix::config::File::default() + } + Err(error) => return Err(error).context("failed to read repository config"), + }; + + edit(&mut config)?; + + config + .write_to(&mut lock) + .context("failed to write repository config")?; + + lock.commit().context("failed to save repository config")?; + + Ok(()) + } + + pub(crate) fn workdir_or_dot(&self) -> &Path { + self.inner.workdir().unwrap_or_else(|| Path::new(".")) + } + + /// Run `git -C dir args`, disabling the terminal prompt and capturing stderr. + /// + /// `what` names the command in the spawn error. + pub(crate) fn run_git(dir: &Path, args: &[&str], what: &str) -> Result { + Self::git_command(dir) + .args(args) + .stderr(Stdio::piped()) + .output() + .with_context(|| format!("failed to spawn `{what}`")) + } + + /// A `git -C dir` command with the terminal prompt disabled. + pub(crate) fn git_command(dir: &Path) -> Command { + let mut command = Command::new("git"); + command.arg("-C").arg(dir).env("GIT_TERMINAL_PROMPT", "0"); + command + } } diff --git a/crates/signed_git/src/repo.rs b/crates/signed_git/src/repo.rs index 93d5998..29cbedc 100644 --- a/crates/signed_git/src/repo.rs +++ b/crates/signed_git/src/repo.rs @@ -2,342 +2,442 @@ use std::path::Path; use anyhow::{Context, Result}; -use crate::history::open_with_cache; -use crate::worktree::{force_checkout, worktree_dirty}; +/// In-memory object cache for history walks, see [`Repo::open_cached`]. +/// +/// Without one, a walk re-decodes the same commit objects from the object database. +/// Sized generously: a walk can cover a large portion of the repository's history. +const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024; -/// The merge base of two revisions in the repository at `repo_path`, -/// revisions may be branch names, remote-tracking refs or commit ids. +/// An opened git repository. /// -/// `Ok(None)` when the revisions share no common ancestor. -/// -/// Unresolvable revisions are errors. -pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result> { - let repo = open_with_cache(repo_path)?; - let a = repo.rev_parse_single(a.as_bytes())?; - let b = repo.rev_parse_single(b.as_bytes())?; - match repo.merge_base(a, b) { - Ok(id) => Ok(Some(id.to_string())), - // No common ancestor, a valid outcome for a proposal. - Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None), - Err(e) => Err(e.into()), - } +/// One `open` per operation instead of every helper re-opening by path. +pub struct Repo { + pub(crate) inner: gix::Repository, } -/// The commit HEAD points to in the repository at `repo_path`. -/// -/// `None` when the repository has no commits yet, an unborn HEAD. -pub fn head_commit_id(repo_path: &Path) -> Result> { - let Ok(repo) = gix::open(repo_path) else { - return Ok(None); - }; - - match repo.head_id() { - Ok(id) => Ok(Some(id.to_string())), - Err(_) => Ok(None), - } -} - -/// The commits in `base..HEAD` of the repository at `repo_path`, oldest first. -/// This is the order `git am` creates them. -/// -/// `HEAD` alone when `base` is `None`. -pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result> { - let repo = match gix::open(repo_path) { - Ok(repo) => repo, - Err(_) if base.is_none() => return Ok(Vec::new()), - Err(e) => return Err(e.into()), - }; - - let head = match repo.head_id() { - Ok(head) => head, - Err(_) if base.is_none() => return Ok(Vec::new()), - Err(e) => return Err(e).context("repository has no commits"), - }; - - let Some(base) = base else { - return Ok(vec![head.to_string()]); - }; - - let base = repo.rev_parse_single(base.as_bytes())?; - let mut commits = Vec::new(); - - for info in repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, - )) - .with_hidden([base]) - .all()? - { - commits.push(info?.id().to_string()); - } - - // Oldest first, like `git rev-list --reverse`, the order `git am` creates them. - commits.reverse(); - - Ok(commits) -} - -/// The identity written to reflogs and commits created by this crate itself. -/// -/// Like `git -c user.name=… -c user.email=…` per invocation: the repository works -/// without a global git identity, and `gix` runs no hooks and never signs. -pub(crate) fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) { - let seconds = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_secs() as i64) - .unwrap_or_default(); - - let signature = gix::actor::Signature { - name: gix::bstr::BString::from("Signed"), - email: gix::bstr::BString::from("signed@localhost"), - time: gix::date::Time { seconds, offset: 0 }, - }; - - (signature, gix::date::parse::TimeBuf::default()) -} - -/// Create a repository at `path` with an initial `main` branch. -/// Write a `README.md` from `name` and `description`, then create the initial commit. -/// -/// Returns the initial commit id. -pub fn init_repository(path: &Path, name: &str, description: &str) -> Result { - use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; - - std::fs::create_dir_all(path) - .with_context(|| format!("failed to create {}", path.display()))?; - - let repo = gix::init(path)?; - - let (signature, mut time_buf) = repository_signature(); - let signature = signature.to_ref(&mut time_buf); - - // The initial branch is `main`, regardless of `init.defaultBranch` in - // the user's git configuration: point the unborn HEAD there. - let head = gix::refs::FullName::try_from("HEAD") - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - - repo.edit_references_as( - [RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: "checkout: moving to main".into(), - }, - expected: PreviousValue::Any, - new: gix::refs::Target::Symbolic( - gix::refs::FullName::try_from("refs/heads/main") - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?, - ), - }, - name: head, - deref: false, - }], - Some(signature), - )?; - - let readme = if description.trim().is_empty() { - format!("# {name}\n") - } else { - format!("# {name}\n\n{description}\n") - }; - - std::fs::write(path.join("README.md"), &readme).context("failed to write README.md")?; - - let blob = repo.write_object(gix::objs::Blob { - data: readme.into_bytes(), - })?; - - let tree = repo.write_object(gix::objs::Tree { - entries: vec![gix::objs::tree::Entry { - mode: gix::objs::tree::EntryKind::Blob.into(), - filename: gix::bstr::BString::from("README.md"), - oid: blob.into(), - }], - })?; - - let commit = repo.commit_as( - signature, - signature, - "HEAD", - "Initial commit", - tree, - Vec::::new(), - )?; - - // Populate the index so the fresh repository is clean, - // as `git add` and `git commit` would leave it. - let mut index = repo.index_from_tree(&tree)?; - index.write(gix::index::write::Options::default())?; - - Ok(commit.to_string()) -} - -/// The earliest unique commit of the repository at `repo_path`. -/// Used as the NIP-34 announcement's `euc` marker. -/// -/// `None` for a repository without commits. -pub fn root_commit(repo_path: &Path) -> Result> { - let Ok(repo) = gix::open(repo_path) else { - return Ok(None); - }; - - let Ok(head) = repo.head_id() else { - return Ok(None); - }; - - for info in repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, - )) - .all()? - { - let info = info?; - if info.parent_ids().next().is_none() { - return Ok(Some(info.id().to_string())); - } - } - - Ok(None) -} - -/// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`. -/// `prefix` is a ref namespace like `refs/fork//`. -/// -/// Returns an empty list when nothing matches. -pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result> { - let pattern = prefix.trim_end_matches('/'); - let repo = gix::open(repo_path)?; - let mut names = Vec::new(); - - for reference in repo.references()?.all()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - let name = String::from_utf8_lossy(reference.name().as_bstr()).into_owned(); - - // Match the pattern itself and everything beneath it, like `git for-each-ref`. - let under_pattern = name - .strip_prefix(pattern) - .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')); - - if under_pattern { - names.push(name); - } - } - - names.sort(); - - Ok(names) -} - -/// Delete every ref under `prefix` of the repository at `repo_path`. -/// `prefix` is a ref namespace like `refs/fork//`. -pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> { - use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog}; - - let refs = refs_with_prefix(repo_path, prefix)?; - if refs.is_empty() { - return Ok(()); - } - - let repo = gix::open(repo_path)?; - let edits: Vec = refs - .iter() - .map(|name| { - let full = gix::refs::FullName::try_from(name.as_str()) - .map_err(|e| anyhow::anyhow!("invalid ref name {name}: {e}"))?; - Ok(RefEdit { - change: Change::Delete { - expected: PreviousValue::Any, - log: RefLog::AndReference, - }, - name: full, - deref: false, - }) +impl Repo { + /// Open the repository at `workdir`. + pub fn open(workdir: &Path) -> Result { + Ok(Self { + inner: gix::open(workdir)?, }) - .collect::>>()?; + } - repo.edit_references(edits)?; + /// Open the repository at `workdir`, `None` when the path is not one. + pub fn try_open(workdir: &Path) -> Option { + Self::open(workdir).ok() + } - Ok(()) -} + /// Open the repository at `workdir` with an in-memory object cache. + /// + /// Only history walks benefit from it, they re-decode the same commit + /// objects repeatedly. Single-object reads open the repository plain. + pub fn open_cached(workdir: &Path) -> Result { + let mut repo = gix::open(workdir)?; + repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES); + Ok(Self { inner: repo }) + } -/// Short name of the branch HEAD points to at `workdir`, -/// `None` when detached or unreadable, like `git branch --show-current`. -pub fn worktree_current_branch(workdir: &Path) -> Option { - let repo = gix::open(workdir).ok()?; - let head = repo.head().ok()?; - let name = head.referent_name()?; - Some(String::from_utf8_lossy(name.shorten()).into_owned()) -} + /// Create a repository at `path` with an initial `main` branch. + /// Write a `README.md` from `name` and `description`, then create the initial commit. + /// + /// Returns the initial commit id. + pub fn init(path: &Path, name: &str, description: &str) -> Result { + use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; -pub fn worktree_ref_exists(workdir: &Path, name: &str) -> bool { - let Ok(repo) = gix::open(workdir) else { - return false; - }; - repo.find_reference(name).is_ok() -} + std::fs::create_dir_all(path) + .with_context(|| format!("failed to create {}", path.display()))?; -/// Fast-forward local branches that trail their remote-tracking counterpart. -/// -/// Returns whether any branch moved. -pub fn fast_forward_branches(workdir: &Path) -> Result { - let repo = gix::open(workdir)?; - let current = worktree_current_branch(workdir); - let heads = refs_with_prefix(workdir, "refs/heads")?; + let repo = gix::init(path)?; - let (signature, mut time_buf) = repository_signature(); - let signature = signature.to_ref(&mut time_buf); + let (signature, mut time_buf) = Self::repository_signature(); + let signature = signature.to_ref(&mut time_buf); - let mut moved = false; - - for head in heads { - let Some(branch) = head.strip_prefix("refs/heads/") else { - continue; - }; - - let remote = format!("refs/remotes/origin/{branch}"); - // No remote-tracking counterpart means the remote lacks this branch. - let Ok(mut remote_reference) = repo.find_reference(&remote) else { - continue; - }; - - let Ok(mut local_reference) = repo.find_reference(&head) else { - continue; - }; - - let Ok(remote_oid) = remote_reference.peel_to_id() else { - continue; - }; - - let Ok(local_oid) = local_reference.peel_to_id() else { - continue; - }; - - let remote_oid = remote_oid.detach(); - let local_oid = local_oid.detach(); - - if local_oid == remote_oid { - continue; - } - - // Only fast-forward. - // Local-only commits or diverged history must never be rewritten by a refresh. - let Ok(base) = repo.merge_base(local_oid, remote_oid) else { - continue; - }; - - if base != local_oid { - continue; - } - - let full = gix::refs::FullName::try_from(head.as_str()) + // The initial branch is `main`, regardless of `init.defaultBranch` in + // the user's git configuration: point the unborn HEAD there. + let head = gix::refs::FullName::try_from("HEAD") .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - let edit = |new: gix::refs::Target| { - use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; - RefEdit { + repo.edit_references_as( + [RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: "checkout: moving to main".into(), + }, + expected: PreviousValue::Any, + new: gix::refs::Target::Symbolic( + gix::refs::FullName::try_from("refs/heads/main") + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?, + ), + }, + name: head, + deref: false, + }], + Some(signature), + )?; + + let readme = if description.trim().is_empty() { + format!("# {name}\n") + } else { + format!("# {name}\n\n{description}\n") + }; + + std::fs::write(path.join("README.md"), &readme).context("failed to write README.md")?; + + let blob = repo.write_object(gix::objs::Blob { + data: readme.into_bytes(), + })?; + + let tree = repo.write_object(gix::objs::Tree { + entries: vec![gix::objs::tree::Entry { + mode: gix::objs::tree::EntryKind::Blob.into(), + filename: gix::bstr::BString::from("README.md"), + oid: blob.into(), + }], + })?; + + let commit = repo.commit_as( + signature, + signature, + "HEAD", + "Initial commit", + tree, + Vec::::new(), + )?; + + // Populate the index so the fresh repository is clean, + // as `git add` and `git commit` would leave it. + let mut index = repo.index_from_tree(&tree)?; + index.write(gix::index::write::Options::default())?; + + Ok(commit.to_string()) + } + + /// Clone into `path` from the first working URL in `clone_urls`. + /// + /// Unlike [`crate::GitCache::ensure_clone`], the clone is not kept in any cache. + pub fn clone>(clone_urls: &[U], path: &Path) -> Result { + if path.exists() { + anyhow::bail!("destination {} already exists", path.display()); + } + + let mut last_error = None; + + for url in clone_urls { + match Self::clone_from(url.as_ref(), path) { + Ok(repo) => { + // The initial clone uses the default refspecs. Also fetch the `refs/nostr/*` PR refs. + repo.fetch().ok(); + return Ok(repo); + } + Err(error) => last_error = Some(error), + } + } + + match last_error { + Some(error) => Err(error).context("failed to clone from any mirror"), + None => anyhow::bail!("no clone URLs provided"), + } + } + + fn clone_from(url: &str, path: &Path) -> Result { + let url = Self::transport_url(url); + let url = gix::url::parse(url).context("invalid clone URL")?; + + let mut prepare = gix::prepare_clone(url, path)?; + let (mut checkout, _fetch) = + prepare.fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?; + let (repo, _checkout) = + checkout.main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?; + + Ok(Self { inner: repo }) + } + + pub fn inner(&self) -> &gix::Repository { + &self.inner + } + + /// The worktree directory, `None` for a bare repository. + pub fn workdir(&self) -> Option<&Path> { + self.inner.workdir() + } + + /// The commit id HEAD points to. + /// + /// `None` when the repository has no commits yet, an unborn HEAD. + pub fn head(&self) -> Option { + self.inner.head_id().ok().map(|id| id.to_string()) + } + + /// The merge base of two revisions, + /// revisions may be branch names, remote-tracking refs or commit ids. + /// + /// `Ok(None)` when the revisions share no common ancestor. + /// + /// Unresolvable revisions are errors. + pub fn merge_base(&self, a: &str, b: &str) -> Result> { + let a = self.inner.rev_parse_single(a.as_bytes())?; + let b = self.inner.rev_parse_single(b.as_bytes())?; + match self.inner.merge_base(a, b) { + Ok(id) => Ok(Some(id.to_string())), + // No common ancestor, a valid outcome for a proposal. + Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// The commits in `base..HEAD`, oldest first. + /// This is the order `git am` creates them. + /// + /// `HEAD` alone when `base` is `None`. + pub fn commits_since(&self, base: Option<&str>) -> Result> { + let head = match self.inner.head_id() { + Ok(head) => head, + Err(_) if base.is_none() => return Ok(Vec::new()), + Err(e) => return Err(e).context("repository has no commits"), + }; + + let Some(base) = base else { + return Ok(vec![head.to_string()]); + }; + + let base = self.inner.rev_parse_single(base.as_bytes())?; + let mut commits = Vec::new(); + + for info in self + .inner + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, + )) + .with_hidden([base]) + .all()? + { + commits.push(info?.id().to_string()); + } + + // Oldest first, like `git rev-list --reverse`, the order `git am` creates them. + commits.reverse(); + + Ok(commits) + } + + /// The earliest unique commit of the repository. + /// Used as the NIP-34 announcement's `euc` marker. + /// + /// `None` for a repository without commits. + pub fn root_commit(&self) -> Result> { + let Ok(head) = self.inner.head_id() else { + return Ok(None); + }; + + for info in self + .inner + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, + )) + .all()? + { + let info = info?; + if info.parent_ids().next().is_none() { + return Ok(Some(info.id().to_string())); + } + } + + Ok(None) + } + + /// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`. + /// `prefix` is a ref namespace like `refs/fork//`. + /// + /// Returns an empty list when nothing matches. + pub fn refs_with_prefix(&self, prefix: &str) -> Result> { + let pattern = prefix.trim_end_matches('/'); + let mut names = Vec::new(); + + for reference in self.inner.references()?.all()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + let name = String::from_utf8_lossy(reference.name().as_bstr()).into_owned(); + + // Match the pattern itself and everything beneath it, like `git for-each-ref`. + let under_pattern = name + .strip_prefix(pattern) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')); + + if under_pattern { + names.push(name); + } + } + + names.sort(); + + Ok(names) + } + + /// Delete every ref under `prefix`. + /// `prefix` is a ref namespace like `refs/fork//`. + pub fn delete_refs_with_prefix(&self, prefix: &str) -> Result<()> { + use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog}; + + let refs = self.refs_with_prefix(prefix)?; + if refs.is_empty() { + return Ok(()); + } + + let edits: Vec = refs + .iter() + .map(|name| { + let full = gix::refs::FullName::try_from(name.as_str()) + .map_err(|e| anyhow::anyhow!("invalid ref name {name}: {e}"))?; + Ok(RefEdit { + change: Change::Delete { + expected: PreviousValue::Any, + log: RefLog::AndReference, + }, + name: full, + deref: false, + }) + }) + .collect::>>()?; + + self.inner.edit_references(edits)?; + + Ok(()) + } + + /// Short name of the branch HEAD points to, + /// `None` when detached or unreadable, like `git branch --show-current`. + pub fn current_branch(&self) -> Option { + let head = self.inner.head().ok()?; + let name = head.referent_name()?; + Some(String::from_utf8_lossy(name.shorten()).into_owned()) + } + + pub fn ref_exists(&self, name: &str) -> bool { + self.inner.find_reference(name).is_ok() + } + + /// Short names of local branches, `refs/heads/*`, sorted alphabetically. + pub fn branches(&self) -> Result> { + let mut names = Vec::new(); + for reference in self.inner.references()?.local_branches()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); + } + names.sort(); + Ok(names) + } + + /// Short names of tags, `refs/tags/*`, sorted alphabetically. + pub fn tags(&self) -> Result> { + let mut names = Vec::new(); + for reference in self.inner.references()?.tags()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); + } + names.sort(); + Ok(names) + } + + /// Branch, tag and HEAD refs of the repository. + /// + /// Ready for a NIP-34 kind-30618 repository state announcement. + pub fn ref_state(&self) -> Result { + let mut refs = Vec::new(); + + for reference in self.inner.references()?.local_branches()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + refs.push(( + String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), + reference.id().to_string(), + )); + } + + for reference in self.inner.references()?.tags()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + refs.push(( + String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), + reference.id().to_string(), + )); + } + refs.sort(); + + let head = match self.inner.head() { + Ok(head) => head + .referent_name() + .filter(|name| name.as_bstr().starts_with(b"refs/heads/")) + .map(|name| String::from_utf8_lossy(name.shorten()).into_owned()), + Err(_) => None, + }; + + Ok(RepoRefState { refs, head }) + } + + /// Fast-forward local branches that trail their remote-tracking counterpart. + /// + /// Returns whether any branch moved. + pub fn fast_forward_branches(&self) -> Result { + use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; + + if self.workdir().is_none() { + return Ok(false); + } + + let current = self.current_branch(); + let heads = self.refs_with_prefix("refs/heads")?; + + let (signature, mut time_buf) = Self::repository_signature(); + let signature = signature.to_ref(&mut time_buf); + + let mut moved = false; + + for head in heads { + let Some(branch) = head.strip_prefix("refs/heads/") else { + continue; + }; + + let remote = format!("refs/remotes/origin/{branch}"); + // No remote-tracking counterpart means the remote lacks this branch. + let Ok(mut remote_reference) = self.inner.find_reference(&remote) else { + continue; + }; + + let Ok(mut local_reference) = self.inner.find_reference(&head) else { + continue; + }; + + let Ok(remote_oid) = remote_reference.peel_to_id() else { + continue; + }; + + let Ok(local_oid) = local_reference.peel_to_id() else { + continue; + }; + + let remote_oid = remote_oid.detach(); + let local_oid = local_oid.detach(); + + if local_oid == remote_oid { + continue; + } + + // Only fast-forward. + // Local-only commits or diverged history must never be rewritten by a refresh. + let Ok(base) = self.inner.merge_base(local_oid, remote_oid) else { + continue; + }; + + if base != local_oid { + continue; + } + + let full = gix::refs::FullName::try_from(head.as_str()) + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; + + let edit = |new: gix::refs::Target| RefEdit { change: Change::Update { log: LogChange { mode: RefLog::AndReference, @@ -351,80 +451,69 @@ pub fn fast_forward_branches(workdir: &Path) -> Result { }, name: full.clone(), deref: false, + }; + + if current.as_deref() == Some(branch) { + // Merge so the checked-out worktree follows the branch. + // Only proceed on a clean worktree, like `git merge --ff-only`. + if self.is_dirty() { + continue; + } + + let tree = self.inner.find_object(remote_oid)?.peel_to_tree()?.id; + + self.force_checkout(&tree)?; + + self.inner.edit_references_as( + [edit(gix::refs::Target::Object(remote_oid))], + Some(signature), + )?; + + moved = true; + } else { + self.inner.edit_references_as( + [edit(gix::refs::Target::Object(remote_oid))], + Some(signature), + )?; + + moved = true; } + } + + Ok(moved) + } + + /// The identity written to reflogs and commits created by this crate itself. + /// + /// Like `git -c user.name=… -c user.email=…` per invocation: the repository works + /// without a global git identity, and `gix` runs no hooks and never signs. + pub(crate) fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) { + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or_default(); + + let signature = gix::actor::Signature { + name: gix::bstr::BString::from("Signed"), + email: gix::bstr::BString::from("signed@localhost"), + time: gix::date::Time { seconds, offset: 0 }, }; - if current.as_deref() == Some(branch) { - // Merge so the checked-out worktree follows the branch. - // Only proceed on a clean worktree, like `git merge --ff-only`. - if worktree_dirty(workdir) { - continue; - } - - let tree = repo.find_object(remote_oid)?.peel_to_tree()?.id; - - force_checkout(&repo, &tree)?; - - repo.edit_references_as( - [edit(gix::refs::Target::Object(remote_oid))], - Some(signature), - )?; - - moved = true; - } else { - repo.edit_references_as( - [edit(gix::refs::Target::Object(remote_oid))], - Some(signature), - )?; - - moved = true; - } + (signature, gix::date::parse::TimeBuf::default()) } - Ok(moved) -} - -/// Short names of local branches, `refs/heads/*`, of `repo`, sorted alphabetically. -pub fn repo_branches(repo: &gix::Repository) -> Result> { - let mut names = Vec::new(); - for reference in repo.references()?.local_branches()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); + /// Rewrite a grasp server URL to the https URL the git transport actually uses. + /// + /// GRASP servers announce `grasp:////` clone URLs. + /// The transport is git smart HTTP, so the scheme is rewritten for gix. + pub(crate) fn transport_url(url: &str) -> String { + url.strip_prefix("grasp://") + .map(|rest| format!("https://{rest}")) + .unwrap_or_else(|| url.to_owned()) } - names.sort(); - Ok(names) -} - -/// Short names of tags, `refs/tags/*`, of `repo`, sorted alphabetically. -pub fn repo_tags(repo: &gix::Repository) -> Result> { - let mut names = Vec::new(); - for reference in repo.references()?.tags()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); - } - names.sort(); - Ok(names) -} - -/// Short names of local branches, `refs/heads/*`, sorted alphabetically. -pub fn worktree_branches(workdir: &Path) -> Result> { - repo_branches(&gix::open(workdir)?) -} - -/// Short name of the branch HEAD points to, or `None` when detached. -/// -/// Detached after checking out a tag or a commit directly. -pub fn current_branch(repo: &gix::Repository) -> Result> { - let head = repo.head()?; - let Some(name) = head.referent_name() else { - return Ok(None); - }; - Ok(Some(String::from_utf8_lossy(name.shorten()).into_owned())) } /// Branch, tag and HEAD refs of a repository. -/// -/// Ready for a NIP-34 kind-30618 repository state announcement. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepoRefState { /// `(full refname, commit id)` pairs for heads and tags, sorted. @@ -432,38 +521,3 @@ pub struct RepoRefState { /// Short branch name HEAD points to, or `None` when detached. pub head: Option, } - -pub fn repo_ref_state(repo: &gix::Repository) -> Result { - let mut refs = Vec::new(); - - for reference in repo.references()?.local_branches()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - refs.push(( - String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), - reference.id().to_string(), - )); - } - - for reference in repo.references()?.tags()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - refs.push(( - String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), - reference.id().to_string(), - )); - } - refs.sort(); - - let head = match repo.head() { - Ok(head) => head - .referent_name() - .filter(|name| name.as_bstr().starts_with(b"refs/heads/")) - .map(|name| String::from_utf8_lossy(name.shorten()).into_owned()), - Err(_) => None, - }; - - Ok(RepoRefState { refs, head }) -} - -pub fn worktree_ref_state(workdir: &Path) -> Result { - repo_ref_state(&gix::open(workdir)?) -} diff --git a/crates/signed_git/src/scan.rs b/crates/signed_git/src/scan.rs index 6e64fc0..8bb3830 100644 --- a/crates/signed_git/src/scan.rs +++ b/crates/signed_git/src/scan.rs @@ -2,7 +2,8 @@ use std::path::{Path, PathBuf}; use ignore::WalkBuilder; -use crate::nip34::{Nip34Binding, detect_nip34}; +use crate::nip34::Nip34Binding; +use crate::repo::Repo; /// Caps nesting so pathological trees can't stall the scan. const SCAN_MAX_DEPTH: usize = 12; @@ -49,7 +50,7 @@ pub fn find_git_repos(root: &Path) -> Vec { roots .into_iter() .map(|path| { - let nip34 = detect_nip34(&path); + let nip34 = Repo::open(&path).ok().and_then(|repo| repo.nip34_binding()); LocalRepo { path, nip34 } }) .collect() diff --git a/crates/signed_git/src/tests.rs b/crates/signed_git/src/tests.rs index 189a708..e470ff6 100644 --- a/crates/signed_git/src/tests.rs +++ b/crates/signed_git/src/tests.rs @@ -5,11 +5,11 @@ use super::*; #[test] fn blocks_parent_components() { - assert_eq!(sanitize_path_component(".."), "_"); - assert_eq!(sanitize_path_component("."), "_"); + assert_eq!(GitCache::sanitize_path_component(".."), "_"); + assert_eq!(GitCache::sanitize_path_component("."), "_"); // Separators are neutralized before the check, so these stay safe. - assert_eq!(sanitize_path_component("../.."), ".._.."); - assert_eq!(sanitize_path_component("a/../b"), "a_.._b"); + assert_eq!(GitCache::sanitize_path_component("../.."), ".._.."); + assert_eq!(GitCache::sanitize_path_component("a/../b"), "a_.._b"); } #[test] @@ -18,14 +18,14 @@ fn root_commit_reports_the_first_ancestor() { commit_all(&repo, "initial"); let dir = dir.path(); - let root = root_commit(dir).expect("root").expect("commit"); + let root = repo.root_commit().expect("root").expect("commit"); assert_eq!(root.len(), 40); // The root commit does not change when history grows. std::fs::write(dir.join("b.txt"), b"two").expect("write"); commit_all(&repo, "second"); assert_eq!( - root_commit(dir).expect("root").as_deref(), + repo.root_commit().expect("root").as_deref(), Some(root.as_str()) ); } @@ -48,7 +48,10 @@ fn push_all_mirrors_branches_and_tags() { git_run(dir, &["tag", "v1.0"]); let base_url = format!("file://{}", server.path().display()); - push_all(dir, &base_url, "npub1test", "my-repo").expect("push"); + Repo::open(dir) + .expect("open") + .push_all(&base_url, "npub1test", "my-repo") + .expect("push"); let refs = git_in(&server_repo, &["show-ref"]).expect("server refs"); assert!(refs.contains("refs/heads/main")); @@ -66,34 +69,33 @@ fn remote_has_refs_reports_whether_pushed_refs_landed() { let main = git_in(dir, &["rev-parse", "refs/heads/main"]).expect("main oid"); let url = format!("file://{}/npub1test/my-repo.git", server.path().display()); let expected = vec![("refs/heads/main".to_owned(), main.clone())]; + let repo = Repo::open(dir).expect("open"); - assert!(!remote_has_refs(dir, &url, &expected).expect("probe")); + assert!(!repo.remote_has_refs(&url, &expected).expect("probe")); - push_all( - dir, + repo.push_all( &format!("file://{}", server.path().display()), "npub1test", "my-repo", ) .expect("push"); - assert!(remote_has_refs(dir, &url, &expected).expect("probe")); + assert!(repo.remote_has_refs(&url, &expected).expect("probe")); // A stale expectation - the exact race a retry resolves - is false. let stale = vec![("refs/heads/main".to_owned(), "0".repeat(40))]; - assert!(!remote_has_refs(dir, &url, &stale).expect("probe")); + assert!(!repo.remote_has_refs(&url, &stale).expect("probe")); // Extra remote refs (e.g. a tag pushed later) do not invalidate the // refs this push wanted to land. git_run(dir, &["tag", "v1.0"]); - push_all( - dir, + repo.push_all( &format!("file://{}", server.path().display()), "npub1test", "my-repo", ) .expect("push"); - assert!(remote_has_refs(dir, &url, &expected).expect("probe")); + assert!(repo.remote_has_refs(&url, &expected).expect("probe")); } #[test] @@ -102,9 +104,9 @@ fn repo_ref_state_lists_branches_tags_and_head() { commit_all(&repo, "initial"); let workdir = repo.workdir().expect("workdir").to_path_buf(); - let state = repo_ref_state(&repo).expect("refs"); + let state = repo.ref_state().expect("refs"); - let branch = current_branch(&repo).expect("branch").expect("on a branch"); + let branch = repo.current_branch().expect("on a branch"); assert_eq!(state.head.as_deref(), Some(branch.as_str())); assert_eq!(state.refs.len(), 1); assert_eq!(state.refs[0].0, format!("refs/heads/{branch}")); @@ -113,7 +115,7 @@ fn repo_ref_state_lists_branches_tags_and_head() { git_run(&workdir, &["branch", "feature"]); git_run(&workdir, &["tag", "v1.0"]); - let state = repo_ref_state(&repo).expect("refs"); + let state = repo.ref_state().expect("refs"); let mut expected: Vec = vec![ format!("refs/heads/{branch}"), "refs/heads/feature".to_owned(), @@ -130,15 +132,15 @@ fn repo_ref_state_lists_branches_tags_and_head() { ); git_run(&workdir, &["checkout", "--detach"]); - let state = repo_ref_state(&repo).expect("refs"); + let state = repo.ref_state().expect("refs"); assert!(state.head.is_none()); assert_eq!(state.refs.len(), 3); } /// Build a throwaway non-bare repository from `(rel, bytes)` file pairs. -fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) { +fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repo) { let dir = tempfile::tempdir().expect("tempdir"); - let repo = gix::init(&dir).expect("init"); + gix::init(&dir).expect("init"); for (rel, bytes) in files { let path = dir.path().join(rel); @@ -146,12 +148,13 @@ fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) { std::fs::write(&path, bytes).expect("write"); } + let repo = Repo::open(dir.path()).expect("open"); (dir, repo) } /// Stage everything and create a commit with the git CLI. -/// Like [`apply_patch`], the crate already shells out to the CLI. -fn commit_all(repo: &gix::Repository, message: &str) { +/// Like [`Repo::apply_patch`], the crate already shells out to the CLI. +fn commit_all(repo: &Repo, message: &str) { git_run(repo.workdir().expect("workdir"), &["add", "-A"]); git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]); } @@ -160,19 +163,20 @@ fn commit_all(repo: &gix::Repository, message: &str) { fn merge_base_finds_the_fork_point_and_reports_unrelated_history() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("repo"); - let initial = init_repository(&path, "My Repo", "desc").expect("init"); + let initial = Repo::init(&path, "My Repo", "desc").expect("init"); + let repo = Repo::open(&path).expect("open"); // A feature branch and a mainline commit diverge from the initial commit. // The initial commit is their merge base. git_run(&path, &["checkout", "-b", "feature"]); std::fs::write(path.join("feature.txt"), "feature\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "feature commit"); + commit_all(&repo, "feature commit"); git_run(&path, &["checkout", "main"]); std::fs::write(path.join("main.txt"), "main\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "mainline commit"); + commit_all(&repo, "mainline commit"); assert_eq!( - merge_base(&path, "feature", "main") + repo.merge_base("feature", "main") .expect("merge base") .as_deref(), Some(initial.as_str()) @@ -181,27 +185,30 @@ fn merge_base_finds_the_fork_point_and_reports_unrelated_history() { // An orphan branch shares no history with main, so `Ok(None)`. git_run(&path, &["checkout", "--orphan", "orphan"]); std::fs::write(path.join("orphan.txt"), "orphan\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "orphan commit"); - assert_eq!(merge_base(&path, "orphan", "main").expect("ok"), None); + commit_all(&repo, "orphan commit"); + assert_eq!(repo.merge_base("orphan", "main").expect("ok"), None); // An unresolvable revision is an error, not a missing ancestor. - assert!(merge_base(&path, "orphan", "no-such-ref").is_err()); + assert!(repo.merge_base("orphan", "no-such-ref").is_err()); } #[test] fn split_patch_series_splits_real_multi_commit_mboxes() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("repo"); - let initial = init_repository(&path, "My Repo", "desc").expect("init"); + let initial = Repo::init(&path, "My Repo", "desc").expect("init"); + let repo = Repo::open(&path).expect("open"); git_run(&path, &["checkout", "-b", "feature"]); std::fs::write(path.join("one.txt"), "one\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "first commit"); + commit_all(&repo, "first commit"); std::fs::write(path.join("two.txt"), "two\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "second commit"); + commit_all(&repo, "second commit"); - let series = format_patch_between(&path, &initial, "feature").expect("series"); - let parts = split_patch_series(&series); + let series = repo + .format_patch_between(&initial, "feature") + .expect("series"); + let parts = PatchParser::split_patch_series(&series); assert_eq!(parts.len(), 2); assert!(parts[0].contains("Subject: [PATCH 1/2] first commit")); @@ -217,33 +224,31 @@ fn split_patch_series_splits_real_multi_commit_mboxes() { fn head_commit_and_commits_since_track_applied_commits() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("repo"); - let initial = init_repository(&path, "My Repo", "desc").expect("init"); + let initial = Repo::init(&path, "My Repo", "desc").expect("init"); + let repo = Repo::open(&path).expect("open"); - assert_eq!( - head_commit_id(&path).expect("head").as_deref(), - Some(initial.as_str()) - ); + assert_eq!(repo.head().as_deref(), Some(initial.as_str())); // No base given, `HEAD` alone. assert_eq!( - commits_since(&path, None).expect("commits"), + repo.commits_since(None).expect("commits"), vec![initial.clone()] ); std::fs::write(path.join("one.txt"), "one\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "first commit"); - let first = head_commit_id(&path).expect("head").expect("on a branch"); + commit_all(&repo, "first commit"); + let first = repo.head().expect("on a branch"); std::fs::write(path.join("two.txt"), "two\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "second commit"); - let second = head_commit_id(&path).expect("head").expect("on a branch"); + commit_all(&repo, "second commit"); + let second = repo.head().expect("on a branch"); // Oldest first, like the order `git am` creates them. assert_eq!( - commits_since(&path, Some(&initial)).expect("commits"), + repo.commits_since(Some(&initial)).expect("commits"), vec![first.clone(), second.clone()] ); assert_eq!( - commits_since(&path, Some(&first)).expect("commits"), + repo.commits_since(Some(&first)).expect("commits"), vec![second] ); } @@ -256,25 +261,29 @@ fn working_copy_cloned_from_the_mirror_matches_head_and_origin() { // the first time a repository is opened. let dir = tempfile::tempdir().expect("tempdir"); let mirror = dir.path().join("mirror"); - let commit = init_repository(&mirror, "My Repo", "Does things.").expect("init"); - ensure_origin(&mirror, "https://gitnostr.com/npub1test/my-repo.git").expect("origin"); + let commit = Repo::init(&mirror, "My Repo", "Does things.").expect("init"); + Repo::open(&mirror) + .expect("open") + .ensure_origin("https://gitnostr.com/npub1test/my-repo.git") + .expect("origin"); // The working copy is cloned from the mirror. // It then shares the announced history exactly. // `origin` is re-pointed at the grasp server instead of the mirror path. let destination = dir.path().join("folder").join("My_Repo"); std::fs::create_dir_all(destination.parent().unwrap()).expect("parent"); - clone_repo(&[format!("file://{}", mirror.display())], &destination).expect("clone"); - set_origin(&destination, "https://gitnostr.com/npub1test/my-repo.git").expect("set origin"); + Repo::clone(&[format!("file://{}", mirror.display())], &destination).expect("clone"); + Repo::open(&destination) + .expect("open") + .set_origin("https://gitnostr.com/npub1test/my-repo.git") + .expect("set origin"); + let working = Repo::open(&destination).expect("open"); assert_eq!( - origin_url(&destination).expect("url").as_deref(), + working.origin_url().expect("url").as_deref(), Some("https://gitnostr.com/npub1test/my-repo.git") ); - assert_eq!( - head_commit_id(&destination).expect("head").as_deref(), - Some(commit.as_str()) - ); + assert_eq!(working.head().as_deref(), Some(commit.as_str())); assert!(destination.join("README.md").is_file()); } @@ -288,7 +297,9 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { commit_all(&work_repo, "initial"); let work = work_dir.path(); let base_url = format!("file://{}", dir.path().display()); - push_all(work, &base_url, "npub1test", "repo").expect("push"); + work_repo + .push_all(&base_url, "npub1test", "repo") + .expect("push"); // A mirror clone, like the app's GitCache clones. let mirror = dir.path().join("mirror"); @@ -302,12 +313,15 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { ], ); let initial = git_in(&mirror, &["rev-parse", "HEAD"]).expect("initial"); + let repo = Repo::open(&mirror).expect("open"); // The owner pushes a new commit. // The mirror fetches it, but its local `main` and worktree stay behind. std::fs::write(work.join("new.txt"), b"new\n").expect("write"); - commit_all(&gix::open(work).expect("open"), "new commit"); - push_all(work, &base_url, "npub1test", "repo").expect("push"); + commit_all(&work_repo, "new commit"); + work_repo + .push_all(&base_url, "npub1test", "repo") + .expect("push"); git_run(&mirror, &["fetch", "origin"]); let remote = git_in(&mirror, &["rev-parse", "refs/remotes/origin/main"]).expect("remote"); assert_eq!( @@ -318,20 +332,20 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { // Fast-forwarding catches the branch and its worktree up. // The second call has nothing left to move. - assert!(fast_forward_branches(&mirror).expect("ff")); + assert!(repo.fast_forward_branches().expect("ff")); assert_eq!( git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"), remote ); assert!(mirror.join("new.txt").is_file()); - assert!(!fast_forward_branches(&mirror).expect("idle")); + assert!(!repo.fast_forward_branches().expect("idle")); // A branch with local commits of its own is never touched. git_run(&mirror, &["checkout", "-b", "wip"]); std::fs::write(mirror.join("wip.txt"), b"wip\n").expect("write"); - commit_all(&gix::open(&mirror).expect("open"), "local wip"); + commit_all(&repo, "local wip"); let wip = git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip"); - assert!(!fast_forward_branches(&mirror).expect("wip skipped")); + assert!(!repo.fast_forward_branches().expect("wip skipped")); assert_eq!( git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip kept"), wip @@ -350,13 +364,13 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { commit_all(&upstream_repo, "initial"); let upstream_path = upstream_dir.path(); let initial = git_in(upstream_path, &["rev-parse", "HEAD"]).expect("initial"); - push_all( - upstream_path, - &format!("file://{}", dir.path().display()), - "npub1base", - "base", - ) - .expect("push"); + upstream_repo + .push_all( + &format!("file://{}", dir.path().display()), + "npub1base", + "base", + ) + .expect("push"); let base_url = format!("file://{}", base_server.display()); let mirror = dir.path().join("mirror"); @@ -364,6 +378,7 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { dir.path(), &["clone", "-q", &base_url, mirror.to_str().unwrap()], ); + let mirror_repo = Repo::open(&mirror).expect("open"); // The fork server has the same initial commit. // It also carries a feature commit on its own `feature` branch. @@ -374,58 +389,68 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { ); git_run(&fork_work, &["checkout", "-b", "feature"]); std::fs::write(fork_work.join("feature.txt"), "feature\n").expect("write"); - commit_all(&gix::open(&fork_work).expect("open"), "feature commit"); + commit_all(&Repo::open(&fork_work).expect("open"), "feature commit"); let tip = git_in(&fork_work, &["rev-parse", "HEAD"]).expect("tip"); let fork_server = bare_server(dir.path(), "npub1fork", "fork"); - push_commit_ref( - &fork_work, - &format!("file://{}", fork_server.display()), - &tip, - "refs/heads/feature", - ) - .expect("push"); + Repo::open(&fork_work) + .expect("open") + .push_ref( + &format!("file://{}", fork_server.display()), + &tip, + "refs/heads/feature", + ) + .expect("push"); // Import the fork's heads into the mirror under a private prefix. // The first dead URL is skipped, the second works. let dead = format!("file://{}/missing.git", dir.path().display()); - fetch_repo_refs( - &mirror, - &[dead, format!("file://{}", fork_server.display())], - "+refs/heads/*:refs/fork/npub1fork/fork/*", - ) - .expect("fetch"); + mirror_repo + .fetch_refs( + &[dead, format!("file://{}", fork_server.display())], + "+refs/heads/*:refs/fork/npub1fork/fork/*", + ) + .expect("fetch"); assert_eq!( - refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"), + mirror_repo + .refs_with_prefix("refs/fork/npub1fork/fork") + .expect("refs"), vec!["refs/fork/npub1fork/fork/feature"] ); // Nothing leaked into the normal ref namespaces. assert_eq!( - refs_with_prefix(&mirror, "refs/heads/fork").expect("refs"), + mirror_repo + .refs_with_prefix("refs/heads/fork") + .expect("refs"), Vec::::new() ); // The mirror can now range across both histories. // The fork point is the shared initial commit, the proposal covers the fork commit. assert_eq!( - merge_base( - &mirror, - "refs/remotes/origin/main", - "refs/fork/npub1fork/fork/feature", - ) - .expect("merge base") - .as_deref(), + mirror_repo + .merge_base( + "refs/remotes/origin/main", + "refs/fork/npub1fork/fork/feature", + ) + .expect("merge base") + .as_deref(), Some(initial.as_str()) ); - let patch = - format_patch_between(&mirror, &initial, "refs/fork/npub1fork/fork/feature").expect("patch"); + let patch = mirror_repo + .format_patch_between(&initial, "refs/fork/npub1fork/fork/feature") + .expect("patch"); assert!(patch.contains("Subject: [PATCH] feature commit")); assert!(patch.contains("feature.txt")); - delete_refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("delete"); + mirror_repo + .delete_refs_with_prefix("refs/fork/npub1fork/fork") + .expect("delete"); assert_eq!( - refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"), + mirror_repo + .refs_with_prefix("refs/fork/npub1fork/fork") + .expect("refs"), Vec::::new() ); } @@ -490,7 +515,7 @@ diff --git a/b.txt b/b.txt +y "#; - let commits = patch_commits(patch); + let commits = PatchParser::patch_commits(patch); assert_eq!(commits.len(), 2); assert_eq!(commits[0].id, "1111111111111111111111111111111111111111"); @@ -536,7 +561,7 @@ fn parses_real_format_patch_output() { let output = git_run(dir.path(), &["format-patch", "-1", "--stdout"]); let patch = String::from_utf8(output.stdout).expect("patch is utf-8"); - let diff = patch_diffs(&patch).expect("parse real format-patch output"); + let diff = PatchParser::patch_diffs(&patch).expect("parse real format-patch output"); let by_path = |path: &str| { diff.files diff --git a/crates/signed_git/src/worktree.rs b/crates/signed_git/src/worktree.rs index dd68418..bb566c4 100644 --- a/crates/signed_git/src/worktree.rs +++ b/crates/signed_git/src/worktree.rs @@ -4,140 +4,315 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use gix::progress::Discard; -use crate::history::{FileCommit, head_commit}; -use crate::repo::{current_branch, repository_signature}; +use crate::history::FileCommit; +use crate::repo::Repo; -/// Whether the worktree of `workdir` has uncommitted changes. -/// -/// Best-effort: any read failure is reported as clean. -pub fn worktree_dirty(workdir: &Path) -> bool { - let Ok(repo) = gix::open(workdir) else { - return false; - }; - - // Changes to tracked files, staged or not; untracked files are excluded. - match repo.is_dirty() { - Ok(true) => return true, - Ok(false) => {} - Err(_) => return false, - } - - // Untracked files surface as `DirectoryContents` items of the index-vs-worktree walk, - // tracked files only appear there when modified. - let Ok(platform) = repo.status(Discard) else { - return false; - }; - - let Ok(mut changes) = platform.into_index_worktree_iter(Vec::::new()) - else { - return false; - }; - - for change in changes.by_ref() { - match change { - Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true, - Ok(_) => {} +impl Repo { + /// Whether the worktree has uncommitted changes. + /// + /// Best-effort: any read failure is reported as clean. + pub fn is_dirty(&self) -> bool { + // Changes to tracked files, staged or not; untracked files are excluded. + match self.inner.is_dirty() { + Ok(true) => return true, + Ok(false) => {} Err(_) => return false, } + + // Untracked files surface as `DirectoryContents` items of the index-vs-worktree walk, + // tracked files only appear there when modified. + let Ok(platform) = self.inner.status(Discard) else { + return false; + }; + + let Ok(mut changes) = platform.into_index_worktree_iter(Vec::::new()) + else { + return false; + }; + + for change in changes.by_ref() { + match change { + Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true, + Ok(_) => {} + Err(_) => return false, + } + } + + false } - false -} + /// Commits in `base..branch`. + /// + /// Best-effort: 0 when the range cannot be computed. + pub fn commits_ahead(&self, base: &str, branch: &str) -> u32 { + let (Some(base), Some(branch)) = (self.resolve_commit(base), self.resolve_commit(branch)) + else { + return 0; + }; -/// Commits in `base..branch` of the checkout at `workdir`. -/// -/// Best-effort: 0 when the range cannot be computed. -pub fn worktree_commits_ahead(workdir: &Path, base: &str, branch: &str) -> u32 { - let Ok(repo) = gix::open(workdir) else { - return 0; - }; + let Ok(walk) = self.inner.rev_walk([branch]).with_hidden([base]).all() else { + return 0; + }; - let (Some(base), Some(branch)) = (resolve_commit(&repo, base), resolve_commit(&repo, branch)) - else { - return 0; - }; - - let Ok(walk) = repo.rev_walk([branch]).with_hidden([base]).all() else { - return 0; - }; - - walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32 -} - -/// Resolve `rev` to a commit id, accepting full refs or the bare branch names -/// callers pass. `gix`'s revision parser already applies git's ref DWIM. -fn resolve_commit<'a>(repo: &'a gix::Repository, rev: &str) -> Option> { - repo.rev_parse_single(rev.as_bytes()).ok() -} - -/// Relative paths of all entries in the worktree, files and directories. -/// -/// The `.git` directory is skipped. -pub fn worktree_entries(repo: &gix::Repository) -> Result> { - let workdir = repo.workdir().context("repository has no worktree")?; - - let mut entries: Vec<(PathBuf, bool)> = Vec::new(); - collect_entries(workdir, workdir, &mut entries)?; - - entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| { - b_is_dir - .cmp(a_is_dir) - .then_with(|| a.as_os_str().cmp(b.as_os_str())) - }); - Ok(entries.into_iter().map(|(path, _)| path).collect()) -} - -/// Read a file from the worktree. -/// -/// Returns `Ok(None)` if the path is missing or not a regular file. -pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result>> { - let workdir = repo.workdir().context("repository has no worktree")?; - let path = workdir.join(rel); - - match std::fs::read(&path) { - Ok(bytes) => Ok(Some(bytes)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None), - Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), + walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32 } -} -/// Find the README file in the repository root. -/// -/// Falls back to any other file whose name starts with `readme`. -pub fn find_readme(repo: &gix::Repository) -> Result> { - let Some(workdir) = repo.workdir() else { - return Ok(None); - }; + /// Resolve `rev` to a commit id, accepting full refs or the bare branch names + /// callers pass. `gix`'s revision parser already applies git's ref DWIM. + fn resolve_commit<'a>(&'a self, rev: &str) -> Option> { + self.inner.rev_parse_single(rev.as_bytes()).ok() + } - let mut candidates: Vec = Vec::new(); - for entry in std::fs::read_dir(workdir)? { - let entry = entry?; - let name = entry.file_name(); - let Some(name) = name.to_str() else { continue }; - if name.to_ascii_lowercase().starts_with("readme") { - candidates.push(entry.path()); + /// Relative paths of all entries in the worktree, files and directories. + /// + /// The `.git` directory is skipped. + pub fn entries(&self) -> Result> { + let workdir = self.inner.workdir().context("repository has no worktree")?; + + let mut entries: Vec<(PathBuf, bool)> = Vec::new(); + Self::collect_entries(workdir, workdir, &mut entries)?; + + entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| { + b_is_dir + .cmp(a_is_dir) + .then_with(|| a.as_os_str().cmp(b.as_os_str())) + }); + Ok(entries.into_iter().map(|(path, _)| path).collect()) + } + + /// Read a file from the worktree. + /// + /// Returns `Ok(None)` if the path is missing or not a regular file. + pub fn read(&self, rel: &Path) -> Result>> { + let workdir = self.inner.workdir().context("repository has no worktree")?; + let path = workdir.join(rel); + + match std::fs::read(&path) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None), + Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), } } - candidates.sort_by_key(|path| { - let ext = path - .extension() - .map(|e| e.to_string_lossy().to_ascii_lowercase()); - match ext.as_deref() { - Some("md") => 0, - Some("markdown") => 1, - Some("mdown") => 2, - Some("mkdn") => 3, - Some(_) => 5, - None => 4, - } - }); + /// Find the README file in the repository root. + /// + /// Falls back to any other file whose name starts with `readme`. + pub fn find_readme(&self) -> Result> { + let Some(workdir) = self.inner.workdir() else { + return Ok(None); + }; - Ok(candidates - .into_iter() - .next() - .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) + let mut candidates: Vec = Vec::new(); + for entry in std::fs::read_dir(workdir)? { + let entry = entry?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if name.to_ascii_lowercase().starts_with("readme") { + candidates.push(entry.path()); + } + } + + candidates.sort_by_key(|path| { + let ext = path + .extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()); + match ext.as_deref() { + Some("md") => 0, + Some("markdown") => 1, + Some("mdown") => 2, + Some("mkdn") => 3, + Some(_) => 5, + None => 4, + } + }); + + Ok(candidates + .into_iter() + .next() + .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) + } + + /// Everything the browser needs to refresh after a branch or tag switch. + pub fn snapshot(&self) -> Result { + let readme_path = self.find_readme()?; + let readme = match &readme_path { + Some(path) => self.read(path)?, + None => None, + }; + Ok(WorktreeSnapshot { + entries: self.entries()?, + readme_path, + readme, + current_branch: self.current_branch(), + head_commit: self.head_commit().unwrap_or(None), + branches: self.branches().unwrap_or_default(), + tags: self.tags().unwrap_or_default(), + }) + } + + /// Check out the local branch `name`, HEAD stays attached to it. + pub fn checkout_branch(&self, name: &str) -> Result<()> { + let full = format!("refs/heads/{name}"); + + let branch = gix::refs::FullName::try_from(full.as_str()) + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; + + let mut reference = self.inner.find_reference(&full)?; + let tree = reference.peel_to_tree()?.id; + + let (signature, mut time_buf) = Self::repository_signature(); + let signature = signature.to_ref(&mut time_buf); + + self.move_head( + signature, + gix::refs::Target::Symbolic(branch), + &format!("checkout: moving to {name}"), + )?; + + self.force_checkout(&tree)?; + + Ok(()) + } + + /// Check out the tag `name`, HEAD becomes detached at the tagged commit. + pub fn checkout_tag(&self, name: &str) -> Result<()> { + let full = format!("refs/tags/{name}"); + + let mut reference = self.inner.find_reference(&full)?; + + let commit = reference.peel_to_id()?; + let tree = reference.peel_to_tree()?.id; + + let (signature, mut time_buf) = Self::repository_signature(); + let signature = signature.to_ref(&mut time_buf); + + self.move_head( + signature, + gix::refs::Target::Object(commit.detach()), + &format!("checkout: moving to {name}"), + )?; + + self.force_checkout(&tree)?; + + Ok(()) + } + + pub(crate) fn force_checkout(&self, tree: &gix::hash::oid) -> Result<()> { + let workdir = self + .inner + .workdir() + .context("repository has no worktree")? + .to_path_buf(); + + let mut index = self.inner.index_from_tree(tree)?; + + // Files the previous index tracked but `tree` no longer contains are removed, + // like git deleting files that vanish between branches. + if let Ok(previous) = self.inner.index_or_empty() { + let keep: HashSet = index + .entries() + .iter() + .map(|entry| { + PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned()) + }) + .collect(); + for entry in previous.entries() { + let rel = entry.path(&previous); + let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned()); + + if keep.contains(&rel) { + continue; + } + + let path = workdir.join(&rel); + + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("failed to remove {}", path.display())); + } + } + } + } + + let mut options = self + .inner + .checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?; + options.overwrite_existing = true; + + let objects = self.inner.objects.clone().into_arc()?; + let files = gix::progress::Discard; + let bytes = gix::progress::Discard; + + gix_worktree_state::checkout( + &mut index, + workdir, + objects, + &files, + &bytes, + &gix::interrupt::IS_INTERRUPTED, + options, + )?; + + index.write(gix::index::write::Options::default())?; + + Ok(()) + } + + /// Point `HEAD` at `target` and record the switch in the reflog. + fn move_head( + &self, + signature: gix::actor::SignatureRef<'_>, + target: gix::refs::Target, + message: &str, + ) -> Result<()> { + use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; + + let head = gix::refs::FullName::try_from("HEAD") + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; + + self.inner.edit_references_as( + [RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: message.into(), + }, + expected: PreviousValue::Any, + new: target, + }, + name: head, + deref: false, + }], + Some(signature), + )?; + + Ok(()) + } + + /// Relative paths of all entries below `dir`, relative to `root`. + fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + if entry.file_name() == ".git" { + continue; + } + + let is_dir = entry.file_type()?.is_dir(); + let path = entry.path(); + let rel = path.strip_prefix(root)?.to_path_buf(); + out.push((rel, is_dir)); + + if is_dir { + Self::collect_entries(root, &path, out)?; + } + } + Ok(()) + } } /// Everything the browser needs to refresh after a branch or tag switch. @@ -150,185 +325,10 @@ pub struct WorktreeSnapshot { pub readme: Option>, /// Branch HEAD points to, `None` when detached, for example on a tag. pub current_branch: Option, - /// Commit HEAD points to, if any, see [`head_commit`]. + /// Commit HEAD points to, if any, see [`Repo::head_commit`]. pub head_commit: Option, -} - -/// Collects entries, the README, the branch HEAD points to and its commit. -pub fn worktree_snapshot(workdir: &Path) -> Result { - let repo = gix::open(workdir)?; - let readme_path = find_readme(&repo)?; - let readme = match &readme_path { - Some(path) => worktree_read(&repo, path)?, - None => None, - }; - Ok(WorktreeSnapshot { - entries: worktree_entries(&repo)?, - readme_path, - readme, - current_branch: current_branch(&repo)?, - head_commit: head_commit(&repo)?, - }) -} - -pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> Result<()> { - let workdir = repo - .workdir() - .context("repository has no worktree")? - .to_path_buf(); - - let mut index = repo.index_from_tree(tree)?; - - // Files the previous index tracked but `tree` no longer contains are removed, - // like git deleting files that vanish between branches. - if let Ok(previous) = repo.index_or_empty() { - let keep: HashSet = index - .entries() - .iter() - .map(|entry| PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned())) - .collect(); - for entry in previous.entries() { - let rel = entry.path(&previous); - let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned()); - - if keep.contains(&rel) { - continue; - } - - let path = workdir.join(&rel); - - match std::fs::remove_file(&path) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(error) - .with_context(|| format!("failed to remove {}", path.display())); - } - } - } - } - - let mut options = - repo.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?; - options.overwrite_existing = true; - - let objects = repo.objects.clone().into_arc()?; - let files = gix::progress::Discard; - let bytes = gix::progress::Discard; - - gix_worktree_state::checkout( - &mut index, - workdir, - objects, - &files, - &bytes, - &gix::interrupt::IS_INTERRUPTED, - options, - )?; - - index.write(gix::index::write::Options::default())?; - - Ok(()) -} - -/// Point `HEAD` at `target` and record the switch in the reflog. -fn move_head( - repo: &gix::Repository, - signature: gix::actor::SignatureRef<'_>, - target: gix::refs::Target, - message: &str, -) -> Result<()> { - use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; - - let head = gix::refs::FullName::try_from("HEAD") - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - - repo.edit_references_as( - [RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: message.into(), - }, - expected: PreviousValue::Any, - new: target, - }, - name: head, - deref: false, - }], - Some(signature), - )?; - - Ok(()) -} - -/// Check out the local branch `name`, HEAD stays attached to it. -pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> { - let repo = gix::open(workdir)?; - let full = format!("refs/heads/{name}"); - - let branch = gix::refs::FullName::try_from(full.as_str()) - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - - let mut reference = repo.find_reference(&full)?; - let tree = reference.peel_to_tree()?.id; - - let (signature, mut time_buf) = repository_signature(); - let signature = signature.to_ref(&mut time_buf); - - move_head( - &repo, - signature, - gix::refs::Target::Symbolic(branch), - &format!("checkout: moving to {name}"), - )?; - - force_checkout(&repo, &tree)?; - - Ok(()) -} - -/// Check out the tag `name`, HEAD becomes detached at the tagged commit. -pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> { - let repo = gix::open(workdir)?; - let full = format!("refs/tags/{name}"); - - let mut reference = repo.find_reference(&full)?; - - let commit = reference.peel_to_id()?; - let tree = reference.peel_to_tree()?.id; - - let (signature, mut time_buf) = repository_signature(); - let signature = signature.to_ref(&mut time_buf); - - move_head( - &repo, - signature, - gix::refs::Target::Object(commit.detach()), - &format!("checkout: moving to {name}"), - )?; - - force_checkout(&repo, &tree)?; - - Ok(()) -} - -fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - if entry.file_name() == ".git" { - continue; - } - - let is_dir = entry.file_type()?.is_dir(); - let path = entry.path(); - let rel = path.strip_prefix(root)?.to_path_buf(); - out.push((rel, is_dir)); - - if is_dir { - collect_entries(root, &path, out)?; - } - } - Ok(()) + /// Short names of local branches, sorted alphabetically. + pub branches: Vec, + /// Short names of tags, sorted alphabetically. + pub tags: Vec, } diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 2929ac3..df799bf 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -12,6 +12,7 @@ use nostr_connect::prelude::*; use nostr_sdk::client::SyncSummary; use nostr_sdk::prelude::*; use signed_core::{Announcement, Filters, RepoAddr, RepoState, filters}; +use signed_git::{GitCache, Repo}; use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; use crate::git_store::repo_mirror_path; @@ -408,7 +409,7 @@ impl Backend { // Initialize directly at the user's chosen destination. let destination = { - let dir_name = signed_git::sanitize_path_component(&name); + let dir_name = GitCache::sanitize_path_component(&name); let dir_name = if dir_name.is_empty() { "repository".to_owned() } else { @@ -431,11 +432,11 @@ impl Backend { bail!("destination {} already exists", destination.display()); } - let commit = signed_git::init_repository(&destination, &name, &description)?; + let commit = Repo::init(&destination, &name, &description)?; if let Some(base) = servers.first().and_then(grasp_base_url) { let url = format!("{base}/{owner}/{repo_id}.git"); - signed_git::set_origin(&destination, &url)?; + Repo::open(&destination)?.set_origin(&url)?; } Ok::<_, Error>(commit) @@ -500,7 +501,9 @@ impl Backend { &owner, &servers, &executor, - signed_git::push_main, + |path, base, owner, repo_id| { + Repo::open(path)?.push_main(base, owner, repo_id) + }, ) .await } @@ -585,8 +588,9 @@ impl Backend { let work = cx.background_spawn({ let path = path.clone(); async move { - let state = signed_git::worktree_ref_state(&path)?; - let euc = signed_git::root_commit(&path)?; + let repo = Repo::open(&path)?; + let state = repo.ref_state()?; + let euc = repo.root_commit()?; Ok::<_, Error>((state, euc)) } }); @@ -651,7 +655,9 @@ impl Backend { &owner, &servers, &executor, - signed_git::push_all, + |path, base, owner, repo_id| { + Repo::open(path)?.push_all(base, owner, repo_id) + }, ) .await } @@ -687,7 +693,9 @@ impl Backend { let url = format!("{base}/{owner}/{repo_id}.git"); let path = path.clone(); cx.background_spawn(async move { - signed_git::ensure_origin(&path, &url).ok(); + Repo::open(&path) + .and_then(|repo| repo.ensure_origin(&url)) + .ok(); }) .await; } @@ -699,7 +707,9 @@ impl Backend { Ok(naddr) => { let path = path.clone(); cx.background_spawn(async move { - if let Err(error) = signed_git::set_nostr_repo(&path, &naddr) { + if let Err(error) = + Repo::open(&path).and_then(|repo| repo.set_nostr_repo(&naddr)) + { log::warn!( "failed to record the NIP-34 marker for {}: {error}", path.display() @@ -783,7 +793,7 @@ impl Backend { let mut state = { let work = cx.background_spawn({ let path = path.clone(); - async move { signed_git::worktree_ref_state(&path) } + async move { Repo::open(&path).and_then(|repo| repo.ref_state()) } }); work.await? }; @@ -837,7 +847,9 @@ impl Backend { &owner, &relays, &executor, - signed_git::push_all, + |path, base, owner, repo_id| { + Repo::open(path)?.push_all(base, owner, repo_id) + }, ) .await } @@ -1571,7 +1583,7 @@ async fn push_staged_to_grasps( owner: &str, servers: &[RelayUrl], executor: &BackgroundExecutor, - push: fn(&Path, &str, &str, &str) -> Result<(), Error>, + push: impl Fn(&Path, &str, &str, &str) -> Result<(), Error>, ) -> PushOutcome { let mut outcome = PushOutcome::default(); @@ -1647,7 +1659,9 @@ async fn push_staged_to_grasps( // pushed data is already there. if let Some(last_reason) = &reason && is_stale_advertisement_race(last_reason) - && signed_git::remote_has_refs(path, &git_url, refs).unwrap_or(false) + && Repo::open(path) + .and_then(|repo| repo.remote_has_refs(&git_url, refs)) + .unwrap_or(false) { if let Some(event) = staged_event { keep_newest(&mut outcome.state_event, event); diff --git a/crates/signed_state/src/checkouts.rs b/crates/signed_state/src/checkouts.rs index 67f48f5..0809319 100644 --- a/crates/signed_state/src/checkouts.rs +++ b/crates/signed_state/src/checkouts.rs @@ -7,6 +7,7 @@ use gpui::{App, AppContext, Context, Entity, Global, Subscription}; use nostr::prelude::*; use settings::{CheckoutRecord, SettingsStore}; use signed_core::{Announcement, RepoAddr}; +use signed_git::Repo; use utils::same_repo_url; use crate::backend::{Backend, BackendEvent}; @@ -338,8 +339,14 @@ impl CheckoutsStore { { continue; } - let origin = signed_git::origin_url(path).ok().flatten(); - let root = signed_git::root_commit(path).ok().flatten(); + let origin = Repo::open(path) + .and_then(|repo| repo.origin_url()) + .ok() + .flatten(); + let root = Repo::open(path) + .and_then(|repo| repo.root_commit()) + .ok() + .flatten(); facts.push((path.clone(), origin, root)); } @@ -555,14 +562,15 @@ fn resolve_associations<'a>( } fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option { - let branches = signed_git::worktree_branches(path).ok()?; + let repo = Repo::try_open(path)?; + let branches = repo.branches().ok()?; - if branches.is_empty() || signed_git::worktree_dirty(path) { + if branches.is_empty() || repo.is_dirty() { return None; } - let branch = signed_git::worktree_current_branch(path)?; - let head = signed_git::head_commit_id(path).ok().flatten()?; + let branch = repo.current_branch()?; + let head = repo.head()?; let base = announced_head .filter(|name| branches.iter().any(|b| b == name)) .map(str::to_owned) @@ -573,7 +581,7 @@ fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option 0).then_some(CheckoutStatus { path: path.to_path_buf(), branch, @@ -585,31 +593,33 @@ fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option Option { - if signed_git::worktree_dirty(path) { + let repo = Repo::try_open(path)?; + if repo.is_dirty() { return None; } - let branch = signed_git::worktree_current_branch(path)?; - let head = signed_git::head_commit_id(path).ok().flatten()?; - let origin = signed_git::origin_url(path).ok().flatten()?; + let branch = repo.current_branch()?; + let head = repo.head()?; + let origin = repo.origin_url().ok().flatten()?; if fetch { - signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok(); + repo.fetch_refs(&[origin], "+refs/heads/*:refs/remotes/origin/*") + .ok(); } let remote = format!("refs/remotes/origin/{branch}"); // A branch never fetched or pushed yet compares against the remote HEAD. // The remote HEAD is the fork point in practice. - let base = if signed_git::worktree_ref_exists(path, &remote) { + let base = if repo.ref_exists(&remote) { remote - } else if signed_git::worktree_ref_exists(path, "refs/remotes/origin/HEAD") { + } else if repo.ref_exists("refs/remotes/origin/HEAD") { "refs/remotes/origin/HEAD".to_owned() } else { return None; }; - let ahead = signed_git::worktree_commits_ahead(path, &base, &branch); + let ahead = repo.commits_ahead(&base, &branch); (ahead > 0).then_some(CheckoutStatus { path: path.to_path_buf(), @@ -730,7 +740,7 @@ mod tests { fn checkout_status_reports_ahead_branches_only() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("repo"); - let _initial = signed_git::init_repository(&path, "My Repo", "").expect("init"); + let _initial = Repo::init(&path, "My Repo", "").expect("init"); let run = |args: &[&str]| { let status = Command::new("git") .current_dir(&path) @@ -777,7 +787,7 @@ mod tests { // Act like a grasp server and allow them. let dir = tempfile::tempdir().expect("tempdir"); let remote = dir.path().join("remote"); - signed_git::init_repository(&remote, "My Repo", "").expect("init"); + Repo::init(&remote, "My Repo", "").expect("init"); let config = Command::new("git") .args(["config", "receive.denyCurrentBranch", "ignore"]) .current_dir(&remote) diff --git a/crates/signed_state/src/git_store.rs b/crates/signed_state/src/git_store.rs index 141a127..961136c 100644 --- a/crates/signed_state/src/git_store.rs +++ b/crates/signed_state/src/git_store.rs @@ -2,10 +2,9 @@ use std::path::PathBuf; use std::sync::OnceLock; use anyhow::Result; -use gix::Repository; use nostr::prelude::Url; use signed_core::RepoAddr; -use signed_git::GitCache; +use signed_git::{GitCache, Repo}; static GIT_CACHE: OnceLock = OnceLock::new(); @@ -26,12 +25,12 @@ pub fn repo_mirror_path(addr: &RepoAddr) -> PathBuf { } /// Open the mirror of `addr`, if it has been cloned. -pub fn open_repo_mirror(addr: &RepoAddr) -> Result> { +pub fn open_repo_mirror(addr: &RepoAddr) -> Result> { git_cache().open(addr) } /// Open the mirror of `addr`, cloning it first when it does not exist yet. -pub fn ensure_repo_mirror(addr: &RepoAddr, clone_urls: &[Url]) -> Result { +pub fn ensure_repo_mirror(addr: &RepoAddr, clone_urls: &[Url]) -> Result { git_cache().ensure_clone(addr, clone_urls) } diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index b1e09bf..3d5e139 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -12,7 +12,7 @@ use signed_core::{ Announcement, Deletions, Filters, GitEvent, PullRequest, RepoAddr, RepoState, RepoStatus, filters, }; -use signed_git::Nip34Binding; +use signed_git::{Nip34Binding, PatchParser, Repo}; use signed_nostr::UniversalSigner; use crate::backend::{ @@ -700,7 +700,7 @@ impl RepoStore { return; }; - let series: Vec = signed_git::split_patch_series(&patch) + let series: Vec = PatchParser::split_patch_series(&patch) .into_iter() .map(str::to_owned) .collect(); @@ -906,9 +906,9 @@ impl RepoStore { let mut pushed = 0; for (url, label) in &targets { - match signed_git::push_commit_ref( - &path, url, &tip, &reference, - ) { + match Repo::open(&path) + .and_then(|repo| repo.push_ref(url, &tip, &reference)) + { Ok(()) => pushed += 1, Err(e) => failures.push(format!("{label}: {e}")), } @@ -986,7 +986,7 @@ impl RepoStore { let merge_base = merge_base.clone(); let compare_ref = compare_ref.clone(); async move { - signed_git::format_patch_between(&repo_path, &merge_base, &compare_ref) + Repo::open(&repo_path)?.format_patch_between(&merge_base, &compare_ref) } }) .await; @@ -1037,7 +1037,7 @@ impl RepoStore { return; } - let series: Vec = signed_git::split_patch_series(&patch) + let series: Vec = PatchParser::split_patch_series(&patch) .into_iter() .map(str::to_owned) .collect(); @@ -1368,7 +1368,9 @@ impl RepoStore { let clone = { let destination = destination.clone(); - cx.background_spawn(async move { signed_git::clone_repo(&clone_urls, &destination) }) + // The clone's repository handle is dropped in the task: gix handles + // are not `Send`, they must not cross the spawn boundary. + cx.background_spawn(async move { Repo::clone(&clone_urls, &destination).map(|_| ()) }) }; cx.spawn(async move |this, cx| { @@ -1596,11 +1598,10 @@ fn comment_builder( #[cfg(test)] mod tests { - use signed_core::GitEvent; - use std::collections::HashSet; use nostr_sdk::prelude::*; + use signed_core::GitEvent; use super::{RepoStore, comment_builder, patch_current_commit}; diff --git a/crates/workspace/src/views/commit_diff/mod.rs b/crates/workspace/src/views/commit_diff/mod.rs index 17799c8..e91702c 100644 --- a/crates/workspace/src/views/commit_diff/mod.rs +++ b/crates/workspace/src/views/commit_diff/mod.rs @@ -17,7 +17,9 @@ use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree}; use gpui_component::{ ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; -use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff}; +use signed_git::{ + CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo, +}; use signed_state::RepoStore; use signed_ui::{placeholder, tree_row}; use utils::relative_time_secs; @@ -355,14 +357,14 @@ impl CommitDiffView { .background_spawn({ let worktree = worktree.clone(); let id = id.clone(); - async move { signed_git::worktree_commit(&worktree, &id) } + async move { Repo::open(&worktree).and_then(|repo| repo.commit(&id)) } }) .await; let diff = cx .background_spawn({ let worktree = worktree.clone(); let id = id.clone(); - async move { signed_git::worktree_commit_diff(&worktree, &id) } + async move { Repo::open(&worktree).and_then(|repo| repo.commit_diff(&id)) } }) .await; diff --git a/crates/workspace/src/views/pull_requests/detail.rs b/crates/workspace/src/views/pull_requests/detail.rs index c32c71d..7b75eff 100644 --- a/crates/workspace/src/views/pull_requests/detail.rs +++ b/crates/workspace/src/views/pull_requests/detail.rs @@ -22,7 +22,7 @@ use gpui_component::{ }; use nostr::prelude::{Event, EventId, Kind, Url}; use signed_core::{GitEvent, PullRequest, RepoAddr}; -use signed_git::{FileCommit, patch_commits, patch_diffs}; +use signed_git::{FileCommit, PatchParser}; use signed_state::{Backend, ProfileStore, RepoStore, ensure_repo_mirror}; use signed_ui::{Avatar, CountBadge, placeholder, status_badge}; use utils::{relative_time, relative_time_secs}; @@ -233,14 +233,14 @@ impl PullRequestDetailView { let nostr_diff = cx .background_spawn({ let patch = patch.clone(); - async move { patch_diffs(&patch) } + async move { PatchParser::patch_diffs(&patch) } }) .await; let nostr_commits = cx .background_spawn({ let patch = patch.clone(); - async move { patch_commits(&patch) } + async move { PatchParser::patch_commits(&patch) } }) .await; @@ -262,8 +262,9 @@ impl PullRequestDetailView { Some( cx.background_spawn(async move { let repo = ensure_repo_mirror(&addr, &clone_urls)?; + let gix_repo = repo.inner(); - let workdir = repo + let workdir = gix_repo .workdir() .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? .to_path_buf(); @@ -275,17 +276,16 @@ impl PullRequestDetailView { Some(base) => base, // No `merge-base` tag. Use the merge base of the tip and the default branch. None => { - let head = repo + let head = gix_repo .head_id() .map_err(|_| anyhow::anyhow!("repository has no HEAD"))?; - let tip_id = repo.rev_parse_single(tip.as_bytes())?; - repo.merge_base(tip_id, head)?.to_string() + let tip_id = gix_repo.rev_parse_single(tip.as_bytes())?; + gix_repo.merge_base(tip_id, head)?.to_string() } }; - let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?; - let commits = - signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?; + let diff = repo.range_diff(&base, &tip)?; + let commits = repo.commit_range(&base, &tip)?; Ok::<_, anyhow::Error>((diff, commits, workdir)) }) diff --git a/crates/workspace/src/views/pull_requests/new.rs b/crates/workspace/src/views/pull_requests/new.rs index 2762655..86e6744 100644 --- a/crates/workspace/src/views/pull_requests/new.rs +++ b/crates/workspace/src/views/pull_requests/new.rs @@ -22,10 +22,7 @@ use gpui_component::{ }; use nostr::prelude::*; use signed_core::{Announcement, RepoAddr}; -use signed_git::{ - delete_refs_with_prefix, fetch_repo_refs, fork_namespace, merge_base, refs_with_prefix, - worktree_commit_range_commits, worktree_commit_range_diff, -}; +use signed_git::{GitCache, Repo}; use signed_state::{ Backend, CheckoutsStore, RepoListStore, RepoStore, ensure_repo_mirror, repo_mirror_path, }; @@ -399,10 +396,12 @@ impl NewPullRequestView { .background_spawn({ let path = path.clone(); async move { - let repo = gix::open(Path::new(&path)).ok()?; - let branches = - signed_git::worktree_branches(Path::new(&path)).unwrap_or_default(); - let current = signed_git::current_branch(&repo).ok().flatten(); + let repo = Repo::try_open(Path::new(&path)); + let branches = repo + .as_ref() + .and_then(|repo| repo.branches().ok()) + .unwrap_or_default(); + let current = repo.as_ref().and_then(|repo| repo.current_branch()); Some((branches, current)) } }) @@ -533,7 +532,7 @@ impl NewPullRequestView { return; }; let mirror_path = repo_mirror_path(&base); - let namespace = fork_namespace(&announcement); + let namespace = GitCache::fork_namespace(&announcement); let clone_urls = announcement.clone.clone(); let base_clone_urls: Vec = self @@ -568,13 +567,13 @@ impl NewPullRequestView { let mirror_path = mirror_path.clone(); async move { ensure_repo_mirror(&base, &base_clone_urls)?; + let repo = Repo::open(&mirror_path)?; // Prune stale imports of any fork. Then import this fork's heads under its namespace. - delete_refs_with_prefix(&mirror_path, "refs/fork")?; + repo.delete_refs_with_prefix("refs/fork")?; // Fetch the fork's refs and import them under the fork's namespace. - fetch_repo_refs( - &mirror_path, + repo.fetch_refs( &clone_urls, &format!("+refs/heads/*:refs/fork/{namespace}/*"), )?; @@ -594,12 +593,12 @@ impl NewPullRequestView { }; let base_branches = strip( - refs_with_prefix(&mirror_path, "refs/remotes/origin")?, + repo.refs_with_prefix("refs/remotes/origin")?, "refs/remotes/origin", ); let compare_branches = strip( - refs_with_prefix(&mirror_path, &format!("refs/fork/{namespace}"))?, + repo.refs_with_prefix(&format!("refs/fork/{namespace}"))?, &format!("refs/fork/{namespace}"), ); @@ -773,24 +772,17 @@ impl NewPullRequestView { let base_name = base_name.clone(); let compare_name = compare_name.clone(); async move { - let merge_base = merge_base(Path::new(&repo_path), &base, &compare)? - .ok_or_else(|| { + let repo = Repo::open(Path::new(&repo_path))?; + let merge_base = + repo.merge_base(&base, &compare)?.ok_or_else(|| { anyhow::anyhow!( "{base_name} and {compare_name} share no common ancestor" ) })?; - let commits = worktree_commit_range_commits( - Path::new(&repo_path), - &merge_base, - &compare, - )?; + let commits = repo.commit_range(&merge_base, &compare)?; - let diff = worktree_commit_range_diff( - Path::new(&repo_path), - &merge_base, - &compare, - )?; + let diff = repo.range_diff(&merge_base, &compare)?; Ok::<_, anyhow::Error>((merge_base, commits, diff)) } diff --git a/crates/workspace/src/views/repo/files.rs b/crates/workspace/src/views/repo/files.rs index ebd3fe9..c9f1bba 100644 --- a/crates/workspace/src/views/repo/files.rs +++ b/crates/workspace/src/views/repo/files.rs @@ -11,7 +11,7 @@ use gpui_component::spinner::Spinner; use gpui_component::text::{TextView, TextViewState}; use gpui_component::tree::{TreeEntry, TreeState, tree}; use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex}; -use signed_git::{FileCommit, WorktreeSnapshot}; +use signed_git::{FileCommit, Repo, WorktreeSnapshot}; use signed_ui::{placeholder, tree_row}; use crate::views::tree::{TreeItemSeed, tree_items}; @@ -611,9 +611,9 @@ impl RepoFilesView { let task: Task> = cx.spawn(async move |this, cx| { let rels: Vec = paths.iter().map(PathBuf::from).collect(); let result = cx - .background_spawn( - async move { signed_git::worktree_last_commits(&worktree, &rels) }, - ) + .background_spawn(async move { + Repo::open_cached(&worktree).and_then(|repo| repo.last_commits(&rels)) + }) .await; this.update(cx, |this, cx| { diff --git a/crates/workspace/src/views/repo/history.rs b/crates/workspace/src/views/repo/history.rs index 21360ed..52dda2f 100644 --- a/crates/workspace/src/views/repo/history.rs +++ b/crates/workspace/src/views/repo/history.rs @@ -8,7 +8,7 @@ use gpui::{Context, Entity, Pixels, Render, Size, Task, WeakEntity, Window, div, use gpui_component::scroll::Scrollbar; use gpui_component::spinner::Spinner; use gpui_component::{ActiveTheme, Sizable, VirtualListScrollHandle, v_flex, v_virtual_list}; -use signed_git::CommitList; +use signed_git::{CommitList, Repo}; use signed_state::RepoStore; use signed_ui::placeholder; @@ -69,7 +69,9 @@ impl RepoHistoryView { let task: Task> = cx.spawn(async move |this, cx| { let result = cx - .background_spawn(async move { signed_git::worktree_all_commits(&worktree) }) + .background_spawn(async move { + Repo::open_cached(&worktree).and_then(|repo| repo.all_commits()) + }) .await; this.update(cx, |this, cx| { diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 23f9dac..30a8dd4 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -5,7 +5,6 @@ use std::rc::Rc; use anyhow::Error; use assets::CustomIconName; use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle}; -use gix::Repository; use gpui::prelude::*; use gpui::{ Action, Anchor, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, @@ -24,7 +23,7 @@ use gpui_component::{ use nostr::nips::nip19::Nip19Coordinate; use nostr::prelude::{RelayUrl, ToBech32, Url}; use signed_core::{Announcement, RepoAddr, RepoStatus}; -use signed_git::FileCommit; +use signed_git::{FileCommit, GitCache, Repo}; use signed_state::{ Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, ProfileStore, RepoListStore, RepoStore, ensure_repo_mirror, open_repo_mirror, @@ -326,7 +325,7 @@ impl RepoDetailView { let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { let data = cx .background_spawn(async move { - let repo = gix::open(&local_path)?; + let repo = Repo::open(&local_path)?; load_repo_data(&repo) }) .await; @@ -409,37 +408,22 @@ impl RepoDetailView { }; // Best-effort, a fetch failure, e.g. offline, keeps the cached state. - signed_git::fetch_all(&repo).ok(); + repo.fetch().ok(); - let worktree = repo.workdir().map(Path::to_path_buf); + let moved = repo.fast_forward_branches().unwrap_or(false); - let moved = match &worktree { - Some(worktree) => { - signed_git::fast_forward_branches(worktree).unwrap_or(false) - } - None => false, - }; - - let (branches, tags) = match &worktree { - Some(_) => ( - signed_git::repo_branches(&repo).unwrap_or_default(), - signed_git::repo_tags(&repo).unwrap_or_default(), - ), - None => (Vec::new(), Vec::new()), - }; - - let current_branch = signed_git::current_branch(&repo).unwrap_or(None); - let head_commit = signed_git::head_commit(&repo).unwrap_or(None); - - Ok::<_, Error>(Some((moved, branches, tags, current_branch, head_commit))) + Ok::<_, Error>(Some((moved, repo.snapshot()?))) }) } .await; this.update_in(cx, |this, window, cx| { - if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh { - let branches: Vec = branches.iter().map(Into::into).collect(); - let tags: Vec = tags.iter().map(Into::into).collect(); + if let Ok(Some((moved, snapshot))) = refresh { + let branches: Vec = + snapshot.branches.iter().map(Into::into).collect(); + let tags: Vec = snapshot.tags.iter().map(Into::into).collect(); + let current_branch = snapshot.current_branch.clone(); + let head_commit = snapshot.head_commit.clone(); let branches_changed = this.refs.set_branches( branches, @@ -532,7 +516,7 @@ impl RepoDetailView { .map(|name| name.to_string()) .filter(|name| !name.trim().is_empty()) .unwrap_or_else(|| addr.identifier().to_owned()); - let name = signed_git::sanitize_path_component(&name); + let name = GitCache::sanitize_path_component(&name); if name.is_empty() { "repository".to_owned() @@ -617,13 +601,12 @@ impl RepoDetailView { let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { let result = cx .background_spawn(async move { + let repo = Repo::open(&worktree); match kind { RefKind::Branch => { - signed_git::worktree_checkout_branch(&worktree, &checkout_name) - } - RefKind::Tag => { - signed_git::worktree_checkout_tag(&worktree, &checkout_name) + repo.and_then(|repo| repo.checkout_branch(&checkout_name)) } + RefKind::Tag => repo.and_then(|repo| repo.checkout_tag(&checkout_name)), } }) .await; @@ -665,7 +648,7 @@ impl RepoDetailView { let task: gpui::Task> = cx.spawn(async move |this, cx| { let result = cx .background_spawn(async move { - let snapshot = signed_git::worktree_snapshot(&worktree)?; + let snapshot = Repo::open(&worktree)?.snapshot()?; // Build the tree off the main thread, like [`Self::load_repo`]. let tree = build_tree_items(&snapshot.entries); let paths = sorted_worktree_paths(&snapshot.entries); @@ -716,7 +699,7 @@ impl RepoDetailView { let task: gpui::Task> = cx.spawn(async move |this, cx| { let result = cx .background_spawn(async move { - let snapshot = signed_git::worktree_snapshot(&worktree)?; + let snapshot = Repo::open(&worktree)?.snapshot()?; let tree = build_tree_items(&snapshot.entries); let paths = sorted_worktree_paths(&snapshot.entries); Ok::<_, Error>((snapshot, tree, paths)) @@ -1952,38 +1935,21 @@ fn truncate_naddr_link(url: &str, tail: usize) -> String { format!("{}...{}", &url[..end], &url[url.len() - tail..]) } -fn load_repo_data(repo: &Repository) -> Result { - let entries = signed_git::worktree_entries(repo)?; - let tree = build_tree_items(&entries); - let readme_path = signed_git::find_readme(repo)?; - - let readme = match &readme_path { - Some(path) => signed_git::worktree_read(repo, path)?, - None => None, - }; - +fn load_repo_data(repo: &Repo) -> Result { + let snapshot = repo.snapshot()?; let worktree = repo.workdir().map(Path::to_path_buf); - let head_commit = signed_git::head_commit(repo).unwrap_or(None); - - let (branches, tags, current_branch) = match &worktree { - Some(_) => ( - signed_git::repo_branches(repo).unwrap_or_default(), - signed_git::repo_tags(repo).unwrap_or_default(), - signed_git::current_branch(repo).unwrap_or(None), - ), - None => (Vec::new(), Vec::new(), None), - }; + let tree = build_tree_items(&snapshot.entries); Ok(RepoData { tree, - entries, - readme_path, - readme, + entries: snapshot.entries, + readme_path: snapshot.readme_path, + readme: snapshot.readme, worktree, - branches, - tags, - current_branch, - head_commit, + branches: snapshot.branches, + tags: snapshot.tags, + current_branch: snapshot.current_branch, + head_commit: snapshot.head_commit, }) } -- 2.54.0 From 6706e328ec8f360811291c72321f62ce0355a5f6 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 3 Oct 2026 16:05:47 +0700 Subject: [PATCH 7/9] refactor module functions into methods on store types --- crates/signed_state/src/backend.rs | 1113 ++++------------- crates/signed_state/src/bootstrap.rs | 99 ++ crates/signed_state/src/checkouts.rs | 337 ++--- crates/signed_state/src/git_store.rs | 54 +- crates/signed_state/src/inbox.rs | 254 ++-- crates/signed_state/src/lib.rs | 17 +- crates/signed_state/src/local_repos.rs | 64 +- crates/signed_state/src/profile.rs | 3 +- crates/signed_state/src/push.rs | 577 +++++++++ crates/signed_state/src/repo.rs | 188 ++- crates/workspace/src/views/inbox.rs | 4 +- .../src/views/pull_requests/detail.rs | 4 +- .../workspace/src/views/pull_requests/new.rs | 8 +- crates/workspace/src/views/repo/mod.rs | 19 +- crates/workspace/src/views/sidebar/mod.rs | 4 +- 15 files changed, 1397 insertions(+), 1348 deletions(-) create mode 100644 crates/signed_state/src/bootstrap.rs create mode 100644 crates/signed_state/src/push.rs diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index df799bf..140203b 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -1,31 +1,28 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::{Duration, Instant}; use anyhow::{Error, anyhow, bail}; use bitcoin_hashes::sha1::Hash as Sha1Hash; -use gpui::{App, AppContext, BackgroundExecutor, Context, Entity, EventEmitter, Global, Task}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, Global, Task, WeakEntity}; use nostr::event::IntoEventBuilder; use nostr::nips::nip19::Nip19Coordinate; use nostr_connect::prelude::*; -use nostr_sdk::client::SyncSummary; use nostr_sdk::prelude::*; -use signed_core::{Announcement, Filters, RepoAddr, RepoState, filters}; +use signed_core::{Announcement, Filters, RepoAddr, filters}; use signed_git::{GitCache, Repo}; use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; -use crate::git_store::repo_mirror_path; +use crate::bootstrap::{subscribe_bootstrap_only, sync_bootstrap_only, user_grasp_list_servers}; +use crate::git_store::Mirrors; use crate::inbox::Inbox; +use crate::push::{GraspPush, PushOutcome, grasp_base_url, grasp_clone_url}; use crate::repos::RepoListStore; pub const USER_KEYRING: &str = "Signed Safe Storage"; /// Timeout for NIP-46 signer responses. pub const NOSTR_CONNECT_TIMEOUT: u64 = 60; -/// Relays connected at startup, before any user-specific relay config is known. -pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"]; -/// Relays used to index the user's NIP-65 relay list. -pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"]; const PUMP_DEBOUNCE: Duration = Duration::from_millis(200); @@ -89,7 +86,7 @@ impl Backend { let mut seen: HashSet = HashSet::new(); 'outer: loop { - match next_update(&mut notifications, &mut seen).await { + match UpdateEvent::next(&mut notifications, &mut seen).await { Some(UpdateEvent::Profile(author)) => { pending_profiles.insert(author); } @@ -109,7 +106,7 @@ impl Backend { let timer = cx.background_executor().timer(deadline - now); futures::pin_mut!(timer); - let next = next_update(&mut notifications, &mut seen); + let next = UpdateEvent::next(&mut notifications, &mut seen); futures::pin_mut!(next); match futures::future::select(next, timer).await { @@ -338,20 +335,16 @@ impl Backend { .map(|url| RelayUrl::parse(url).expect("valid relay URL")) .collect(); - let client = this.client.clone(); - let signer = this.signer.clone(); + let pusher = GraspPush::new(this.client.clone(), this.signer.clone()); for builder in [ RelayList::new(relays).into_event_builder(), metadata, GitUserGraspList { grasp_servers }.into_event_builder(), ] { - let client = client.clone(); - let signer = signer.clone(); - cx.spawn(async move |_this, _cx| { - publish_best_effort(&client, &signer, builder).await - }) - .detach(); + let pusher = pusher.clone(); + cx.spawn(async move |_this, _cx| pusher.publish_best_effort(builder).await) + .detach(); } })?; @@ -446,94 +439,32 @@ impl Backend { let commit = work.await?; let commit_sha = Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid id"))?; - // The nostr client queues events until each relay is connected. - for url in &servers { - client.add_relay(url).and_connect().await.ok(); - } - - // The state event is the push authorization. It must be accepted before the push below. - let announcement = GitRepositoryAnnouncement { - id: repo_id.clone(), - name: Some(name.clone()), - description: (!description.is_empty()).then_some(description.clone()), - web: Vec::new(), - clone: servers - .iter() - .filter_map(|relay| grasp_clone_url(relay, &owner, &repo_id)) - .collect(), - relays: servers.clone(), - euc: Some(commit_sha), - maintainers: Vec::new(), - }; - let signer = this.update(cx, |this, _cx| this.signer.clone())?; - let event = { - let builder = announcement.into_event_builder(); - let event = builder.finalize_async(&signer).await?; - let output = client.send_event(&event).broadcast().await?; - require_relay_accepted(output, event)? - }; + let announcement = repository_announcement( + &repo_id, + &name, + &description, + &owner, + &servers, + Some(commit_sha), + ); - // The state event is the push authorization. Stage it on each - // grasp server's relay, then push the initial commit. - // Creation fails only when no server accepted the push, the announcement - // is then retracted so the repository is not left announced without content. - let refs = vec![("refs/heads/main".to_owned(), commit)]; - - let push = cx.background_spawn({ - let client = client.clone(); - let signer = signer.clone(); - let destination = destination.clone(); - let owner = owner.clone(); - let repo_id = repo_id.clone(); - let servers = servers.clone(); - let refs = refs.clone(); - let executor = cx.background_executor().clone(); - async move { - push_staged_to_grasps( - &client, - &signer, - &repo_id, - &refs, - Some("main"), - &destination, - &owner, - &servers, - &executor, - |path, base, owner, repo_id| { - Repo::open(path)?.push_main(base, owner, repo_id) - }, - ) - .await - } - }); - - let outcome = push.await; - - if outcome.accepted() == 0 { - // The announcement is already published. - // Retract it so the repository is not left announced without content. - this.update(cx, |this, cx| { - this.retract_events(std::slice::from_ref(&event), cx); - }) - .ok(); - - return Err(anyhow!( - "The repository was announced, but the push to every grasp server failed: {}. \ - The announcement has been retracted", - outcome.failure_summary() - )); - } - - // Fan the state out to the relays once a git server holds the objects. - // Staging already stored the event locally, publishing makes it - // visible to the other relays and clients. - if let Some(state_event) = &outcome.state_event - && let Err(e) = client.send_event(state_event).broadcast().await - { - log::warn!("failed to broadcast repository state: {e}"); - } + let event = announce_repository_and_push( + &this, + &client, + &signer, + announcement, + &repo_id, + &owner, + &servers, + vec![("refs/heads/main".to_owned(), commit)], + Some("main".to_owned()), + &destination, + cx, + |path, base, owner, repo_id| Repo::open(path)?.push_main(base, owner, repo_id), + ) + .await?; let announcement = Announcement::from_event(&event) .ok_or_else(|| anyhow!("failed to parse announcement"))?; @@ -596,97 +527,28 @@ impl Backend { }); let (state, euc) = work.await?; - // The nostr client queues events until each relay is connected. - for url in &servers { - client.add_relay(url).and_connect().await.ok(); - } - - // The state event is the push authorization. It must be accepted before the push below. - let announcement = GitRepositoryAnnouncement { - id: repo_id.clone(), - name: Some(name.clone()), - description: (!description.is_empty()).then_some(description.clone()), - web: Vec::new(), - clone: servers - .iter() - .filter_map(|relay| grasp_clone_url(relay, &owner, &repo_id)) - .collect(), - relays: servers.clone(), - euc: euc.and_then(|commit| Sha1Hash::from_str(&commit).ok()), - maintainers: Vec::new(), - }; - let signer = this.update(cx, |this, _cx| this.signer.clone())?; - let event = { - let builder = announcement.into_event_builder(); - let event = builder.finalize_async(&signer).await?; - let output = client.send_event(&event).broadcast().await?; - require_relay_accepted(output, event)? - }; + let euc = euc.and_then(|commit| Sha1Hash::from_str(&commit).ok()); + let announcement = + repository_announcement(&repo_id, &name, &description, &owner, &servers, euc); - let refs = state.refs.clone(); - let head = state.head.clone(); - - // The state event is the push authorization. Stage it on each - // grasp server's relay, then push every branch and tag. The push - // fails only when no server accepted it. The announcement is then - // retracted so the repository is not left announced without content. - // An empty repository has no state to stage and nothing to push. - if !refs.is_empty() { - let push = cx.background_spawn({ - let client = client.clone(); - let signer = signer.clone(); - let path = path.clone(); - let owner = owner.clone(); - let repo_id = repo_id.clone(); - let servers = servers.clone(); - let refs = refs.clone(); - let head = head.clone(); - let executor = cx.background_executor().clone(); - async move { - push_staged_to_grasps( - &client, - &signer, - &repo_id, - &refs, - head.as_deref(), - &path, - &owner, - &servers, - &executor, - |path, base, owner, repo_id| { - Repo::open(path)?.push_all(base, owner, repo_id) - }, - ) - .await - } - }); - let outcome = push.await; - - if outcome.accepted() == 0 { - // The announcement is already published. Retract it so - // the repository is not left announced without content. - this.update(cx, |this, cx| { - this.retract_events(std::slice::from_ref(&event), cx); - }) - .ok(); - - return Err(anyhow!( - "The repository was announced, but the push to every grasp server failed: {}. \ - The announcement has been retracted", - outcome.failure_summary() - )); - } - - // Fan the state out to the relays once a git server holds the objects. - // Staging already stored the event locally, publishing makes it visible to the other relays and clients. - if let Some(state_event) = &outcome.state_event - && let Err(e) = client.send_event(state_event).broadcast().await - { - log::warn!("failed to broadcast repository state: {e}"); - } - } + // The state event is the push authorization. It must be accepted before the push below. + let event = announce_repository_and_push( + &this, + &client, + &signer, + announcement, + &repo_id, + &owner, + &servers, + state.refs.clone(), + state.head.clone(), + &path, + cx, + |path, base, owner, repo_id| Repo::open(path)?.push_all(base, owner, repo_id), + ) + .await?; // Point `origin` at the first grasp server so later pushes have a target. if let Some(base) = servers.first().and_then(grasp_base_url) { @@ -734,7 +596,7 @@ impl Backend { announcement: Announcement, cx: &mut Context, ) -> Task> { - let path = repo_mirror_path(&announcement.addr()); + let path = Mirrors::path(&announcement.addr()); self.push_repo_from(announcement, path, None, cx) } @@ -827,8 +689,7 @@ impl Backend { PushOutcome::default() } else { let push = cx.background_spawn({ - let client = client.clone(); - let signer = signer.clone(); + let pusher = GraspPush::new(client.clone(), signer.clone()); let path = path.clone(); let owner = owner.clone(); let repo_id = repo_id.clone(); @@ -837,21 +698,20 @@ impl Backend { let head = head.clone(); let executor = cx.background_executor().clone(); async move { - push_staged_to_grasps( - &client, - &signer, - &repo_id, - &refs, - head.as_deref(), - &path, - &owner, - &relays, - &executor, - |path, base, owner, repo_id| { - Repo::open(path)?.push_all(base, owner, repo_id) - }, - ) - .await + pusher + .push_staged_to_grasps( + &repo_id, + &refs, + head.as_deref(), + &path, + &owner, + &relays, + &executor, + |path, base, owner, repo_id| { + Repo::open(path)?.push_all(base, owner, repo_id) + }, + ) + .await } }); push.await @@ -1035,12 +895,32 @@ impl Backend { filters: Vec, cx: &mut Context, ) { + if relays.is_empty() || filters.is_empty() { + return; + } + let client = self.client.clone(); cx.spawn(async move |_this, _cx| { - if let Err(e) = connect_repo_relays(&client, relays, filters).await { - log::warn!("repo relay fetch failed: {e}"); + let connected: Result<(), Error> = async { + for url in relays.iter() { + client.add_relay(url).and_connect().await?; + } + Ok(()) } + .await; + + if let Err(e) = connected { + log::warn!("repo relay fetch failed: {e}"); + return Ok::<(), Error>(()); + } + + for filter in filters.into_iter() { + if let Err(e) = client.sync(filter).with(relays.iter()).await { + log::warn!("repo relay negentropy sync failed: {e}"); + } + } + Ok::<(), Error>(()) }) .detach(); @@ -1122,15 +1002,13 @@ impl Backend { /// Each target gets its own deletion event: a relay rejecting or /// dropping one does not affect the others. fn retract_events(&mut self, events: &[Event], cx: &mut Context) { - let client = self.client.clone(); - let signer = self.signer.clone(); + let pusher = GraspPush::new(self.client.clone(), self.signer.clone()); for event in events.iter().cloned() { - let client = client.clone(); - let signer = signer.clone(); + let pusher = pusher.clone(); cx.spawn(async move |_this, _cx| { - if let Err(e) = retract_event(&client, &signer, &event).await { + if let Err(e) = pusher.retract_event(&event).await { log::warn!("failed to retract event {}: {e}", event.id); } }) @@ -1139,548 +1017,154 @@ impl Backend { } } +/// The announcement of one repository, as published to the relays. +fn repository_announcement( + repo_id: &str, + name: &str, + description: &str, + owner: &str, + servers: &[RelayUrl], + euc: Option, +) -> GitRepositoryAnnouncement { + GitRepositoryAnnouncement { + id: repo_id.to_owned(), + name: Some(name.to_owned()), + description: (!description.is_empty()).then(|| description.to_owned()), + web: Vec::new(), + clone: servers + .iter() + .filter_map(|relay| grasp_clone_url(relay, owner, repo_id)) + .collect(), + relays: servers.to_vec(), + euc, + maintainers: Vec::new(), + } +} + +/// Announce a repository, stage the announcement on each grasp server's relay +#[allow(clippy::too_many_arguments)] +async fn announce_repository_and_push( + backend: &WeakEntity, + client: &Client, + signer: &UniversalSigner, + announcement: GitRepositoryAnnouncement, + repo_id: &str, + owner: &str, + servers: &[RelayUrl], + refs: Vec<(String, String)>, + head: Option, + path: &Path, + cx: &mut AsyncApp, + push: impl Fn(&Path, &str, &str, &str) -> Result<(), Error> + Send + 'static, +) -> Result { + // The nostr client queues events until each relay is connected. + for url in servers { + client.add_relay(url).and_connect().await.ok(); + } + + // The state event is the push authorization. It must be accepted before the push below. + let event = { + let builder = announcement.into_event_builder(); + let event = builder.finalize_async(signer).await?; + let output = client.send_event(&event).broadcast().await?; + GraspPush::require_relay_accepted(output, event)? + }; + + // The state event is the push authorization. Stage it on each grasp + // server's relay, then push the git data. The push fails only when no + // server accepted it. The announcement is then retracted so the + // repository is not left announced without content. + let outcome = if refs.is_empty() { + PushOutcome::default() + } else { + let pusher = GraspPush::new(client.clone(), signer.clone()); + let repo_id = repo_id.to_owned(); + let owner = owner.to_owned(); + let servers = servers.to_vec(); + let head = head.clone(); + let path = path.to_path_buf(); + let executor = cx.background_executor().clone(); + cx.background_spawn(async move { + pusher + .push_staged_to_grasps( + &repo_id, + &refs, + head.as_deref(), + &path, + &owner, + &servers, + &executor, + push, + ) + .await + }) + .await + }; + + if outcome.accepted() == 0 { + // The announcement is already published. Retract it so the + // repository is not left announced without content. + backend + .update(cx, |backend, cx| { + backend.retract_events(std::slice::from_ref(&event), cx); + }) + .ok(); + + return Err(anyhow!( + "The repository was announced, but the push to every grasp server failed: {}. \ + The announcement has been retracted", + outcome.failure_summary() + )); + } + + // Fan the state out to the relays once a git server holds the objects. + // Staging already stored the event locally, publishing makes it + // visible to the other relays and clients. + if let Some(state_event) = &outcome.state_event + && let Err(e) = client.send_event(state_event).broadcast().await + { + log::warn!("failed to broadcast repository state: {e}"); + } + + Ok(event) +} + /// A relay event the backend routes to a store group. enum UpdateEvent { Profile(PublicKey), Repo(Update), } -/// Await the next relay event from the notification stream. -async fn next_update( - notifications: &mut (impl futures::Stream + Unpin), - seen: &mut HashSet, -) -> Option { - loop { - match notifications.next().await { - Some(ClientNotification::Message { message, .. }) => { - let RelayMessage::Event { event, .. } = *message else { - continue; - }; +impl UpdateEvent { + /// Await the next relay event from the notification stream. + async fn next( + notifications: &mut (impl futures::Stream + Unpin), + seen: &mut HashSet, + ) -> Option { + loop { + match notifications.next().await { + Some(ClientNotification::Message { message, .. }) => { + let RelayMessage::Event { event, .. } = *message else { + continue; + }; - let update = match event.kind { - Kind::Metadata => UpdateEvent::Profile(event.pubkey), - kind if filters::is_repo_kind(kind) => { - UpdateEvent::Repo(Update::from_event(&event)) + let update = match event.kind { + Kind::Metadata => UpdateEvent::Profile(event.pubkey), + kind if filters::is_repo_kind(kind) => { + UpdateEvent::Repo(Update::from_event(&event)) + } + _ => continue, + }; + + if seen.insert(event.id) { + return Some(update); } - _ => continue, - }; - - if seen.insert(event.id) { - return Some(update); - } - } - Some(_) => continue, - None => return None, - } - } -} - -/// Sign and send a single NIP-09 deletion request for `event`. -async fn retract_event( - client: &Client, - signer: &UniversalSigner, - event: &Event, -) -> Result<(), Error> { - let builder = EventDeletionRequest::new() - .id(event.id) - .into_event_builder(); - - let deletion = builder.finalize_async(signer).await?; - client.send_event(&deletion).broadcast().await?; - - Ok(()) -} - -/// The event was accepted by at least one relay, or a descriptive error otherwise. -pub(crate) fn require_relay_accepted( - output: SendEventOutput, - event: Event, -) -> Result { - if output.success.is_empty() && !output.failed.is_empty() { - let reasons = output - .failed - .values() - .cloned() - .collect::>() - .join(", "); - bail!("event not accepted by any relay: {reasons}"); - } - - Ok(event) -} - -/// Sign and broadcast `builder`, logging rather than surfacing failures. -/// -/// Used for best-effort identity bootstrap events, where a relay hiccup -/// should not block sign-up. -async fn publish_best_effort(client: &Client, signer: &UniversalSigner, builder: EventBuilder) { - let result: Result<(), Error> = async { - let event = builder.finalize_async(signer).await?; - let output = client.send_event(&event).broadcast().await?; - require_relay_accepted(output, event)?; - Ok(()) - } - .await; - - if let Err(e) = result { - log::warn!("failed to publish identity bootstrap event: {e}"); - } -} - -async fn connect_repo_relays( - client: &Client, - relays: Vec, - filters: Vec, -) -> Result<(), Error> { - if relays.is_empty() || filters.is_empty() { - return Ok(()); - } - - for url in relays.iter() { - client.add_relay(url).and_connect().await?; - } - - for filter in filters.into_iter() { - if let Err(e) = client.sync(filter).with(relays.iter()).await { - log::warn!("repo relay negentropy sync failed: {e}"); - } - } - - Ok(()) -} - -/// Add and connect the startup relays. -async fn ensure_bootstrap_relays(client: &Client) -> Result<(), Error> { - for url in BOOTSTRAP_RELAYS { - client.add_relay(url).and_connect().await?; - } - - for url in INDEXER_RELAYS { - client - .add_relay(url) - .capabilities(RelayCapabilities::DISCOVERY) - .and_connect() - .await?; - } - - Ok(()) -} - -pub(crate) async fn subscribe_bootstrap_only( - client: &Client, - filters: Vec, -) -> Result<(), Error> { - ensure_bootstrap_relays(client).await?; - - let opts = SubscribeAutoCloseOptions::default() - .exit_policy(ReqExitPolicy::ExitOnEOSE) - .timeout(Some(Duration::from_secs(10))); - - let target: HashMap<&str, Vec> = BOOTSTRAP_RELAYS - .iter() - .map(|relay| (*relay, filters.clone())) - .collect(); - - client.subscribe(target).close_on(opts).await?; - - Ok(()) -} - -pub(crate) async fn sync_bootstrap_only( - client: &Client, - filter: Filter, - opts: SyncOptions, -) -> Result { - ensure_bootstrap_relays(client).await?; - - let output = client - .sync(filter) - .with(BOOTSTRAP_RELAYS) - .opts(opts) - .await?; - - Ok(output.value) -} - -/// Base URL of a grasp server, `https://`. -/// -/// `ws://` grasp servers use `http://`, like ngit. -pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option { - // `domain()` drops the port. - let parsed = Url::parse(relay.as_str()).ok()?; - let host = parsed.host_str()?; - let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default(); - // `ws://` grasp servers, e.g. local dev relays, speak plain HTTP. - let scheme = if relay.scheme().is_secure() { - "https" - } else { - "http" - }; - Some(format!("{scheme}://{host}{port}")) -} - -fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option { - let base = grasp_base_url(relay)?; - Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok() -} - -/// GRASP-06 contributor namespace URL of a pull request tip. -pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String { - format!("{base_url}/prs/{npub}/{repo_id}.git") -} - -/// Assemble the `clone` URLs of a pull request. -/// -/// The author's GRASP-06 `/prs/` URLs come first. -pub(crate) fn pr_clone_urls(prs_urls: Vec, base_clone_urls: Vec) -> Vec { - let mut seen = std::collections::HashSet::new(); - let mut urls = Vec::new(); - for url in prs_urls.into_iter().chain(base_clone_urls) { - if seen.insert(url.to_string()) { - urls.push(url); - } - } - urls -} - -/// The `g` tag servers of one kind-10317 grasp list event, in tag order. -fn grasp_list_servers(event: &Event) -> Vec { - event - .tags - .iter() - .filter(|tag| tag.kind() == "g") - .filter_map(|tag| tag.content()) - .filter_map(|url| RelayUrl::parse(url).ok()) - .collect() -} - -/// Grasp servers of the newest kind-10317 grasp list among `events`. -fn latest_grasp_list_servers(events: Vec) -> Vec { - events - .into_iter() - .max_by_key(|event| event.created_at) - .map(|event| grasp_list_servers(&event)) - .unwrap_or_default() -} - -pub async fn user_grasp_list_servers( - client: &Client, - user: PublicKey, -) -> Result, Error> { - let events: Vec = client - .database() - .query(Filters::grasp_list(user)) - .await? - .into_iter() - .collect(); - Ok(latest_grasp_list_servers(events)) -} - -const GRASP_PUSH_ATTEMPTS: usize = 3; - -/// Pause before re-staging a state event after a transient denial. -const GRASP_RETRY_DELAY: Duration = Duration::from_secs(1); - -#[derive(Debug, Clone)] -pub struct GraspServerResult { - pub relay: RelayUrl, - /// `None` when the server accepted the data, the reason otherwise. - pub reason: Option, -} - -impl GraspServerResult { - fn ok(relay: RelayUrl) -> Self { - Self { - relay, - reason: None, - } - } - - fn failed(relay: RelayUrl, reason: impl Into) -> Self { - Self { - relay, - reason: Some(reason.into()), - } - } -} - -#[derive(Debug, Clone, Default)] -pub struct PushOutcome { - /// Per-server results, in the order the servers were listed. - pub servers: Vec, - /// The newest state event a grasp relay accepted for this push, if any. - /// - /// Broadcast to the other relays once a git server holds the data. - pub state_event: Option, -} - -impl PushOutcome { - pub fn accepted(&self) -> usize { - self.servers - .iter() - .filter(|server| server.reason.is_none()) - .count() - } - - fn failing(&self) -> impl Iterator { - self.servers.iter().filter(|server| server.reason.is_some()) - } - - pub fn failure_summary(&self) -> String { - self.failing() - .map(|server| { - let reason = - utils::flatten_whitespace(server.reason.as_deref().unwrap_or("unknown error")); - format!("{}: {reason}", server.relay) - }) - .collect::>() - .join("; ") - } - - /// A warning for a push only some grasp servers accepted. - /// - /// `None` when every server accepted the push or nothing was pushed. - pub fn partial_warning(&self) -> Option { - let accepted = self.accepted(); - if self.servers.is_empty() || accepted == self.servers.len() { - return None; - } - Some(format!( - "Pushed to {accepted} of {} grasp servers: {}. Republish to sync.", - self.servers.len(), - self.failure_summary() - )) - } -} - -/// Reasons a push attempt should be retried with a freshly staged state -/// event and a fresh git advertisement. -/// -/// Two families are retried: -/// -/// - **Purgatory denials**: the grasp server sends these when the state -/// event for the push has not reached its purgatory yet. Re-staging a -/// fresh event resolves them. -/// - **Stale advertisement races**: `git receive-pack` compares each ref -/// update against the value it advertised when the push started. The grasp -/// server's own background sync can move a ref in between - typically by -/// aligning the repository to a parked state event once the objects of an -/// earlier attempt land - so the compare-and-swap fails with `cannot lock -/// ref` / `incorrect old value provided`. A retry against the fresh -/// advertisement converges, and when the race is lost the pushed data is -/// usually already on the server (see `is_stale_advertisement_race` and -/// the convergence probe in `push_staged_to_grasps`). -/// -/// Other rejections are not retried. -fn is_transient_grasp_denial(stderr: &str) -> bool { - let error = stderr.to_lowercase(); - [ - "no state events in purgatory", - "no matching state event", - "doesn't match push", - "none from authorized publishers", - "no repository announcement found", - "cannot lock ref", - "incorrect old value provided", - ] - .iter() - .any(|marker| error.contains(marker)) -} - -/// A push rejected because `git receive-pack`'s compare-and-swap lost to the -/// grasp server's own background ref alignment: the ref moved between this -/// push's advertisement and its ref transaction (`cannot lock ref ... is at -/// ... but expected ...` / `incorrect old value provided`). The pushed data -/// is usually already on the server by then. -fn is_stale_advertisement_race(stderr: &str) -> bool { - let error = stderr.to_lowercase(); - error.contains("cannot lock ref") || error.contains("incorrect old value provided") -} - -/// Keep `event` as the push's fan-out state event when it is newer than the -/// current one. All staged events carry the same refs; the newest timestamp -/// wins on the relays. -fn keep_newest(state_event: &mut Option, event: Event) { - if state_event - .as_ref() - .is_none_or(|current| event.created_at > current.created_at) - { - *state_event = Some(event); - } -} - -/// Sign a fresh kind `30618` state event for the push. -/// -/// `last_created_at` is the timestamp of the previous event signed for this push. -/// Retries within the same second get the next second: a grasp relay -/// treats a same-id resend as a duplicate and does not re-run its ingest, -/// so an identical resend cannot re-park a state event lost from its purgatory. -async fn sign_state_event( - signer: &UniversalSigner, - repo_id: &str, - refs: &[(String, String)], - head: Option<&str>, - last_created_at: u64, -) -> Result<(Event, u64), String> { - let now = Timestamp::now().as_secs(); - let created_at = if now > last_created_at { - now - } else { - last_created_at + 1 - }; - - let event = RepoState::build(repo_id, refs, head) - .custom_created_at(Timestamp::from_secs(created_at)) - .finalize_async(signer) - .await - .map_err(|e| format!("could not sign the state event: {e}"))?; - - Ok((event, created_at)) -} - -/// Ensure the relay is known and connected, then publish `event` to it. -/// -/// `Ok` only when the relay confirmed the event. -/// On a grasp relay the accept parks the event in purgatory, -/// which authorizes the paired git push. -async fn stage_event_on_relay( - client: &Client, - relay: &RelayUrl, - event: &Event, -) -> Result<(), String> { - client - .add_relay(relay) - .and_connect() - .await - .map_err(|e| format!("could not add relay {relay}: {e}"))?; - - let output = client - .send_event(event) - .to([relay.clone()]) - .await - .map_err(|e| format!("could not send the state event to {relay}: {e}"))?; - - if output.success.contains_key(relay) { - Ok(()) - } else { - let reason = output - .failed - .get(relay) - .cloned() - .unwrap_or_else(|| "relay did not confirm the event".to_owned()); - Err(reason) - } -} - -#[allow(clippy::too_many_arguments)] -async fn push_staged_to_grasps( - client: &Client, - signer: &UniversalSigner, - repo_id: &str, - refs: &[(String, String)], - head: Option<&str>, - path: &Path, - owner: &str, - servers: &[RelayUrl], - executor: &BackgroundExecutor, - push: impl Fn(&Path, &str, &str, &str) -> Result<(), Error>, -) -> PushOutcome { - let mut outcome = PushOutcome::default(); - - for relay in servers { - let Some(base) = grasp_base_url(relay) else { - outcome - .servers - .push(GraspServerResult::failed(relay.clone(), "no domain")); - continue; - }; - let git_url = format!("{base}/{owner}/{repo_id}.git"); - - let mut reason = None; - let mut last_created_at = 0; - // The last state event staged on this server, for the convergence - // probe below when every push attempt lost the stale-ref race. - let mut staged_event = None; - - 'server: for attempt in 1..=GRASP_PUSH_ATTEMPTS { - if attempt > 1 { - // Give the server's ingest a moment before re-staging. - executor.timer(GRASP_RETRY_DELAY).await; - } - - let (event, created_at) = - match sign_state_event(signer, repo_id, refs, head, last_created_at).await { - Ok(signed) => signed, - Err(e) => { - reason = Some(e); - break 'server; - } - }; - - last_created_at = created_at; - - // Stage the state event on this server's own relay. - // A failed stage means the grasp never parked the state, - // so the git push would be denied anyway: skip it (the eligibility gate). - if let Err(e) = stage_event_on_relay(client, relay, &event).await { - // One retry absorbs a relay connect blip, on the first - // attempt only. - if attempt == 1 && stage_event_on_relay(client, relay, &event).await.is_ok() { - // staged on the retry - } else { - reason = Some(e); - break 'server; - } - } - staged_event = Some(event.clone()); - - match push(path, &base, owner, repo_id) { - Ok(()) => { - keep_newest(&mut outcome.state_event, event); - break 'server; - } - Err(e) => { - let text = e.to_string(); - if attempt < GRASP_PUSH_ATTEMPTS && is_transient_grasp_denial(&text) { - reason = Some(text); - continue 'server; - } - reason = Some(text); - break 'server; } + Some(_) => continue, + None => return None, } } - - // The grasp's own background sync aligns refs to staged state - // events as soon as the objects land, which can beat every push - // attempt's compare-and-swap (`cannot lock ref ... but expected`). - // When the last denial was that race the sync has usually finished - // by now: verify the advertised refs and accept the server when the - // pushed data is already there. - if let Some(last_reason) = &reason - && is_stale_advertisement_race(last_reason) - && Repo::open(path) - .and_then(|repo| repo.remote_has_refs(&git_url, refs)) - .unwrap_or(false) - { - if let Some(event) = staged_event { - keep_newest(&mut outcome.state_event, event); - } - reason = None; - } - - match reason { - Some(reason) => { - log::warn!("grasp push failed: {relay}: {reason}"); - outcome - .servers - .push(GraspServerResult::failed(relay.clone(), reason)); - } - None => outcome.servers.push(GraspServerResult::ok(relay.clone())), - } } - - outcome } /// Split a stored bunker credential into the plain URI and the session key. @@ -1695,138 +1179,3 @@ fn extract_master_key(credential: &str) -> (&str, Keys) { None => (credential, Keys::generate()), } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn grasp_base_url_maps_schemes_like_ngit() { - let wss = RelayUrl::parse("wss://relay.ngit.dev").expect("url"); - assert_eq!( - grasp_base_url(&wss).as_deref(), - Some("https://relay.ngit.dev") - ); - - let ws = RelayUrl::parse("ws://localhost:8080").expect("url"); - assert_eq!( - grasp_base_url(&ws).as_deref(), - Some("http://localhost:8080") - ); - } - - #[test] - fn grasp_clone_url_matches_ngit_format() { - let relay = RelayUrl::parse("wss://gitnostr.com").expect("url"); - let url = grasp_clone_url(&relay, "npub1test", "my-repo").expect("url"); - assert_eq!( - url.to_string(), - "https://gitnostr.com/npub1test/my-repo.git" - ); - } - - #[test] - fn grasp06_prs_url_matches_ngit_format() { - assert_eq!( - grasp06_prs_url("https://relay.ngit.dev", "npub1author", "my-repo"), - "https://relay.ngit.dev/prs/npub1author/my-repo.git" - ); - // `ws://` grasp servers, local dev, keep their plain-HTTP base. - assert_eq!( - grasp06_prs_url("http://localhost:8080", "npub1author", "my-repo"), - "http://localhost:8080/prs/npub1author/my-repo.git" - ); - } - - #[test] - fn pr_clone_urls_orders_author_first_and_deduplicates() { - let prs = vec![ - Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"), - Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"), - ]; - let base = vec![ - Url::parse("https://a.example/npub1owner/repo.git").expect("url"), - Url::parse("https://b.example/npub1owner/repo.git").expect("url"), - Url::parse("https://b.example/npub1owner/repo.git").expect("url"), - ]; - - let urls = pr_clone_urls(prs, base); - assert_eq!( - urls.iter().map(ToString::to_string).collect::>(), - vec![ - "https://a.example/prs/npub1me/repo.git", - "https://a.example/npub1owner/repo.git", - "https://b.example/npub1owner/repo.git", - ] - ); - } - - #[test] - fn transient_grasp_denials_are_classified() { - // The exact server rejection that started this work: the state event - // had not reached the grasp's purgatory before the git push. - let reported = "remote: ERR authorisation failed: No state events in purgatory\n\ - fatal: the remote end hung up unexpectedly\n\ - error: failed to push some refs to 'https://relay.ngit.dev/...git'"; - assert!(is_transient_grasp_denial(reported)); - - // The other purgatory states a fresh event resolves. - assert!(is_transient_grasp_denial( - "remote: ERR authorisation failed: No matching state event found in purgatory" - )); - assert!(is_transient_grasp_denial( - "remote: ERR authorisation failed: 1 state event in purgatory from authorized \ - publisher but doesn't match push" - )); - assert!(is_transient_grasp_denial( - "remote: ERR authorisation failed: 2 state events in purgatory but none from \ - authorized publishers" - )); - assert!(is_transient_grasp_denial( - "remote: ERR authorisation failed: No repository announcement found" - )); - - // Rejections a fresh state event cannot fix are not retried. - assert!(!is_transient_grasp_denial( - "remote: ERR authorisation failed: not a maintainer of this repository" - )); - assert!(!is_transient_grasp_denial( - "fatal: unable to access 'https://relay.ngit.dev/...': The requested URL returned \ - error: 403" - )); - assert!(!is_transient_grasp_denial( - "fatal: unable to access 'https://relay.ngit.dev/...': Could not resolve host" - )); - } - - #[test] - fn stale_ref_races_are_retried() { - // The grasp's background sync aligned the ref to a parked state event - // between this push's advertisement and its ref transaction. The ref - // is usually already where the push wants it, so a retry converges. - let reported = "remote: error: cannot lock ref 'refs/heads/main': is at \ - cac2ac91b6f5fb8dfcb6962785babc6e65350cb3 but expected \ - bc5e892aa84dc6240a5fbcd59367a4857d26f49b\n\ - To https://relay.ngit.dev/npub1owner/signed-test.git\n\ - ! [remote rejected] main -> main (incorrect old value provided)\n\ - error: failed to push some refs to 'https://relay.ngit.dev/npub1owner/signed-test.git'"; - assert!(is_transient_grasp_denial(reported)); - assert!(is_stale_advertisement_race(reported)); - - // Markers match independently of the surrounding git output. - assert!(is_stale_advertisement_race( - "cannot lock ref 'refs/heads/main'" - )); - assert!(is_stale_advertisement_race( - "! [remote rejected] main -> main (incorrect old value provided)" - )); - - // A purgatory denial is not a stale-advertisement race. - assert!(!is_stale_advertisement_race("No state events in purgatory")); - - // A real divergence is a different error and stays permanent. - assert!(!is_transient_grasp_denial( - " ! [rejected] main -> main (non-fast-forward)" - )); - } -} diff --git a/crates/signed_state/src/bootstrap.rs b/crates/signed_state/src/bootstrap.rs new file mode 100644 index 0000000..906ac84 --- /dev/null +++ b/crates/signed_state/src/bootstrap.rs @@ -0,0 +1,99 @@ +use std::collections::HashMap; +use std::time::Duration; + +use anyhow::Error; +use nostr_connect::prelude::*; +use nostr_sdk::client::SyncSummary; +use nostr_sdk::prelude::*; +use signed_core::Filters; + +/// Relays connected at startup, before any user-specific relay config is known. +pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"]; +/// Relays used to index the user's NIP-65 relay list. +pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"]; + +/// Add and connect the startup relays. +async fn ensure_bootstrap_relays(client: &Client) -> Result<(), Error> { + for url in BOOTSTRAP_RELAYS { + client.add_relay(url).and_connect().await?; + } + + for url in INDEXER_RELAYS { + client + .add_relay(url) + .capabilities(RelayCapabilities::DISCOVERY) + .and_connect() + .await?; + } + + Ok(()) +} + +pub(crate) async fn subscribe_bootstrap_only( + client: &Client, + filters: Vec, +) -> Result<(), Error> { + ensure_bootstrap_relays(client).await?; + + let opts = SubscribeAutoCloseOptions::default() + .exit_policy(ReqExitPolicy::ExitOnEOSE) + .timeout(Some(Duration::from_secs(10))); + + let target: HashMap<&str, Vec> = BOOTSTRAP_RELAYS + .iter() + .map(|relay| (*relay, filters.clone())) + .collect(); + + client.subscribe(target).close_on(opts).await?; + + Ok(()) +} + +pub(crate) async fn sync_bootstrap_only( + client: &Client, + filter: Filter, + opts: SyncOptions, +) -> Result { + ensure_bootstrap_relays(client).await?; + + let output = client + .sync(filter) + .with(BOOTSTRAP_RELAYS) + .opts(opts) + .await?; + + Ok(output.value) +} + +/// The `g` tag servers of one kind-10317 grasp list event, in tag order. +fn grasp_list_servers(event: &Event) -> Vec { + event + .tags + .iter() + .filter(|tag| tag.kind() == "g") + .filter_map(|tag| tag.content()) + .filter_map(|url| RelayUrl::parse(url).ok()) + .collect() +} + +/// Grasp servers of the newest kind-10317 grasp list among `events`. +fn latest_grasp_list_servers(events: Vec) -> Vec { + events + .into_iter() + .max_by_key(|event| event.created_at) + .map(|event| grasp_list_servers(&event)) + .unwrap_or_default() +} + +pub async fn user_grasp_list_servers( + client: &Client, + user: PublicKey, +) -> Result, Error> { + let events: Vec = client + .database() + .query(Filters::grasp_list(user)) + .await? + .into_iter() + .collect(); + Ok(latest_grasp_list_servers(events)) +} diff --git a/crates/signed_state/src/checkouts.rs b/crates/signed_state/src/checkouts.rs index 0809319..d102197 100644 --- a/crates/signed_state/src/checkouts.rs +++ b/crates/signed_state/src/checkouts.rs @@ -11,7 +11,7 @@ use signed_git::Repo; use utils::same_repo_url; use crate::backend::{Backend, BackendEvent}; -use crate::git_store::repo_mirror_root; +use crate::git_store::Mirrors; use crate::local_repos::LocalReposStore; use crate::refresh::{RefreshGate, RefreshRequest}; use crate::repos::RepoListStore; @@ -308,7 +308,7 @@ impl CheckoutsStore { let announcements = RepoListStore::global(cx).read(cx).announcements.clone(); let scanned = LocalReposStore::global(cx).read(cx).repos.clone(); - let cache_root = repo_mirror_root().canonicalize().ok(); + let cache_root = Mirrors::root().canonicalize().ok(); let requested: Vec<(RepoAddr, Option)> = self .status_requested @@ -350,7 +350,8 @@ impl CheckoutsStore { facts.push((path.clone(), origin, root)); } - let associations = resolve_associations(&remembered, &facts, announcements.iter()); + let associations = + CheckoutsStore::resolve_associations(&remembered, &facts, announcements.iter()); // Missing directories are stale records, drop them. let associations: HashMap> = associations @@ -359,7 +360,7 @@ impl CheckoutsStore { .collect(); let (statuses, push_statuses) = - compute_statuses(&associations, &requested, &push_requested, true); + CheckoutsStore::compute_statuses(&associations, &requested, &push_requested, true); Ok::<_, Error>((associations, statuses, push_statuses)) }); @@ -485,7 +486,7 @@ impl CheckoutsStore { let work = cx.background_spawn(async move { let (statuses, push_statuses) = - compute_statuses(&associations, &requested, &push_requested, false); + CheckoutsStore::compute_statuses(&associations, &requested, &push_requested, false); Ok::<_, Error>((statuses, push_statuses)) }); @@ -519,190 +520,192 @@ impl CheckoutsStore { } } -fn resolve_associations<'a>( - remembered: &[Remembered], - scanned: &[(PathBuf, Option, Option)], - announcements: impl IntoIterator, -) -> HashMap> { - let announcements: Vec<&Announcement> = announcements.into_iter().collect(); - let mut out: HashMap> = HashMap::new(); +impl CheckoutsStore { + fn resolve_associations<'a>( + remembered: &[Remembered], + scanned: &[(PathBuf, Option, Option)], + announcements: impl IntoIterator, + ) -> HashMap> { + let announcements: Vec<&Announcement> = announcements.into_iter().collect(); + let mut out: HashMap> = HashMap::new(); - let mut sorted: Vec<&Remembered> = remembered.iter().collect(); - sorted.sort_by_key(|record| std::cmp::Reverse(record.last_used)); - for record in sorted { - let paths = out.entry(record.addr.clone()).or_default(); - if !paths.contains(&record.path) { - paths.push(record.path.clone()); + let mut sorted: Vec<&Remembered> = remembered.iter().collect(); + sorted.sort_by_key(|record| std::cmp::Reverse(record.last_used)); + for record in sorted { + let paths = out.entry(record.addr.clone()).or_default(); + if !paths.contains(&record.path) { + paths.push(record.path.clone()); + } } - } - for (path, origin, root) in scanned { - for announcement in &announcements { - let url_match = origin.as_deref().is_some_and(|origin| { - announcement - .clone - .iter() - .any(|url| same_repo_url(origin, url.as_str())) - }); + for (path, origin, root) in scanned { + for announcement in &announcements { + let url_match = origin.as_deref().is_some_and(|origin| { + announcement + .clone + .iter() + .any(|url| same_repo_url(origin, url.as_str())) + }); - let euc_match = root - .as_deref() - .is_some_and(|root| announcement.euc.as_deref() == Some(root)); + let euc_match = root + .as_deref() + .is_some_and(|root| announcement.euc.as_deref() == Some(root)); - if url_match || euc_match { - let paths = out.entry(announcement.addr()).or_default(); - if !paths.contains(path) { - paths.push(path.clone()); + if url_match || euc_match { + let paths = out.entry(announcement.addr()).or_default(); + if !paths.contains(path) { + paths.push(path.clone()); + } } } } + + out } - out -} + fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option { + let repo = Repo::try_open(path)?; + let branches = repo.branches().ok()?; -fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option { - let repo = Repo::try_open(path)?; - let branches = repo.branches().ok()?; + if branches.is_empty() || repo.is_dirty() { + return None; + } - if branches.is_empty() || repo.is_dirty() { - return None; + let branch = repo.current_branch()?; + let head = repo.head()?; + let base = announced_head + .filter(|name| branches.iter().any(|b| b == name)) + .map(str::to_owned) + .or_else(|| branches.iter().find(|b| *b == "main").cloned()) + .or_else(|| branches.first().cloned())?; + + if base == branch { + return None; + } + + let ahead = repo.commits_ahead(&base, &branch); + (ahead > 0).then_some(CheckoutStatus { + path: path.to_path_buf(), + branch, + head, + base, + ahead, + }) } - let branch = repo.current_branch()?; - let head = repo.head()?; - let base = announced_head - .filter(|name| branches.iter().any(|b| b == name)) - .map(str::to_owned) - .or_else(|| branches.iter().find(|b| *b == "main").cloned()) - .or_else(|| branches.first().cloned())?; + /// The `ready to push` status of one checkout of the user's own repository. + fn checkout_push_status(path: &Path, fetch: bool) -> Option { + let repo = Repo::try_open(path)?; + if repo.is_dirty() { + return None; + } - if base == branch { - return None; - } + let branch = repo.current_branch()?; + let head = repo.head()?; + let origin = repo.origin_url().ok().flatten()?; - let ahead = repo.commits_ahead(&base, &branch); - (ahead > 0).then_some(CheckoutStatus { - path: path.to_path_buf(), - branch, - head, - base, - ahead, - }) -} + if fetch { + repo.fetch_refs(&[origin], "+refs/heads/*:refs/remotes/origin/*") + .ok(); + } -/// The `ready to push` status of one checkout of the user's own repository. -fn checkout_push_status(path: &Path, fetch: bool) -> Option { - let repo = Repo::try_open(path)?; - if repo.is_dirty() { - return None; - } + let remote = format!("refs/remotes/origin/{branch}"); - let branch = repo.current_branch()?; - let head = repo.head()?; - let origin = repo.origin_url().ok().flatten()?; - - if fetch { - repo.fetch_refs(&[origin], "+refs/heads/*:refs/remotes/origin/*") - .ok(); - } - - let remote = format!("refs/remotes/origin/{branch}"); - - // A branch never fetched or pushed yet compares against the remote HEAD. - // The remote HEAD is the fork point in practice. - let base = if repo.ref_exists(&remote) { - remote - } else if repo.ref_exists("refs/remotes/origin/HEAD") { - "refs/remotes/origin/HEAD".to_owned() - } else { - return None; - }; - - let ahead = repo.commits_ahead(&base, &branch); - - (ahead > 0).then_some(CheckoutStatus { - path: path.to_path_buf(), - branch, - head, - base, - ahead, - }) -} - -/// Compute the requested statuses against the checkout paths of `associations`. -fn compute_statuses( - associations: &HashMap>, - requested: &[(RepoAddr, Option)], - push_requested: &[RepoAddr], - fetch: bool, -) -> ( - HashMap>, - HashMap>, -) { - let mut statuses: HashMap> = HashMap::new(); - for (addr, announced_head) in requested { - let Some(paths) = associations.get(addr) else { - continue; + // A branch never fetched or pushed yet compares against the remote HEAD. + // The remote HEAD is the fork point in practice. + let base = if repo.ref_exists(&remote) { + remote + } else if repo.ref_exists("refs/remotes/origin/HEAD") { + "refs/remotes/origin/HEAD".to_owned() + } else { + return None; }; - let list: Vec = paths - .iter() - .take(MAX_STATUS_CHECKOUTS) - .filter_map(|path| checkout_status(path, announced_head.as_deref())) - .collect(); + let ahead = repo.commits_ahead(&base, &branch); - if !list.is_empty() { - statuses.insert(addr.clone(), list); + (ahead > 0).then_some(CheckoutStatus { + path: path.to_path_buf(), + branch, + head, + base, + ahead, + }) + } + + /// Compute the requested statuses against the checkout paths of `associations`. + fn compute_statuses( + associations: &HashMap>, + requested: &[(RepoAddr, Option)], + push_requested: &[RepoAddr], + fetch: bool, + ) -> ( + HashMap>, + HashMap>, + ) { + let mut statuses: HashMap> = HashMap::new(); + for (addr, announced_head) in requested { + let Some(paths) = associations.get(addr) else { + continue; + }; + + let list: Vec = paths + .iter() + .take(MAX_STATUS_CHECKOUTS) + .filter_map(|path| Self::checkout_status(path, announced_head.as_deref())) + .collect(); + + if !list.is_empty() { + statuses.insert(addr.clone(), list); + } } - } - let mut push_statuses: HashMap> = HashMap::new(); - for addr in push_requested { - let Some(paths) = associations.get(addr) else { - continue; - }; + let mut push_statuses: HashMap> = HashMap::new(); + for addr in push_requested { + let Some(paths) = associations.get(addr) else { + continue; + }; - let list: Vec = paths - .iter() - .take(MAX_STATUS_CHECKOUTS) - .filter_map(|path| checkout_push_status(path, fetch)) - .collect(); + let list: Vec = paths + .iter() + .take(MAX_STATUS_CHECKOUTS) + .filter_map(|path| Self::checkout_push_status(path, fetch)) + .collect(); - if !list.is_empty() { - push_statuses.insert(addr.clone(), list); + if !list.is_empty() { + push_statuses.insert(addr.clone(), list); + } } + + (statuses, push_statuses) } - (statuses, push_statuses) -} + pub fn pr_proposes_checkout( + pr: &Event, + open: bool, + user: PublicKey, + checkout: &CheckoutStatus, + ) -> bool { + if pr.kind != Kind::GitPullRequest || !open || pr.pubkey != user { + return false; + } -pub fn pr_proposes_checkout( - pr: &Event, - open: bool, - user: PublicKey, - checkout: &CheckoutStatus, -) -> bool { - if pr.kind != Kind::GitPullRequest || !open || pr.pubkey != user { - return false; + let branch_matches = pr + .tags + .iter() + .find(|t| t.kind() == "branch-name") + .and_then(|t| t.content()) + .is_some_and(|name| name == checkout.branch); + + // A renamed branch falls back to the proposed tip commit. + let tip_matches = pr + .tags + .iter() + .find(|t| t.kind() == "c") + .and_then(|t| t.content()) + .is_some_and(|tip| tip == checkout.head); + + branch_matches || tip_matches } - - let branch_matches = pr - .tags - .iter() - .find(|t| t.kind() == "branch-name") - .and_then(|t| t.content()) - .is_some_and(|name| name == checkout.branch); - - // A renamed branch falls back to the proposed tip commit. - let tip_matches = pr - .tags - .iter() - .find(|t| t.kind() == "c") - .and_then(|t| t.content()) - .is_some_and(|tip| tip == checkout.head); - - branch_matches || tip_matches } #[cfg(test)] @@ -763,7 +766,7 @@ mod tests { run(&["checkout", "-b", "feature"]); std::fs::write(path.join("feature.txt"), "x\n").expect("write"); commit("feature work"); - let status = checkout_status(&path, Some("main")).expect("status"); + let status = CheckoutsStore::checkout_status(&path, Some("main")).expect("status"); assert_eq!(status.branch, "feature"); assert_eq!(status.base, "main"); assert_eq!(status.ahead, 1); @@ -771,12 +774,12 @@ mod tests { // Dirty worktrees are never suggested. std::fs::write(path.join("uncommitted.txt"), "y\n").expect("write"); - assert!(checkout_status(&path, Some("main")).is_none()); + assert!(CheckoutsStore::checkout_status(&path, Some("main")).is_none()); run(&["checkout", "--", "."]); // Even on main, nothing to propose. run(&["checkout", "main"]); - assert_eq!(checkout_status(&path, Some("main")), None); + assert_eq!(CheckoutsStore::checkout_status(&path, Some("main")), None); } #[test] @@ -822,13 +825,13 @@ mod tests { }; // A fresh clone has nothing to push. - assert_eq!(checkout_push_status(&checkout, true), None); + assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None); // One local commit, ready to push, counted against the remote. std::fs::write(checkout.join("work.txt"), "x\n").expect("write"); run(&["add", "-A"]); run(&["commit", "-m", "local work"]); - let status = checkout_push_status(&checkout, true).expect("status"); + let status = CheckoutsStore::checkout_push_status(&checkout, true).expect("status"); assert_eq!(status.branch, "main"); assert_eq!(status.base, "refs/remotes/origin/main"); assert_eq!(status.ahead, 1); @@ -836,12 +839,12 @@ mod tests { // The local-only pass reads the tracking refs, no fetch needed: // a commit lands locally long before the remote is reconciled. - let local = checkout_push_status(&checkout, false).expect("local status"); + let local = CheckoutsStore::checkout_push_status(&checkout, false).expect("local status"); assert_eq!(local.ahead, 1); // After the push the same commit is on the remote, idle again. run(&["push", "origin", "main"]); - assert_eq!(checkout_push_status(&checkout, true), None); + assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None); // A commit made by someone else on the remote must not count as local work. // It is behind, not ahead. @@ -860,6 +863,6 @@ mod tests { std::fs::write(remote.join("other.txt"), "y\n").expect("write"); remote_run(&["add", "-A"]); remote_run(&["commit", "-m", "remote work"]); - assert_eq!(checkout_push_status(&checkout, true), None); + assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None); } } diff --git a/crates/signed_state/src/git_store.rs b/crates/signed_state/src/git_store.rs index 961136c..c4072a2 100644 --- a/crates/signed_state/src/git_store.rs +++ b/crates/signed_state/src/git_store.rs @@ -8,34 +8,40 @@ use signed_git::{GitCache, Repo}; static GIT_CACHE: OnceLock = OnceLock::new(); -fn git_cache() -> &'static GitCache { - GIT_CACHE - .get() - .expect("git cache is initialized by signed_state::init") -} +/// The global git repository mirror cache. +pub struct Mirrors; -/// The root directory of the repository mirrors. -pub(crate) fn repo_mirror_root() -> PathBuf { - git_cache().root().to_path_buf() -} +impl Mirrors { + /// Install the global mirror cache root, once. + pub fn install(root: impl Into) { + if GIT_CACHE.set(GitCache::new(root.into())).is_err() { + log::warn!("git cache root is already set, keeping the first one"); + } + } -/// The on-disk path of the mirror of `addr`. -pub fn repo_mirror_path(addr: &RepoAddr) -> PathBuf { - git_cache().repo_path(addr) -} + fn cache() -> &'static GitCache { + GIT_CACHE + .get() + .expect("git cache is initialized by signed_state::init") + } -/// Open the mirror of `addr`, if it has been cloned. -pub fn open_repo_mirror(addr: &RepoAddr) -> Result> { - git_cache().open(addr) -} + /// The root directory of the repository mirrors. + pub(crate) fn root() -> PathBuf { + Self::cache().root().to_path_buf() + } -/// Open the mirror of `addr`, cloning it first when it does not exist yet. -pub fn ensure_repo_mirror(addr: &RepoAddr, clone_urls: &[Url]) -> Result { - git_cache().ensure_clone(addr, clone_urls) -} + /// The on-disk path of the mirror of `addr`. + pub fn path(addr: &RepoAddr) -> PathBuf { + Self::cache().repo_path(addr) + } -pub(crate) fn set_git_cache(root: impl Into) { - if GIT_CACHE.set(GitCache::new(root.into())).is_err() { - log::warn!("git cache root is already set, keeping the first one"); + /// Open the mirror of `addr`, if it has been cloned. + pub fn open(addr: &RepoAddr) -> Result> { + Self::cache().open(addr) + } + + /// Open the mirror of `addr`, cloning it first when it does not exist yet. + pub fn ensure(addr: &RepoAddr, clone_urls: &[Url]) -> Result { + Self::cache().ensure_clone(addr, clone_urls) } } diff --git a/crates/signed_state/src/inbox.rs b/crates/signed_state/src/inbox.rs index d7be438..96e56b7 100644 --- a/crates/signed_state/src/inbox.rs +++ b/crates/signed_state/src/inbox.rs @@ -36,7 +36,7 @@ impl Inbox { cx.notify(); let backend = Backend::global(cx); - let work = cx.background_spawn(async move { load_state(&client, me).await }); + let work = cx.background_spawn(async move { Self::load_state(&client, me).await }); cx.spawn(async move |this, cx| { let loaded = work.await; @@ -82,7 +82,7 @@ impl Inbox { let state = self.state.clone(); let task: Task> = cx.background_spawn(async move { - if let Err(error) = save_state(&client, me, &state).await { + if let Err(error) = Self::save_state(&client, me, &state).await { log::warn!("failed to save inbox state: {error}"); } Ok(()) @@ -90,135 +90,147 @@ impl Inbox { task.detach(); } -} -pub async fn query_inbox( - client: &Client, - me: PublicKey, - state: &InboxReadState, -) -> Result<(Vec, usize), Error> { - let deletion_events = client.database().query(Filters::deletions()).await?; - let deletions = Deletions::from_events(deletion_events); + /// The notifications and authored activity of `me`, grouped into inbox items. + pub async fn query( + client: &Client, + me: PublicKey, + state: &InboxReadState, + ) -> Result<(Vec, usize), Error> { + let deletion_events = client.database().query(Filters::deletions()).await?; + let deletions = Deletions::from_events(deletion_events); - let (notification_events, mut by_id) = fetch_notifications(client, me, &deletions).await?; + let (notification_events, mut by_id) = + Self::fetch_notifications(client, me, &deletions).await?; - let mut activity = Vec::new(); - for event in client - .database() - .query(Filters::authored_activity(me)) - .await? - { - if deletions.is_deleted(&event) || !event.is_git_activity() { - continue; - } - by_id.entry(event.id).or_insert_with(|| event.clone()); - activity.push(event); - } - - let items = inbox::group(notification_events, activity, me, state, &|id| { - by_id.get(&id).cloned() - }); - - let unread_count = items.iter().filter(|item| item.is_unread()).count(); - - Ok((items, unread_count)) -} - -/// `d` tag identifying the inbox state event of `me`. -fn inbox_state_d_tag(me: PublicKey) -> String { - format!("signed-inbox-state:{}", me.to_hex()) -} - -async fn load_state(client: &Client, me: PublicKey) -> Result, Error> { - let filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .identifier(inbox_state_d_tag(me)); - - let events = client.database().query(filter).await?; - - let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else { - return Ok(None); - }; - - match serde_json::from_str(&event.content) { - Ok(state) => Ok(Some(state)), - Err(error) => { - log::warn!("ignoring unreadable inbox state {}: {error}", event.id); - Ok(None) - } - } -} - -/// Sign with a random key and store locally. -async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> { - let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?) - .tags([Tag::identifier(inbox_state_d_tag(me))]) - .finalize(&Keys::generate())?; - - client.database().save_event(&event).await?; - - Ok(()) -} - -/// Notification events and a lookup of every ancestor they reference. -async fn fetch_notifications( - client: &Client, - me: PublicKey, - deletions: &Deletions, -) -> Result<(Vec, HashMap), Error> { - let mut notifications: Vec = Vec::new(); - let mut by_id: HashMap = HashMap::new(); - - for filter in Filters::notifications(me) { - for event in client.database().query(filter).await? { - if deletions.is_deleted(&event) { - continue; - } - - if by_id.insert(event.id, event.clone()).is_none() { - notifications.push(event); - } - } - } - - let mut pending: Vec = notifications.iter().flat_map(event_references).collect(); - let mut seen: HashSet = by_id.keys().copied().collect(); - - loop { - pending.retain(|id| seen.insert(*id)); - - if pending.is_empty() { - break; - } - - let ancestors = client + let mut activity = Vec::new(); + for event in client .database() - .query(Filter::new().ids(pending.iter().copied())) - .await?; - - let mut next = Vec::new(); - - for event in ancestors { - if deletions.is_deleted(&event) { + .query(Filters::authored_activity(me)) + .await? + { + if deletions.is_deleted(&event) || !event.is_git_activity() { continue; } - next.extend(event_references(&event).filter(|id| !seen.contains(id))); - by_id.entry(event.id).or_insert(event); + by_id.entry(event.id).or_insert_with(|| event.clone()); + activity.push(event); } - pending = next; + let items = inbox::group(notification_events, activity, me, state, &|id| { + by_id.get(&id).cloned() + }); + + let unread_count = items.iter().filter(|item| item.is_unread()).count(); + + Ok((items, unread_count)) + } +} + +impl Inbox { + /// `d` tag identifying the inbox state event of `me`. + fn inbox_state_d_tag(me: PublicKey) -> String { + format!("signed-inbox-state:{}", me.to_hex()) } - Ok((notifications, by_id)) -} + async fn load_state(client: &Client, me: PublicKey) -> Result, Error> { + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(Self::inbox_state_d_tag(me)); -/// Event ids referenced by `event` through its `e` and `E` tags. -fn event_references(event: &Event) -> impl Iterator + '_ { - event.tags.iter().filter_map(|tag| { - if tag.kind() != "e" && tag.kind() != "E" { - return None; + let events = client.database().query(filter).await?; + + let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else { + return Ok(None); + }; + + match serde_json::from_str(&event.content) { + Ok(state) => Ok(Some(state)), + Err(error) => { + log::warn!("ignoring unreadable inbox state {}: {error}", event.id); + Ok(None) + } } - tag.content() - .and_then(|content| EventId::from_hex(content).ok()) - }) + } + + /// Sign with a random key and store locally. + async fn save_state( + client: &Client, + me: PublicKey, + state: &InboxReadState, + ) -> Result<(), Error> { + let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?) + .tags([Tag::identifier(Self::inbox_state_d_tag(me))]) + .finalize(&Keys::generate())?; + + client.database().save_event(&event).await?; + + Ok(()) + } + + /// Notification events and a lookup of every ancestor they reference. + async fn fetch_notifications( + client: &Client, + me: PublicKey, + deletions: &Deletions, + ) -> Result<(Vec, HashMap), Error> { + let mut notifications: Vec = Vec::new(); + let mut by_id: HashMap = HashMap::new(); + + for filter in Filters::notifications(me) { + for event in client.database().query(filter).await? { + if deletions.is_deleted(&event) { + continue; + } + + if by_id.insert(event.id, event.clone()).is_none() { + notifications.push(event); + } + } + } + + let mut pending: Vec = notifications + .iter() + .flat_map(Self::event_references) + .collect(); + + let mut seen: HashSet = by_id.keys().copied().collect(); + + loop { + pending.retain(|id| seen.insert(*id)); + + if pending.is_empty() { + break; + } + + let ancestors = client + .database() + .query(Filter::new().ids(pending.iter().copied())) + .await?; + + let mut next = Vec::new(); + + for event in ancestors { + if deletions.is_deleted(&event) { + continue; + } + next.extend(Self::event_references(&event).filter(|id| !seen.contains(id))); + by_id.entry(event.id).or_insert(event); + } + + pending = next; + } + + Ok((notifications, by_id)) + } + + /// Event ids referenced by `event` through its `e` and `E` tags. + fn event_references(event: &Event) -> impl Iterator + '_ { + event.tags.iter().filter_map(|tag| { + if tag.kind() != "e" && tag.kind() != "E" { + return None; + } + tag.content() + .and_then(|content| EventId::from_hex(content).ok()) + }) + } } diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 217cc56..163d5be 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -1,24 +1,27 @@ mod backend; +mod bootstrap; mod checkouts; mod git_store; mod inbox; mod local_repos; mod profile; +mod push; mod refresh; mod repo; mod repos; use std::path::{Path, PathBuf}; -pub use backend::{Backend, BackendEvent, user_grasp_list_servers}; -pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout}; -use git_store::set_git_cache; -pub use git_store::{ensure_repo_mirror, open_repo_mirror, repo_mirror_path}; +pub use backend::{Backend, BackendEvent}; +pub use bootstrap::user_grasp_list_servers; +pub use checkouts::{CheckoutStatus, CheckoutsStore}; +pub use git_store::Mirrors; use gpui::{App, AppContext}; -pub use inbox::{Inbox, query_inbox}; -pub use local_repos::{LocalReposStore, ResolvedLocalRepo, resolve_local_repos}; +pub use inbox::Inbox; +pub use local_repos::{LocalReposStore, ResolvedLocalRepo}; pub use nostr_sdk::prelude::Timestamp; pub use profile::{Profile, ProfileStore}; +pub use push::{GraspServer, PushOutcome}; pub use refresh::{RefreshGate, RefreshRequest}; pub use repo::RepoStore; pub use repos::RepoListStore; @@ -42,7 +45,7 @@ pub fn init( let (client, signer) = (backend.client, backend.signer); // Set Git cache for the repos root - set_git_cache(repos_root); + Mirrors::install(repos_root); // Set global stores for the backend Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx); diff --git a/crates/signed_state/src/local_repos.rs b/crates/signed_state/src/local_repos.rs index c1ad4fb..12b84d0 100644 --- a/crates/signed_state/src/local_repos.rs +++ b/crates/signed_state/src/local_repos.rs @@ -137,40 +137,42 @@ impl ResolvedLocalRepo { } /// Resolve the scanned repositories against the known announcements. -pub fn resolve_local_repos( - repos: &[LocalRepo], - known: &[Announcement], - own: &[Announcement], -) -> Vec { - let shown: HashSet = own.iter().map(Announcement::addr).collect(); +impl LocalReposStore { + pub fn resolve( + repos: &[LocalRepo], + known: &[Announcement], + own: &[Announcement], + ) -> Vec { + let shown: HashSet = own.iter().map(Announcement::addr).collect(); - repos - .iter() - .filter_map(|repo| { - let addr = local_repo_addr(repo); + repos + .iter() + .filter_map(|repo| { + let addr = local_repo_addr(repo); - if let Some(addr) = &addr - && shown.contains(addr) - { - return None; - } + if let Some(addr) = &addr + && shown.contains(addr) + { + return None; + } - let announcement = addr - .as_ref() - .and_then(|addr| { - known - .iter() - .find(|announcement| announcement.addr() == *addr) + let announcement = addr + .as_ref() + .and_then(|addr| { + known + .iter() + .find(|announcement| announcement.addr() == *addr) + }) + .cloned(); + + Some(ResolvedLocalRepo { + path: repo.path.clone(), + nip34: repo.nip34.clone(), + announcement, }) - .cloned(); - - Some(ResolvedLocalRepo { - path: repo.path.clone(), - nip34: repo.nip34.clone(), - announcement, }) - }) - .collect() + .collect() + } } #[cfg(test)] @@ -222,7 +224,7 @@ mod tests { let repo = bound(KEY, "mine"); let own = std::slice::from_ref(&own); - assert!(resolve_local_repos(&[repo], own, own).is_empty()); + assert!(LocalReposStore::resolve(&[repo], own, own).is_empty()); } #[test] @@ -230,7 +232,7 @@ mod tests { let known = announcement(OTHER_KEY, "theirs"); let repo = bound(OTHER_KEY, "theirs"); - let resolved = resolve_local_repos(&[repo], std::slice::from_ref(&known), &[]); + let resolved = LocalReposStore::resolve(&[repo], std::slice::from_ref(&known), &[]); assert_eq!(resolved.len(), 1); assert_eq!(resolved[0].announcement.as_ref(), Some(&known)); diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index 02f84b5..daeffc3 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -10,7 +10,8 @@ use gpui::{ use nostr_sdk::prelude::*; use utils::shorten_pubkey; -use crate::backend::{Backend, BackendEvent, sync_bootstrap_only}; +use crate::backend::{Backend, BackendEvent}; +use crate::bootstrap::sync_bootstrap_only; /// How long to wait for more requests before firing a batched fetch. const BATCH_TIMEOUT: Duration = Duration::from_millis(500); diff --git a/crates/signed_state/src/push.rs b/crates/signed_state/src/push.rs new file mode 100644 index 0000000..9bd06af --- /dev/null +++ b/crates/signed_state/src/push.rs @@ -0,0 +1,577 @@ +use std::path::Path; +use std::time::Duration; + +use anyhow::{Error, bail}; +use gpui::BackgroundExecutor; +use nostr::event::IntoEventBuilder; +use nostr::prelude::Url; +use nostr_sdk::prelude::*; +use signed_core::RepoState; +use signed_git::Repo; +use signed_nostr::UniversalSigner; + +pub(crate) const GRASP_PUSH_ATTEMPTS: usize = 3; + +/// Pause before re-staging a state event after a transient denial. +const GRASP_RETRY_DELAY: Duration = Duration::from_secs(1); + +/// Base URL of a grasp server, `https://`. +/// +/// `ws://` grasp servers use `http://`, like ngit. +pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option { + // `domain()` drops the port. + let parsed = Url::parse(relay.as_str()).ok()?; + let host = parsed.host_str()?; + let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default(); + // `ws://` grasp servers, e.g. local dev relays, speak plain HTTP. + let scheme = if relay.scheme().is_secure() { + "https" + } else { + "http" + }; + Some(format!("{scheme}://{host}{port}")) +} + +pub(crate) fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option { + let base = grasp_base_url(relay)?; + Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok() +} + +/// GRASP-06 contributor namespace URL of a pull request tip. +pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String { + format!("{base_url}/prs/{npub}/{repo_id}.git") +} + +/// Assemble the `clone` URLs of a pull request. +/// +/// The author's GRASP-06 `/prs/` URLs come first. +pub(crate) fn pr_clone_urls(prs_urls: Vec, base_clone_urls: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut urls = Vec::new(); + for url in prs_urls.into_iter().chain(base_clone_urls) { + if seen.insert(url.to_string()) { + urls.push(url); + } + } + urls +} + +#[derive(Debug, Clone)] +pub struct GraspServer { + relay: RelayUrl, + /// `None` when the server accepted the data, the reason otherwise. + reason: Option, +} + +impl GraspServer { + fn ok(relay: RelayUrl) -> Self { + Self { + relay, + reason: None, + } + } + + fn failed(relay: RelayUrl, reason: impl Into) -> Self { + Self { + relay, + reason: Some(reason.into()), + } + } + + pub fn relay(&self) -> &RelayUrl { + &self.relay + } + + /// `None` when the server accepted the data, the reason otherwise. + pub fn reason(&self) -> Option<&str> { + self.reason.as_deref() + } +} + +#[derive(Debug, Clone, Default)] +pub struct PushOutcome { + /// Per-server results, in the order the servers were listed. + pub servers: Vec, + /// The newest state event a grasp relay accepted for this push, if any. + /// + /// Broadcast to the other relays once a git server holds the data. + pub state_event: Option, +} + +impl PushOutcome { + pub fn accepted(&self) -> usize { + self.servers + .iter() + .filter(|server| server.reason.is_none()) + .count() + } + + fn failing(&self) -> impl Iterator { + self.servers.iter().filter(|server| server.reason.is_some()) + } + + pub fn failure_summary(&self) -> String { + self.failing() + .map(|server| { + let reason = + utils::flatten_whitespace(server.reason.as_deref().unwrap_or("unknown error")); + format!("{}: {reason}", server.relay) + }) + .collect::>() + .join("; ") + } + + /// A warning for a push only some grasp servers accepted. + /// + /// `None` when every server accepted the push or nothing was pushed. + pub fn partial_warning(&self) -> Option { + let accepted = self.accepted(); + if self.servers.is_empty() || accepted == self.servers.len() { + return None; + } + Some(format!( + "Pushed to {accepted} of {} grasp servers: {}. Republish to sync.", + self.servers.len(), + self.failure_summary() + )) + } +} + +/// Reasons a push attempt should be retried with a freshly staged state +/// event and a fresh git advertisement. +/// +/// Two families are retried: +/// +/// - **Purgatory denials**: the grasp server sends these when the state +/// event for the push has not reached its purgatory yet. Re-staging a +/// fresh event resolves them. +/// - **Stale advertisement races**: `git receive-pack` compares each ref +/// update against the value it advertised when the push started. The grasp +/// server's own background sync can move a ref in between - typically by +/// aligning the repository to a parked state event once the objects of an +/// earlier attempt land - so the compare-and-swap fails with `cannot lock +/// ref` / `incorrect old value provided`. A retry against the fresh +/// advertisement converges, and when the race is lost the pushed data is +/// usually already on the server (see `is_stale_advertisement_race` and +/// the convergence probe in `push_staged_to_grasps`). +/// +/// Other rejections are not retried. +fn is_transient_grasp_denial(stderr: &str) -> bool { + let error = stderr.to_lowercase(); + [ + "no state events in purgatory", + "no matching state event", + "doesn't match push", + "none from authorized publishers", + "no repository announcement found", + "cannot lock ref", + "incorrect old value provided", + ] + .iter() + .any(|marker| error.contains(marker)) +} + +/// A push rejected because `git receive-pack`'s compare-and-swap lost to the +/// grasp server's own background ref alignment: the ref moved between this +/// push's advertisement and its ref transaction (`cannot lock ref ... is at +/// ... but expected ...` / `incorrect old value provided`). The pushed data +/// is usually already on the server by then. +fn is_stale_advertisement_race(stderr: &str) -> bool { + let error = stderr.to_lowercase(); + error.contains("cannot lock ref") || error.contains("incorrect old value provided") +} + +/// Keep `event` as the push's fan-out state event when it is newer than the +/// current one. All staged events carry the same refs; the newest timestamp +/// wins on the relays. +fn keep_newest(state_event: &mut Option, event: Event) { + if state_event + .as_ref() + .is_none_or(|current| event.created_at > current.created_at) + { + *state_event = Some(event); + } +} + +/// The grasp push pipeline: stage a signed state event on each server's +/// relay, then push the git data, retrying transient denials. +#[derive(Clone)] +pub(crate) struct GraspPush { + client: Client, + signer: UniversalSigner, +} + +impl GraspPush { + pub(crate) fn new(client: Client, signer: UniversalSigner) -> Self { + Self { client, signer } + } + + /// The event was accepted by at least one relay, or a descriptive error otherwise. + pub(crate) fn require_relay_accepted( + output: SendEventOutput, + event: Event, + ) -> Result { + if output.success.is_empty() && !output.failed.is_empty() { + let reasons = output + .failed + .values() + .cloned() + .collect::>() + .join(", "); + bail!("event not accepted by any relay: {reasons}"); + } + + Ok(event) + } + + /// Sign and broadcast `builder`, logging rather than surfacing failures. + /// + /// Used for best-effort identity bootstrap events, where a relay hiccup + /// should not block sign-up. + pub(crate) async fn publish_best_effort(&self, builder: EventBuilder) { + let result: Result<(), Error> = async { + let event = builder.finalize_async(&self.signer).await?; + let output = self.client.send_event(&event).broadcast().await?; + Self::require_relay_accepted(output, event)?; + Ok(()) + } + .await; + + if let Err(e) = result { + log::warn!("failed to publish identity bootstrap event: {e}"); + } + } + + /// Sign `builder`, broadcast the event and require a relay to accept it. + /// Returns the signed event. + pub(crate) async fn publish_one(&self, builder: EventBuilder) -> Result { + let event = builder.finalize_async(&self.signer).await?; + self.send_accepted(event).await + } + + /// Broadcast an already signed event and require a relay to accept it. + /// Returns the event. + pub(crate) async fn send_accepted(&self, event: Event) -> Result { + let output = self.client.send_event(&event).broadcast().await?; + Self::require_relay_accepted(output, event) + } + + /// Sign and send a single NIP-09 deletion request for `event`. + pub(crate) async fn retract_event(&self, event: &Event) -> Result<(), Error> { + let builder = EventDeletionRequest::new() + .id(event.id) + .into_event_builder(); + + let deletion = builder.finalize_async(&self.signer).await?; + self.client.send_event(&deletion).broadcast().await?; + + Ok(()) + } + + /// Sign a fresh kind `30618` state event for the push. + /// + /// `last_created_at` is the timestamp of the previous event signed for this push. + /// Retries within the same second get the next second: a grasp relay + /// treats a same-id resend as a duplicate and does not re-run its ingest, + /// so an identical resend cannot re-park a state event lost from its purgatory. + async fn sign_state_event( + &self, + repo_id: &str, + refs: &[(String, String)], + head: Option<&str>, + last_created_at: u64, + ) -> Result<(Event, u64), String> { + let now = Timestamp::now().as_secs(); + let created_at = if now > last_created_at { + now + } else { + last_created_at + 1 + }; + + let event = RepoState::build(repo_id, refs, head) + .custom_created_at(Timestamp::from_secs(created_at)) + .finalize_async(&self.signer) + .await + .map_err(|e| format!("could not sign the state event: {e}"))?; + + Ok((event, created_at)) + } + + /// Ensure the relay is known and connected, then publish `event` to it. + /// + /// `Ok` only when the relay confirmed the event. + /// On a grasp relay the accept parks the event in purgatory, + /// which authorizes the paired git push. + async fn stage_event_on_relay(&self, relay: &RelayUrl, event: &Event) -> Result<(), String> { + self.client + .add_relay(relay) + .and_connect() + .await + .map_err(|e| format!("could not add relay {relay}: {e}"))?; + + let output = self + .client + .send_event(event) + .to([relay.clone()]) + .await + .map_err(|e| format!("could not send the state event to {relay}: {e}"))?; + + if output.success.contains_key(relay) { + Ok(()) + } else { + let reason = output + .failed + .get(relay) + .cloned() + .unwrap_or_else(|| "relay did not confirm the event".to_owned()); + Err(reason) + } + } + + #[allow(clippy::too_many_arguments)] + pub(crate) async fn push_staged_to_grasps( + &self, + repo_id: &str, + refs: &[(String, String)], + head: Option<&str>, + path: &Path, + owner: &str, + servers: &[RelayUrl], + executor: &BackgroundExecutor, + push: impl Fn(&Path, &str, &str, &str) -> Result<(), Error>, + ) -> PushOutcome { + let mut outcome = PushOutcome::default(); + + for relay in servers { + let Some(base) = grasp_base_url(relay) else { + outcome + .servers + .push(GraspServer::failed(relay.clone(), "no domain")); + continue; + }; + let git_url = format!("{base}/{owner}/{repo_id}.git"); + + let mut reason = None; + let mut last_created_at = 0; + // The last state event staged on this server, for the convergence + // probe below when every push attempt lost the stale-ref race. + let mut staged_event = None; + + 'server: for attempt in 1..=GRASP_PUSH_ATTEMPTS { + if attempt > 1 { + // Give the server's ingest a moment before re-staging. + executor.timer(GRASP_RETRY_DELAY).await; + } + + let (event, created_at) = match self + .sign_state_event(repo_id, refs, head, last_created_at) + .await + { + Ok(signed) => signed, + Err(e) => { + reason = Some(e); + break 'server; + } + }; + + last_created_at = created_at; + + // Stage the state event on this server's own relay. + // A failed stage means the grasp never parked the state, + // so the git push would be denied anyway: skip it (the eligibility gate). + if let Err(e) = self.stage_event_on_relay(relay, &event).await { + // One retry absorbs a relay connect blip, on the first + // attempt only. + if attempt == 1 && self.stage_event_on_relay(relay, &event).await.is_ok() { + // staged on the retry + } else { + reason = Some(e); + break 'server; + } + } + staged_event = Some(event.clone()); + + match push(path, &base, owner, repo_id) { + Ok(()) => { + keep_newest(&mut outcome.state_event, event); + break 'server; + } + Err(e) => { + let text = e.to_string(); + if attempt < GRASP_PUSH_ATTEMPTS && is_transient_grasp_denial(&text) { + reason = Some(text); + continue 'server; + } + reason = Some(text); + break 'server; + } + } + } + + // The grasp's own background sync aligns refs to staged state + // events as soon as the objects land, which can beat every push + // attempt's compare-and-swap (`cannot lock ref ... but expected`). + // When the last denial was that race the sync has usually finished + // by now: verify the advertised refs and accept the server when the + // pushed data is already there. + if let Some(last_reason) = &reason + && is_stale_advertisement_race(last_reason) + && Repo::open(path) + .and_then(|repo| repo.remote_has_refs(&git_url, refs)) + .unwrap_or(false) + { + if let Some(event) = staged_event { + keep_newest(&mut outcome.state_event, event); + } + reason = None; + } + + match reason { + Some(reason) => { + log::warn!("grasp push failed: {relay}: {reason}"); + outcome + .servers + .push(GraspServer::failed(relay.clone(), reason)); + } + None => outcome.servers.push(GraspServer::ok(relay.clone())), + } + } + + outcome + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grasp_base_url_maps_schemes_like_ngit() { + let wss = RelayUrl::parse("wss://relay.ngit.dev").expect("url"); + assert_eq!( + grasp_base_url(&wss).as_deref(), + Some("https://relay.ngit.dev") + ); + + let ws = RelayUrl::parse("ws://localhost:8080").expect("url"); + assert_eq!( + grasp_base_url(&ws).as_deref(), + Some("http://localhost:8080") + ); + } + + #[test] + fn grasp_clone_url_matches_ngit_format() { + let relay = RelayUrl::parse("wss://gitnostr.com").expect("url"); + let url = grasp_clone_url(&relay, "npub1test", "my-repo").expect("url"); + assert_eq!( + url.to_string(), + "https://gitnostr.com/npub1test/my-repo.git" + ); + } + + #[test] + fn grasp06_prs_url_matches_ngit_format() { + assert_eq!( + grasp06_prs_url("https://relay.ngit.dev", "npub1author", "my-repo"), + "https://relay.ngit.dev/prs/npub1author/my-repo.git" + ); + // `ws://` grasp servers, local dev, keep their plain-HTTP base. + assert_eq!( + grasp06_prs_url("http://localhost:8080", "npub1author", "my-repo"), + "http://localhost:8080/prs/npub1author/my-repo.git" + ); + } + + #[test] + fn pr_clone_urls_orders_author_first_and_deduplicates() { + let prs = vec![ + Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"), + Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"), + ]; + let base = vec![ + Url::parse("https://a.example/npub1owner/repo.git").expect("url"), + Url::parse("https://b.example/npub1owner/repo.git").expect("url"), + Url::parse("https://b.example/npub1owner/repo.git").expect("url"), + ]; + + let urls = pr_clone_urls(prs, base); + assert_eq!( + urls.iter().map(ToString::to_string).collect::>(), + vec![ + "https://a.example/prs/npub1me/repo.git", + "https://a.example/npub1owner/repo.git", + "https://b.example/npub1owner/repo.git", + ] + ); + } + + #[test] + fn transient_grasp_denials_are_classified() { + // The exact server rejection that started this work: the state event + // had not reached the grasp's purgatory before the git push. + let reported = "remote: ERR authorisation failed: No state events in purgatory\n\ + fatal: the remote end hung up unexpectedly\n\ + error: failed to push some refs to 'https://relay.ngit.dev/...git'"; + assert!(is_transient_grasp_denial(reported)); + + // The other purgatory states a fresh event resolves. + assert!(is_transient_grasp_denial( + "remote: ERR authorisation failed: No matching state event found in purgatory" + )); + assert!(is_transient_grasp_denial( + "remote: ERR authorisation failed: 1 state event in purgatory from authorized \ + publisher but doesn't match push" + )); + assert!(is_transient_grasp_denial( + "remote: ERR authorisation failed: 2 state events in purgatory but none from \ + authorized publishers" + )); + assert!(is_transient_grasp_denial( + "remote: ERR authorisation failed: No repository announcement found" + )); + + // Rejections a fresh state event cannot fix are not retried. + assert!(!is_transient_grasp_denial( + "remote: ERR authorisation failed: not a maintainer of this repository" + )); + assert!(!is_transient_grasp_denial( + "fatal: unable to access 'https://relay.ngit.dev/...': The requested URL returned \ + error: 403" + )); + assert!(!is_transient_grasp_denial( + "fatal: unable to access 'https://relay.ngit.dev/...': Could not resolve host" + )); + } + + #[test] + fn stale_ref_races_are_retried() { + // The grasp's background sync aligned the ref to a parked state event + // between this push's advertisement and its ref transaction. The ref + // is usually already where the push wants it, so a retry converges. + let reported = "remote: error: cannot lock ref 'refs/heads/main': is at \ + cac2ac91b6f5fb8dfcb6962785babc6e65350cb3 but expected \ + bc5e892aa84dc6240a5fbcd59367a4857d26f49b\n\ + To https://relay.ngit.dev/npub1owner/signed-test.git\n\ + ! [remote rejected] main -> main (incorrect old value provided)\n\ + error: failed to push some refs to 'https://relay.ngit.dev/npub1owner/signed-test.git'"; + assert!(is_transient_grasp_denial(reported)); + assert!(is_stale_advertisement_race(reported)); + + // Markers match independently of the surrounding git output. + assert!(is_stale_advertisement_race( + "cannot lock ref 'refs/heads/main'" + )); + assert!(is_stale_advertisement_race( + "! [remote rejected] main -> main (incorrect old value provided)" + )); + + // A purgatory denial is not a stale-advertisement race. + assert!(!is_stale_advertisement_race("No state events in purgatory")); + + // A real divergence is a different error and stays permanent. + assert!(!is_transient_grasp_denial( + " ! [rejected] main -> main (non-fast-forward)" + )); + } +} diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 3d5e139..532ac6e 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -15,11 +15,10 @@ use signed_core::{ use signed_git::{Nip34Binding, PatchParser, Repo}; use signed_nostr::UniversalSigner; -use crate::backend::{ - Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, require_relay_accepted, - user_grasp_list_servers, -}; +use crate::backend::{Backend, BackendEvent}; +use crate::bootstrap::user_grasp_list_servers; use crate::checkouts::CheckoutsStore; +use crate::push::{GraspPush, PushOutcome, grasp_base_url, grasp06_prs_url, pr_clone_urls}; use crate::repos::RepoListStore; /// Maximum size of one patch event. @@ -700,19 +699,12 @@ impl RepoStore { return; }; - let series: Vec = PatchParser::split_patch_series(&patch) - .into_iter() - .map(str::to_owned) - .collect(); + let series = PatchSeries::parse(&patch); - if let Some(oversized) = series - .iter() - .find(|part| part.len() > MAX_PATCH_EVENT_BYTES) - { + if let Some(oversized) = series.oversized_length() { self.last_error = Some(format!( "patch too large ({} bytes; NIP-34 suggests keeping each patch under {} bytes)", - oversized.len(), - MAX_PATCH_EVENT_BYTES + oversized, MAX_PATCH_EVENT_BYTES )); cx.notify(); return; @@ -720,11 +712,7 @@ impl RepoStore { // The tip of the series is its last commit. // `git format-patch` orders patches oldest first. - let Some(current_commit) = series - .last() - .and_then(|part| patch_current_commit(part)) - .and_then(|hex| hex.parse::().ok()) - else { + let Some(current_commit) = series.tip_commit() else { self.last_error = Some( "Patch must be `git format-patch` output with a `From ` header".into(), ); @@ -930,11 +918,10 @@ impl RepoStore { } } - let publish_result: Result = async { - let output = client.send_event(&event).broadcast().await?; - require_relay_accepted(output, event) - } - .await; + let publish_result = { + let pusher = GraspPush::new(client.clone(), signer.clone()); + pusher.send_accepted(event).await + }; let pr_event = match publish_result { Ok(event) => event, @@ -1037,29 +1024,18 @@ impl RepoStore { return; } - let series: Vec = PatchParser::split_patch_series(&patch) - .into_iter() - .map(str::to_owned) - .collect(); - if let Some(oversized) = series - .iter() - .find(|part| part.len() > MAX_PATCH_EVENT_BYTES) - { + let series = PatchSeries::parse(&patch); + if let Some(oversized) = series.oversized_length() { self.last_error = Some(format!( "patch too large ({} bytes; NIP-34 suggests keeping each patch under {} bytes)", - oversized.len(), - MAX_PATCH_EVENT_BYTES + oversized, MAX_PATCH_EVENT_BYTES )); cx.notify(); return; } // The new tip of the PR is the last commit of the series. - let Some(current_commit) = series - .last() - .and_then(|part| patch_current_commit(part)) - .and_then(|hex| hex.parse::().ok()) - else { + let Some(current_commit) = series.tip_commit() else { self.last_error = Some( "Patch must be `git format-patch` output with a `From ` header".into(), ); @@ -1129,12 +1105,10 @@ impl RepoStore { } }; - let publish_result: Result = async { - let event = builder.finalize_async(&signer).await?; - let output = client.send_event(&event).broadcast().await?; - require_relay_accepted(output, event) - } - .await; + let publish_result = { + let pusher = GraspPush::new(client.clone(), signer.clone()); + pusher.publish_one(builder).await + }; if let Err(e) = publish_result { return this.update(cx, |this, cx| { @@ -1216,38 +1190,10 @@ impl RepoStore { return self.action_error("Repository announcement is not loaded yet", cx); }; - self.pushing = true; - self.last_error = None; - self.last_push_warning = None; - cx.notify(); - let backend = Backend::global(cx); let push = backend.update(cx, |backend, cx| backend.push_repository(announcement, cx)); - cx.spawn(async move |this, cx| { - let result = push.await; - - this.update(cx, |this, cx| { - this.pushing = false; - - match &result { - Ok(outcome) => { - this.last_error = None; - // A push only some grasp servers accepted is a warning: - // the repo is out of sync on the rest until it is republished. - this.last_push_warning = outcome.partial_warning(); - } - Err(e) => { - this.last_error = Some(format!("Push failed: {e}")); - this.last_push_warning = None; - } - } - - cx.notify(); - })?; - - result.map(|_| ()) - }) + self.run_push(push, None, cx) } pub fn push_checkout( @@ -1273,17 +1219,30 @@ impl RepoStore { // The checkout may be on a side branch. let head = self.head.clone(); - self.pushing = true; - self.last_error = None; - self.last_push_warning = None; - cx.notify(); - - let checkouts = CheckoutsStore::global(cx); let backend = Backend::global(cx); let push = backend.update(cx, |backend, cx| { backend.push_checkout(announcement, path.clone(), head, cx) }); + self.run_push(push, Some((addr, path)), cx) + } + + /// Run a backend push task, tracking progress in [`Self::pushing`] and + /// the outcome in [`Self::last_error`] and [`Self::last_push_warning`]. + /// + /// `pushed_checkout` names the checkout whose ready-to-push statuses + /// should be recomputed after the remote moved. + fn run_push( + &mut self, + push: Task>, + pushed_checkout: Option<(RepoAddr, PathBuf)>, + cx: &mut Context, + ) -> Task> { + self.pushing = true; + self.last_error = None; + self.last_push_warning = None; + cx.notify(); + cx.spawn(async move |this, cx| { let result = push.await; @@ -1296,10 +1255,12 @@ impl RepoStore { // A push only some grasp servers accepted is a warning: // the repo is out of sync on the rest until it is republished. this.last_push_warning = outcome.partial_warning(); - // The remote moved, so recompute the ready-to-push statuses. - checkouts.update(cx, |store, cx| { - store.checkout_pushed(&addr, &path, cx); - }); + if let Some((addr, path)) = &pushed_checkout { + // The remote moved, so recompute the ready-to-push statuses. + CheckoutsStore::global(cx).update(cx, |store, cx| { + store.checkout_pushed(addr, path, cx); + }); + } } Err(e) => { this.last_error = Some(format!("Push failed: {e}")); @@ -1431,12 +1392,8 @@ impl RepoStore { }; let task: Task> = cx.spawn(async move |this, cx| { - let publish_result: Result = async { - let event = builder.finalize_async(&signer).await?; - let output = client.send_event(&event).broadcast().await?; - require_relay_accepted(output, event) - } - .await; + let pusher = GraspPush::new(client, signer); + let publish_result = pusher.publish_one(builder).await; if let Err(e) = publish_result { this.update(cx, |this, cx| { @@ -1497,6 +1454,46 @@ fn patch_current_commit(patch: &str) -> Option<&str> { hex.split_whitespace().next().filter(|hex| hex.len() == 40) } +/// A `git format-patch` series with the facts derived from its parts. +struct PatchSeries { + parts: Vec, +} + +impl PatchSeries { + fn parse(patch: &str) -> Self { + Self { + parts: PatchParser::split_patch_series(patch) + .into_iter() + .map(str::to_owned) + .collect(), + } + } + + /// The byte length of the first part over the NIP-34 size suggestion. + fn oversized_length(&self) -> Option { + self.parts + .iter() + .map(String::len) + .find(|length| *length > MAX_PATCH_EVENT_BYTES) + } + + /// The tip of the series is its last commit; + /// `git format-patch` orders patches oldest first. + fn tip_commit(&self) -> Option { + self.parts + .last() + .and_then(|part| patch_current_commit(part)) + .and_then(|hex| hex.parse::().ok()) + } + + fn commit_of(&self, index: usize) -> Option<&str> { + self.parts + .get(index) + .and_then(|part| patch_current_commit(part)) + .filter(|hex| hex.len() == 40) + } +} + /// Publish a `git format-patch` series as chained kind-1617 events. /// /// Returns the root event, the one a PR references. @@ -1507,15 +1504,16 @@ async fn publish_patch_series( addr: &RepoAddr, owner: PublicKey, euc: Option<&str>, - series: &[String], + series: &PatchSeries, first_marker: &str, reply_to: Option, ) -> Result { + let pusher = GraspPush::new(client.clone(), signer.clone()); let mut root: Option = None; let mut previous = reply_to; - for (ix, part) in series.iter().enumerate() { - let Some(commit) = patch_current_commit(part).filter(|hex| hex.len() == 40) else { + for (ix, part) in series.parts.iter().enumerate() { + let Some(commit) = series.commit_of(ix) else { return Err(anyhow::anyhow!( "patch {} of the series has no `From ` header", ix + 1 @@ -1557,9 +1555,7 @@ async fn publish_patch_series( } let builder = EventBuilder::new(Kind::GitPatch, part.clone()).tags(tags); - let event = builder.finalize_async(signer).await?; - let output = client.send_event(&event).broadcast().await?; - let event = require_relay_accepted(output, event)?; + let event = pusher.publish_one(builder).await?; if root.is_none() { root = Some(event.clone()); diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs index 8f04a67..6761735 100644 --- a/crates/workspace/src/views/inbox.rs +++ b/crates/workspace/src/views/inbox.rs @@ -13,7 +13,7 @@ use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp}; use signed_core::{InboxItem, InboxReadState, RepoAddr}; use signed_state::{ - Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox, + Backend, BackendEvent, Inbox, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, }; use signed_ui::{Avatar, CountBadge}; use utils::relative_time; @@ -199,7 +199,7 @@ impl InboxView { let state = self.state.clone(); - let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await }); + let work = cx.background_spawn(async move { Inbox::query(&client, me, &state).await }); self.tasks.push(cx.spawn(async move |this, cx| { let (threads, unread_count) = match work.await { diff --git a/crates/workspace/src/views/pull_requests/detail.rs b/crates/workspace/src/views/pull_requests/detail.rs index 7b75eff..58b3b1c 100644 --- a/crates/workspace/src/views/pull_requests/detail.rs +++ b/crates/workspace/src/views/pull_requests/detail.rs @@ -23,7 +23,7 @@ use gpui_component::{ use nostr::prelude::{Event, EventId, Kind, Url}; use signed_core::{GitEvent, PullRequest, RepoAddr}; use signed_git::{FileCommit, PatchParser}; -use signed_state::{Backend, ProfileStore, RepoStore, ensure_repo_mirror}; +use signed_state::{Backend, Mirrors, ProfileStore, RepoStore}; use signed_ui::{Avatar, CountBadge, placeholder, status_badge}; use utils::{relative_time, relative_time_secs}; @@ -261,7 +261,7 @@ impl PullRequestDetailView { Some( cx.background_spawn(async move { - let repo = ensure_repo_mirror(&addr, &clone_urls)?; + let repo = Mirrors::ensure(&addr, &clone_urls)?; let gix_repo = repo.inner(); let workdir = gix_repo diff --git a/crates/workspace/src/views/pull_requests/new.rs b/crates/workspace/src/views/pull_requests/new.rs index 86e6744..d3cb0df 100644 --- a/crates/workspace/src/views/pull_requests/new.rs +++ b/crates/workspace/src/views/pull_requests/new.rs @@ -23,9 +23,7 @@ use gpui_component::{ use nostr::prelude::*; use signed_core::{Announcement, RepoAddr}; use signed_git::{GitCache, Repo}; -use signed_state::{ - Backend, CheckoutsStore, RepoListStore, RepoStore, ensure_repo_mirror, repo_mirror_path, -}; +use signed_state::{Backend, CheckoutsStore, Mirrors, RepoListStore, RepoStore}; use signed_ui::{CountBadge, placeholder, ref_selector_trigger}; use utils::middle_truncate; @@ -531,7 +529,7 @@ impl NewPullRequestView { let Some((base, _euc)) = self.base_repo(cx) else { return; }; - let mirror_path = repo_mirror_path(&base); + let mirror_path = Mirrors::path(&base); let namespace = GitCache::fork_namespace(&announcement); let clone_urls = announcement.clone.clone(); @@ -566,7 +564,7 @@ impl NewPullRequestView { let clone_urls = clone_urls.clone(); let mirror_path = mirror_path.clone(); async move { - ensure_repo_mirror(&base, &base_clone_urls)?; + Mirrors::ensure(&base, &base_clone_urls)?; let repo = Repo::open(&mirror_path)?; // Prune stale imports of any fork. Then import this fork's heads under its namespace. diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 30a8dd4..a16c9f4 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -25,9 +25,8 @@ use nostr::prelude::{RelayUrl, ToBech32, Url}; use signed_core::{Announcement, RepoAddr, RepoStatus}; use signed_git::{FileCommit, GitCache, Repo}; use signed_state::{ - Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, - ProfileStore, RepoListStore, RepoStore, ensure_repo_mirror, open_repo_mirror, - pr_proposes_checkout, + Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Mirrors, Nip34Binding, Nip34Kind, + ProfileStore, RepoListStore, RepoStore, }; use signed_ui::{ Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, ref_selector_trigger, @@ -359,7 +358,7 @@ impl RepoDetailView { let disk = { let addr = addr.clone(); cx.background_spawn(async move { - match open_repo_mirror(&addr)? { + match Mirrors::open(&addr)? { Some(repo) => Ok(Some(load_repo_data(&repo)?)), None => Ok(None), } @@ -376,7 +375,7 @@ impl RepoDetailView { let addr = addr.clone(); let clone_urls = clone_urls.clone(); cx.background_spawn(async move { - let repo = ensure_repo_mirror(&addr, &clone_urls)?; + let repo = Mirrors::ensure(&addr, &clone_urls)?; load_repo_data(&repo) }) .await @@ -403,7 +402,7 @@ impl RepoDetailView { let addr = addr.clone(); cx.background_spawn(async move { - let Some(repo) = open_repo_mirror(&addr)? else { + let Some(repo) = Mirrors::open(&addr)? else { return Ok::<_, Error>(None); }; @@ -1506,8 +1505,12 @@ impl RepoDetailView { continue; } for pr in &store.pull_requests { - if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status) - { + if CheckoutsStore::pr_proposes_checkout( + pr, + store.status_of(pr) == RepoStatus::Open, + user, + &status, + ) { continue 'status; } } diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index fd16d89..396f441 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -21,7 +21,7 @@ use nostr::prelude::RelayUrl; use signed_core::{Announcement, RepoAddr}; use signed_state::{ Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile, - ProfileStore, RepoListStore, ResolvedLocalRepo, resolve_local_repos, + ProfileStore, RepoListStore, ResolvedLocalRepo, }; use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers}; @@ -136,7 +136,7 @@ impl SidebarPanel { let local = LocalReposStore::global(cx).read(cx); let local_repos = - resolve_local_repos(&local.repos, &repo_list.announcements, &announcements); + LocalReposStore::resolve(&local.repos, &repo_list.announcements, &announcements); (announcements, local_repos, local.scanning) }; -- 2.54.0 From a56b0b88812534cfcd587061b838737b5a320753 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 3 Oct 2026 16:31:37 +0700 Subject: [PATCH 8/9] strip redundant doc comments from signed_core and signed_git --- crates/signed_core/src/addr.rs | 16 +-- crates/signed_core/src/deletions.rs | 23 +--- crates/signed_core/src/filters.rs | 39 +----- crates/signed_core/src/inbox.rs | 59 ++------ crates/signed_core/src/model.rs | 95 ++----------- crates/signed_core/src/state.rs | 9 +- crates/signed_core/src/status.rs | 10 +- crates/signed_git/src/cache.rs | 6 +- crates/signed_git/src/diff.rs | 27 +--- crates/signed_git/src/history.rs | 42 +----- crates/signed_git/src/nip34.rs | 24 ++-- crates/signed_git/src/patch.rs | 66 +++------ crates/signed_git/src/remote.rs | 52 ++----- crates/signed_git/src/repo.rs | 101 ++++---------- crates/signed_git/src/scan.rs | 5 +- crates/signed_git/src/tests.rs | 50 ------- crates/signed_git/src/worktree.rs | 50 ++----- crates/signed_nostr/src/signer.rs | 3 - crates/signed_nostr/src/update.rs | 2 - crates/signed_state/src/backend.rs | 80 +++-------- crates/signed_state/src/bootstrap.rs | 5 - crates/signed_state/src/checkouts.rs | 115 +++------------- crates/signed_state/src/git_store.rs | 6 - crates/signed_state/src/inbox.rs | 10 +- crates/signed_state/src/lib.rs | 11 -- crates/signed_state/src/local_repos.rs | 11 -- crates/signed_state/src/profile.rs | 18 +-- crates/signed_state/src/push.rs | 135 ++++++------------ crates/signed_state/src/refresh.rs | 6 +- crates/signed_state/src/repo.rs | 182 +++++-------------------- crates/signed_state/src/repos.rs | 38 ++---- 31 files changed, 265 insertions(+), 1031 deletions(-) diff --git a/crates/signed_core/src/addr.rs b/crates/signed_core/src/addr.rs index 0945259..a3d0194 100644 --- a/crates/signed_core/src/addr.rs +++ b/crates/signed_core/src/addr.rs @@ -4,9 +4,6 @@ use std::str::FromStr; use nostr::prelude::*; use serde::{Deserialize, Serialize}; -/// Address of a NIP-34 repository announcement, `30617::`. -/// -/// Wraps the Rust Nostr SDK's [`Coordinate`], which parses, formats and hashes this. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct RepoAddr(Coordinate); @@ -16,7 +13,6 @@ impl RepoAddr { Self(Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(identifier)) } - /// NIP-34 `d` tag identifier for a user-visible repository name. pub fn identifier_from_name(name: &str) -> String { name.chars() .map(|c| { @@ -45,7 +41,6 @@ impl RepoAddr { &self.0 } - /// Latest announcement event for this repository. pub fn announcement_filter(&self) -> Filter { Filter::new() .kind(Kind::GitRepoAnnouncement) @@ -53,7 +48,6 @@ impl RepoAddr { .identifier(self.identifier()) } - /// Latest state event for this repository, carrying refs and HEAD. pub fn state_filter(&self) -> Filter { Filter::new() .kind(Kind::RepoState) @@ -61,21 +55,13 @@ impl RepoAddr { .identifier(self.identifier()) } - /// All NIP-34 activity addressed to this repository via its `#a` tag. - /// Covers issues, PRs, patches, statuses and kind-1111 comments. - /// The `a` tag is optional on status events per NIP-34. - /// Statuses published without it are not matched here. + // Statuses may omit the `a` tag per NIP-34; those are not matched here. pub fn activity_filter(&self) -> Filter { Filter::new() .kinds(crate::filters::ACTIVITY_KINDS) .coordinate(&self.0) } - /// Deletion events relevant to this repository. - /// - /// Requests authored by the repository owner. - /// - /// Requests addressed to the repository coordinate via its `#a` tag. pub fn deletion_filters(&self) -> Vec { vec![ Filter::new() diff --git a/crates/signed_core/src/deletions.rs b/crates/signed_core/src/deletions.rs index 1fbcd66..7fca784 100644 --- a/crates/signed_core/src/deletions.rs +++ b/crates/signed_core/src/deletions.rs @@ -2,25 +2,14 @@ use std::collections::HashSet; use nostr::prelude::*; -/// NIP-09 deletion requests and NIP-62 vanish requests, -/// built from the kind-5 and kind-62 events in the local database. -/// -/// Deleted events are hidden before they reach the UI. -/// -/// Pass any event through [`Deletions::is_deleted`] before showing it. pub struct Deletions { - /// `(deleted event id, expected author)` from `e` tags of kind-5 events. ids: HashSet<(EventId, PublicKey)>, - /// `(coordinate, expected author, cutoff)` from `a` tags of kind-5 events. - /// - /// All versions of the addressable event up to `cutoff` are deleted. + // All versions of the addressable event up to `cutoff` are deleted. coords: Vec<(Coordinate, PublicKey, Timestamp)>, - /// `(author, cutoff)` from kind-62 vanish requests. vanished: Vec<(PublicKey, Timestamp)>, } impl Deletions { - /// Build the deletion index from raw kind-5 and kind-62 events. pub fn from_events(events: impl IntoIterator) -> Self { let mut ids = HashSet::new(); let mut coords = Vec::new(); @@ -36,8 +25,8 @@ impl Deletions { .map(|c| (c, event.pubkey, event.created_at)), ); } else if event.kind == Kind::RequestToVanish { - // Client-side we can't verify which relay the request targeted. - // Any vanish request is then honored for the author's events. + // We can't verify which relay the request targeted, so any + // vanish request is honored for the author's events. vanished.push((event.pubkey, event.created_at)); } } @@ -49,10 +38,8 @@ impl Deletions { } } - /// Whether the event is covered by a valid deletion or vanish request. - /// A request is valid when its author matches the deleted event's author, per NIP-09. - /// - /// Addressable events are deleted up to the request's `created_at`. + // A request is valid when its author matches the deleted event's author, + // per NIP-09. Addressable events are deleted up to the request's `created_at`. pub fn is_deleted(&self, event: &Event) -> bool { if self .vanished diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index 01710e2..9b09a6a 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -2,7 +2,6 @@ use std::time::Duration; use nostr::prelude::*; -/// Kinds that make up the activity of a repository. pub const ACTIVITY_KINDS: [Kind; 9] = [ Kind::Comment, Kind::GitPatch, @@ -15,7 +14,6 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [ Kind::GitStatusDraft, ]; -/// Kinds that notify a user when they tag them via their `p` tag. const NOTIFICATION_KINDS: [Kind; 8] = [ Kind::GitIssue, Kind::GitPullRequest, @@ -27,7 +25,6 @@ const NOTIFICATION_KINDS: [Kind; 8] = [ Kind::GitStatusDraft, ]; -/// Git root kinds that make a comment count as git activity. const GIT_ROOT_KINDS: [Kind; 4] = [ Kind::GitIssue, Kind::GitPatch, @@ -35,7 +32,6 @@ const GIT_ROOT_KINDS: [Kind; 4] = [ Kind::GitRepoAnnouncement, ]; -/// Whether `kind` carries repository data: announcements, states, activity and deletions. pub fn is_repo_kind(kind: Kind) -> bool { kind == Kind::GitRepoAnnouncement || kind == Kind::RepoState @@ -44,7 +40,6 @@ pub fn is_repo_kind(kind: Kind) -> bool { || ACTIVITY_KINDS.contains(&kind) } -/// Value of the first tag named `name` on `event`. fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> { event .tags @@ -53,19 +48,15 @@ fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> { .and_then(|tag| tag.content()) } -/// Kind named by the first tag `name` on `event`. fn tag_kind(event: &Event, name: &str) -> Option { tag_value(event, name)?.parse::().ok() } -/// Namespace for the repository-agnostic filter constructors. pub struct Filters; impl Filters { - /// How far back deletion requests are fetched and stored. const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400); - /// Status events, kinds `1630..=1633`, referencing any of the given root events. pub fn statuses_for(roots: impl IntoIterator) -> Filter { Filter::new() .kinds([ @@ -77,17 +68,13 @@ impl Filters { .events(roots) } - /// A user's grasp list, kind `10317`. pub fn grasp_list(public_key: PublicKey) -> Filter { Filter::new() .kind(Kind::GitUserGraspList) .author(public_key) } - /// NIP-22 comments, kind `1111`, referencing any of the given root events. - /// The roots are issues, patches and PRs. - /// - /// Returns two filters, since combining `#E` and `#e` would AND the conditions. + // Two filters: combining `#E` and `#e` would AND the conditions. pub fn comments_for(roots: impl IntoIterator) -> Vec { let roots: Vec = roots.into_iter().map(|id| id.to_hex()).collect(); if roots.is_empty() { @@ -103,7 +90,6 @@ impl Filters { ] } - /// NIP-22 comments on our issues, patches and pull requests. fn notification_comments(me: PublicKey) -> Filter { Filter::new() .kind(Kind::Comment) @@ -111,8 +97,7 @@ impl Filters { .custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"]) } - /// Activity directed at us: comments on our roots, and git events tagging us - /// via their lowercase `p` tag. `Filter::pubkey` sets that `p` tag. + // `Filter::pubkey` matches the git events' lowercase `p` tag. pub fn notifications(me: PublicKey) -> Vec { vec![ Self::notification_comments(me), @@ -120,37 +105,27 @@ impl Filters { ] } - /// Git activity authored by `me`, for "Continue where you left off". - /// - /// A comment on an unrelated kind matches too, so results must be filtered - /// through `GitEvent::is_git_activity` before display. + // A comment on an unrelated kind matches too, so results must be filtered + // through `GitEvent::is_git_activity` before display. pub fn authored_activity(me: PublicKey) -> Filter { Filter::new().kinds(ACTIVITY_KINDS).author(me) } - /// All repository announcements, for global discovery. pub fn all_announcements() -> Filter { Filter::new().kind(Kind::GitRepoAnnouncement) } - /// All repository state events, carrying each repository's refs and last push time. pub fn all_states() -> Filter { Filter::new().kind(Kind::RepoState) } - /// `now` minus [`Self::DELETIONS_LOOKBACK`]. - /// Quantized to whole days so identical filters hash the same. - /// - /// This lets the backend's sync dedup match identical filters. + // Quantized to whole days so identical filters hash the same. fn deletions_since() -> Timestamp { let now = Timestamp::now().as_secs(); Timestamp::from_secs(now - now % 86_400) - Self::DELETIONS_LOOKBACK } - /// All deletion-related events within [`Self::DELETIONS_LOOKBACK`]. - /// These are NIP-09 kind `5` and NIP-62 kind `62`. - /// - /// Deletion requests must be known before any other event is shown. + // Deletion requests must be known before any other event is shown. pub fn deletions() -> Filter { Filter::new() .kinds([Kind::EventDeletion, Kind::RequestToVanish]) @@ -158,13 +133,11 @@ impl Filters { } } -/// Whether a kind-1111 comment targets a git root, checked via its `K` tag. pub(crate) fn is_git_comment(event: &Event) -> bool { event.kind == Kind::Comment && tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind)) } -/// Whether a status event references a git root, checked via its `k` tag. pub(crate) fn is_git_status(event: &Event) -> bool { tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind)) } diff --git a/crates/signed_core/src/inbox.rs b/crates/signed_core/src/inbox.rs index 6f9cbaa..d3365d7 100644 --- a/crates/signed_core/src/inbox.rs +++ b/crates/signed_core/src/inbox.rs @@ -6,33 +6,21 @@ use serde::{Deserialize, Serialize}; use crate::{GitEvent, RepoAddr}; -/// Window before `now` that an advanced cutoff retreats to. const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60); - -/// Window before `now` that a mark-all cutoff retreats to. const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60); -/// A thread of notification and own-activity events sharing one root. #[derive(Debug, Clone)] pub struct InboxItem { - /// The root issue, patch or pull request the events belong to. pub root: EventId, - /// The root event itself, when it is known locally. pub root_event: Option, - /// Repository the root belongs to, from the root's `a` tag. pub address: Option, - /// Notification events directed at the user, newest first. pub events: Vec, - /// The user's own events in the thread, newest first. pub own_events: Vec, - /// Unread event ids, oldest first. pub unread_ids: Vec, - /// Whether every notification event in the thread is archived. pub archived: bool, } impl InboxItem { - /// Title of the thread, read from its root issue/patch/PR when known. pub fn title(&self) -> String { self.root_event .as_ref() @@ -50,7 +38,6 @@ impl InboxItem { .map(|event| event.kind) } - /// Timestamp of the newest event in the thread. pub fn latest_activity(&self) -> Timestamp { self.root_event .as_ref() @@ -62,7 +49,6 @@ impl InboxItem { .unwrap_or_default() } - /// Up to `limit` events of the thread, oldest first. pub fn timeline(&self, limit: usize) -> Vec { let mut seen: HashSet = HashSet::new(); let mut events: Vec = Vec::new(); @@ -92,7 +78,6 @@ impl InboxItem { events } - /// Whether the thread has an unread event still visible in the inbox. pub fn is_unread(&self) -> bool { !self.archived && !self.unread_ids.is_empty() } @@ -112,7 +97,6 @@ impl InboxItem { } } -/// Resolves thread roots by following parent pointers through known events. pub struct ThreadResolver<'a, L: ?Sized> { lookup: &'a L, } @@ -125,16 +109,14 @@ where Self { lookup } } - /// Root issue, patch or pull request of a notification event. - /// - /// Returns `None` when the event is not git-related, or when its root is a - /// coordinate rather than an event. - /// - /// - issue (1621) / PR (1618): itself - /// - patch (1617): its `e` parent patch, else itself - /// - NIP-22 comment (1111): uppercase `E` root pointer - /// - PR update (1619): uppercase `E` - /// - statuses (1630-1633): NIP-10 root `e` + // Kind → root mapping: + // - issue (1621) / PR (1618): itself + // - patch (1617): its `e` parent patch, else itself + // - NIP-22 comment (1111): uppercase `E` root pointer + // - PR update (1619): uppercase `E` + // - statuses (1630-1633): NIP-10 root `e` + // Returns `None` when the event is not git-related, or when its root is a + // coordinate rather than an event. pub fn notification_root(&self, event: &Event) -> Option { match event.kind { Kind::GitIssue | Kind::GitPullRequest => Some(event.id), @@ -159,7 +141,7 @@ where } } - /// Follow NIP-10/NIP-22 parent pointers until a root item is reached. + // Follow NIP-10/NIP-22 parent pointers until a root item is reached. pub fn resolve_thread_root(&self, id: EventId) -> EventId { let mut seen = HashSet::new(); let mut root = id; @@ -184,7 +166,7 @@ where } } - /// Parent of a thread event, mirroring gitworkshop's `getParentId`. + // Mirrors gitworkshop's `getParentId`. fn parent_id(&self, event: &Event) -> Option { for marker in ["reply", "root"] { if let Some(id) = event @@ -217,7 +199,6 @@ where self.first_uppercase_e_id(event) } - /// NIP-10 root of an event: the `e` tag marked `root`, else the first `e` tag. fn nip10_root_id(&self, event: &Event) -> Option { event .tags @@ -226,12 +207,10 @@ where .or_else(|| self.first_e_id(event)) } - /// First `e` tag id, in document order. fn first_e_id(&self, event: &Event) -> Option { self.first_tag_id(event, "e") } - /// First uppercase `E` tag id, in document order. fn first_uppercase_e_id(&self, event: &Event) -> Option { self.first_tag_id(event, "E") } @@ -246,7 +225,6 @@ where }) } - /// Event id from a four-element `e` tag carrying `marker`. fn e_tag_with_marker(&self, tag: &Tag, marker: &str) -> Option { let slice = tag.as_slice(); if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker { @@ -257,7 +235,6 @@ where } } -/// Group notification events and the user's own events into one item per thread. pub fn group( events: E, own: O, @@ -328,7 +305,8 @@ where items } -/// Read and archive state of the inbox, a high-water-mark model. +// High-water-mark model: events at or before the cutoff +// are covered without an entry in the id set. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct InboxReadState { #[serde(default)] @@ -342,31 +320,26 @@ pub struct InboxReadState { } impl InboxReadState { - /// Whether `event` is at or before the read cutoff, or marked read. pub fn is_read(&self, event: &Event) -> bool { event.created_at <= self.read_before || self.read_ids.contains(&event.id) } - /// Whether `event` is at or before the archived cutoff, or marked archived. pub fn is_archived(&self, event: &Event) -> bool { event.created_at <= self.archived_before || self.archived_ids.contains(&event.id) } - /// Mark one event read. Events at or before the cutoff are already read. pub fn mark_read(&mut self, event: &Event) { if event.created_at > self.read_before { self.read_ids.insert(event.id); } } - /// Mark one event archived. Events at or before the cutoff are already archived. pub fn mark_archived(&mut self, event: &Event) { if event.created_at > self.archived_before { self.archived_ids.insert(event.id); } } - /// Mark every non-self event read, anchoring the cutoff ten days back. pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) { let cutoff = now - MARK_ALL_WINDOW; self.read_before = cutoff; @@ -377,15 +350,14 @@ impl InboxReadState { .collect(); } - /// Advance the read cutoff to the newest point that keeps unread events - /// unread, then prune the id set. + // Advance the cutoff to the newest point that keeps unread events unread, + // then prune the id set. pub fn advance_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) { let cutoff = advance_cutoff(all, me, now, self.read_before, |event| self.is_read(event)); self.read_before = cutoff; prune_ids(&mut self.read_ids, all, cutoff); } - /// Advance the archived cutoff, mirroring [`Self::advance_read`]. pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) { let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| { self.is_archived(event) @@ -395,7 +367,6 @@ impl InboxReadState { } } -/// Newest cutoff that keeps unread events unread, never earlier than `current`. fn advance_cutoff( all: &[Event], me: PublicKey, @@ -422,7 +393,6 @@ where candidate.max(current) } -/// Drop ids whose event is unknown or now covered by the cutoff. fn prune_ids(ids: &mut HashSet, all: &[Event], cutoff: Timestamp) { let created_at: HashMap = all .iter() @@ -578,7 +548,6 @@ mod tests { assert_eq!(items[0].kind(), Some(Kind::GitIssue)); assert_eq!(items[0].title(), "Add retry logic"); assert_eq!(items[0].events, vec![reply.clone()]); - // The own events are kept apart from the notifications, newest first. assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]); assert_eq!( items[0] diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 479e6a2..8baafb4 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -4,42 +4,28 @@ use nostr::prelude::*; use crate::RepoAddr; -/// Parsed NIP-34 repository announcement, plain data ready for the UI. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Announcement { - /// ID of the announcement event itself. pub event_id: EventId, - /// Repository ID, the `d` tag. pub id: String, - /// Author of the announcement event. pub owner: PublicKey, - /// When the announcement was published, used for latest-wins resolution. pub created_at: Timestamp, pub name: Option, pub description: Option, - /// Webpage URLs for browsing. pub web: Vec, - /// URLs for `git clone`. pub clone: Vec, - /// Relays the repository monitors for patches and issues. pub relays: Vec, - /// Earliest unique commit ID, the `r` tag with `euc` marker. pub euc: Option, - /// Other recognized maintainers. pub maintainers: Vec, - /// Marks the repository as a subordinate fork of the upstream, per NIP-34. pub upstream: Option, - /// Hashtags labelling the repository, the `t` tags. pub hashtags: Vec, } -/// The `u` tag of a fork announcement, per NIP-34. +// The `u` tag of a fork announcement, per NIP-34. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Upstream { - /// Raw first value of the `u` tag, a coordinate or git URL. pub raw: String, - /// Upstream repository coordinate when the `u` tag names a NIP-34 repository. - /// `None` for the git-URL form. + // `None` for the git-URL form. pub addr: Option, } @@ -65,27 +51,20 @@ impl Upstream { } } -/// Tag accessors for NIP-34 git collaboration events. pub trait GitEvent { - /// Subject of an issue or pull request event. fn activity_subject(&self) -> String; - /// The `c` tag, the tip of the proposed branch, as hex. fn current_commit(&self) -> Option; - /// The `merge-base` tag, the base commit a pull request diffs against. fn merge_base(&self) -> Option; - /// The `clone` tag, URLs the tip commit can be fetched from. fn clone_urls(&self) -> Option>; - /// The `branch-name` tag, the proposed branch's name. fn branch_name(&self) -> Option; - /// Whether the event is git activity worth showing in the activity list. fn is_git_activity(&self) -> bool; - /// Whether the event carries an `e`/`E` tag pointing at `root`. + // Matches both NIP-10 lowercase `e` and NIP-22 uppercase `E` root pointers. fn references_root(&self, root: &EventId) -> bool; } @@ -196,7 +175,6 @@ impl GitEvent for &T { } } -/// A NIP-34 pull request root event, with its patch set and update history. pub struct PullRequest<'a>(pub &'a Event); impl<'a> PullRequest<'a> { @@ -204,22 +182,17 @@ impl<'a> PullRequest<'a> { Self(event) } - /// The patch set of the pull request. - /// - /// Returns an empty list when no patch event can be linked to the PR. + // The PR references its root patch via an `e` tag. pub fn patches(&self, patches: impl IntoIterator) -> Vec<&'a Event> { let pr = self.0; let patches: Vec<&'a Event> = patches.into_iter().collect(); - // The PR references its root patch via an `e` tag. if let Some(root_id) = pr.tags.event_ids().next() && let Some(root) = patches.iter().find(|patch| patch.id == root_id) { return Self::forward_series(root, &patches); } - // The PR has no `e` tag. - // The last patch of the set carries the tip commit in its `commit` or `r` tag. let Some(tip) = pr.current_commit() else { return Vec::new(); }; @@ -250,9 +223,7 @@ impl<'a> PullRequest<'a> { series } - /// The patch set of the pull request joined into one diff text. - /// - /// Falls back to the root event's content when no patch set is found. + // Falls back to the root event's content when no patch set is found. pub fn patch(&self, patches: impl IntoIterator) -> String { let pr = self.0; let patches: Vec<&'a Event> = patches.into_iter().collect(); @@ -267,10 +238,8 @@ impl<'a> PullRequest<'a> { .join("\n") } - /// The newest `GitPullRequestUpdate` revising `root`, from the root's own author. - /// - /// A pull request's tip is only mutable by its author per NIP-34, updates - /// from anyone else are ignored even if they are newer. + // A pull request's tip is only mutable by its author per NIP-34, + // updates from anyone else are ignored even if they are newer. pub fn latest_update( events: impl Iterator, root: &Event, @@ -287,7 +256,6 @@ impl<'a> PullRequest<'a> { .max_by_key(|e| e.created_at) } - /// The chain of patches replying to `root` via NIP-10 `e` tags, oldest first. fn forward_series(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> { let mut series = vec![root]; loop { @@ -309,9 +277,7 @@ impl<'a> PullRequest<'a> { series } - /// Whether `patch` produces `commit`, found via its `commit` or `r` tag. - /// - /// It lets clients find existing patches for a specific commit. + // Lets clients find existing patches for a specific commit. fn patch_produces_commit(patch: &Event, commit: &str) -> bool { patch .tags @@ -324,9 +290,7 @@ impl<'a> PullRequest<'a> { } impl Announcement { - /// The announced forks of `base` a new pull request compare can be built from. - /// - /// The user's own forks are listed first. + // The user's own forks are listed first. pub fn forks_in<'a>( announcements: &'a [Announcement], base: &RepoAddr, @@ -413,15 +377,11 @@ impl Announcement { RepoAddr::new(self.owner, self.id.clone()) } - /// The name of the repository, or a default if none is provided. pub fn name(&self) -> String { self.name.clone().unwrap_or("Untitled".into()) } - /// Whether this announcement is a fork of the repository at `base`. - /// Its `u` tag points at `base`, which also covers permanent forks whose EUC diverged. - /// - /// Or it shares `base`'s earliest unique commit and is not the base itself. + // The `u` tag pointing at `base` also covers permanent forks whose EUC diverged. pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool { if self.addr() == *base { return false; @@ -432,17 +392,14 @@ impl Announcement { base_euc.is_some_and(|euc| self.euc.as_deref() == Some(euc)) } - /// The description of the repository, or a default if none is provided. pub fn description(&self) -> String { self.description .clone() .unwrap_or("No description".to_string()) } - /// The effective maintainers of this repository, - /// the announced `maintainers` plus the announcement author. - /// - /// A `u` tag that marks the repository as a subordinate fork excludes them, per NIP-34. + // A `u` tag marking the repository as a subordinate fork excludes the + // announcement author from the maintainers, per NIP-34. pub fn effective_maintainers(&self) -> Vec { let mut maintainers = self.maintainers.clone(); if self.upstream.is_none() && !maintainers.contains(&self.owner) { @@ -451,7 +408,6 @@ impl Announcement { maintainers } - /// The `git clone` URLs for this repository, deduplicated. pub fn clone_urls(&self) -> Vec { let mut seen = HashSet::new(); self.clone @@ -545,7 +501,6 @@ mod tests { let announcement = Announcement::from_event(&event).expect("parses"); - // An invalid URL keeps the whole clone tag from being parsed. assert!(announcement.clone.is_empty()); assert_eq!( announcement.relays, @@ -568,8 +523,6 @@ mod tests { let announcement = Announcement::from_event(&event).expect("parses"); let upstream = announcement.upstream.expect("parses the u tag"); - // The coordinate part resolves to a repository address. - // The raw value keeps the `|git-url` suffix. assert_eq!( upstream.addr, Some(RepoAddr::new( @@ -589,7 +542,6 @@ mod tests { #[test] fn is_fork_of_matches_the_u_tag_coordinate() { - // The base repository, announced by the `u` tag's owner. let base = RepoAddr::new( PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"), "upstream", @@ -597,26 +549,20 @@ mod tests { let event = announcement_event(&[&["d", "my-fork"], &["u", &base.to_string()]]); let fork = Announcement::from_event(&event).expect("parses"); - // A `u` tag pointing at the base address marks a fork. - // This holds even when neither side announces an EUC. assert!(fork.is_fork_of(&base, None)); } #[test] fn is_fork_of_matches_a_shared_euc() { let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; - // The base repo has no `u` tag. It announces the family EUC. let base_event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]); let base = Announcement::from_event(&base_event).expect("parses"); let base_addr = base.addr(); - // A fork with no `u` tag, a pure mirror or cross-hosted clone, shares the EUC. - // Clients of the family can then find it. let fork_event = announcement_event(&[&["d", "mirror"], &["r", euc, "euc"]]); let fork = Announcement::from_event(&fork_event).expect("parses"); assert!(fork.is_fork_of(&base_addr, base.euc.as_deref())); - // An unrelated repository with a different EUC is not a fork. let other_event = announcement_event(&[ &["d", "other"], &["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"], @@ -624,14 +570,11 @@ mod tests { let other = Announcement::from_event(&other_event).expect("parses"); assert!(!other.is_fork_of(&base_addr, base.euc.as_deref())); - // Without a base EUC there is nothing to compare against. assert!(!fork.is_fork_of(&base_addr, None)); } #[test] fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() { - // A permanent fork re-announces its EUC, the first commit after the fork. - // Only the `u` tag still relates it to the base. let base = RepoAddr::new( PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"), "upstream", @@ -654,8 +597,6 @@ mod tests { let announcement = Announcement::from_event(&event).expect("parses"); let maintainers = announcement.effective_maintainers(); - // The owner asserts themselves as a maintainer of the primary project, per NIP-34. - // Announced co-maintainers are included too. assert_eq!(maintainers.len(), 2); assert!(maintainers.contains(&announcement.owner)); assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"))); @@ -672,8 +613,6 @@ mod tests { let announcement = Announcement::from_event(&event).expect("parses"); let maintainers = announcement.effective_maintainers(); - // A `u` tag marks the repository as a subordinate fork. - // The author is then not a maintainer of the primary project, per NIP-34. assert!(!maintainers.contains(&announcement.owner)); assert_eq!( maintainers, @@ -698,8 +637,6 @@ mod tests { #[test] fn pull_request_patch_joins_the_whole_patch_set() { - // A PR references the root patch, per NIP-34. - // Later patches of the set reply to the previous one via NIP-10 `e` tags. let root = patch_event("patch-one", vec![], 100); let second = patch_event("patch-two", vec![Tag::event(root.id)], 200); let pr = pr_event("description", vec![Tag::event(root.id)]); @@ -733,8 +670,6 @@ mod tests { #[test] fn pull_request_patches_finds_the_set_via_the_tip_commit() { - // PRs without an `e` tag fall back to the patch producing the tip commit. - // Walk the reply chain backward to the root. let root = patch_event("patch-one", vec![], 100); let tip = "1111111111111111111111111111111111111111"; let last = patch_event( @@ -794,7 +729,6 @@ mod tests { created_at, ) }; - // An update revising a different PR must be ignored even though it is newer. let unrelated = signed_at( Kind::GitPullRequestUpdate, vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")], @@ -822,8 +756,6 @@ mod tests { .finalize(&other) .expect("signed event"); - // The tip of a PR is only mutable by its author. - // A newer update from anyone else must not win. assert!(PullRequest::latest_update([&stranger, &root].into_iter(), &root).is_none()); } @@ -861,8 +793,6 @@ mod tests { PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"), "upstream", ); - // Newest first, as RepoListStore keeps them. - // Unrelated repo, the user's fork with the shared EUC, another fork with a `u` tag. let all = vec![ owned_announcements( 2, @@ -936,7 +866,6 @@ mod tests { assert_eq!(forks.len(), 1); assert_eq!(forks[0].id, "mirror"); - // Without a base EUC only `u`-tag forks match. all.push( owned_announcements( 2, diff --git a/crates/signed_core/src/state.rs b/crates/signed_core/src/state.rs index fcf488f..16f444b 100644 --- a/crates/signed_core/src/state.rs +++ b/crates/signed_core/src/state.rs @@ -1,19 +1,13 @@ use nostr::prelude::*; -/// Refs and HEAD parsed from a kind `30618` repository state event. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepoState { - /// `(refname, commit-id)` pairs. pub refs: Vec<(String, String)>, - /// Branch pointed to by the `HEAD` tag, if any. pub head: Option, } impl RepoState { - /// Build a kind `30618` repository state event from refs and HEAD, - /// it is published as `ref: refs/heads/`. - /// - /// The `d` tag matches the repository id. + // The `d` tag matches the repository id. pub fn build(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder { let mut tags: Vec = vec![Tag::identifier(id.to_owned())]; for (name, commit) in refs { @@ -27,7 +21,6 @@ impl RepoState { EventBuilder::new(Kind::RepoState, "").tags(tags) } - /// Parse a kind `30618` repository state event into refs and HEAD. pub fn parse(event: &Event) -> Self { let mut refs = Vec::new(); let mut head = None; diff --git a/crates/signed_core/src/status.rs b/crates/signed_core/src/status.rs index 1537465..091ed21 100644 --- a/crates/signed_core/src/status.rs +++ b/crates/signed_core/src/status.rs @@ -1,6 +1,5 @@ use nostr::prelude::*; -/// Status of a root patch, pull request or issue, kinds `1630..=1633`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum RepoStatus { Open, @@ -29,9 +28,7 @@ impl RepoStatus { } } - /// Resolve the status of a root event per NIP-34. - /// - /// Defaults to [`RepoStatus::Open`]. + // Defaults to Open when no authorized status event exists. pub fn resolve<'a, I>( status_events: I, root_author: &PublicKey, @@ -50,9 +47,8 @@ impl RepoStatus { } } -/// NIP-10 and NIP-34 use the lowercase `e` tag. -/// -/// NIP-22 comments, kind `1111`, use the uppercase `E` tag for the thread root. +// NIP-10 and NIP-34 use the lowercase `e` tag; NIP-22 comments, kind `1111`, +// use the uppercase `E` tag for the thread root. pub fn references_root(event: &Event, root: &EventId) -> bool { let root = root.to_hex(); event diff --git a/crates/signed_git/src/cache.rs b/crates/signed_git/src/cache.rs index 106dd01..5cc04d2 100644 --- a/crates/signed_git/src/cache.rs +++ b/crates/signed_git/src/cache.rs @@ -5,7 +5,6 @@ use signed_core::{Announcement, RepoAddr}; use crate::repo::Repo; -/// On-disk cache of cloned repositories, keyed by owner pubkey / repo id. #[derive(Debug, Clone)] pub struct GitCache { root: PathBuf, @@ -36,7 +35,6 @@ impl GitCache { } } - /// Open the existing clone, fetching it first. pub fn ensure_clone>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result { let path = self.repo_path(addr); @@ -53,7 +51,8 @@ impl GitCache { Repo::clone(clone_urls, &path) } - /// Map an untrusted repository id or display name to a safe single path component. + // `id` is untrusted relay content: it must never escape the cache root as + // a single path component. pub fn sanitize_path_component(id: &str) -> String { let sanitized: String = id .chars() @@ -73,7 +72,6 @@ impl GitCache { sanitized } - /// The refs namespace of a fork's import in the target mirror. pub fn fork_namespace(announcement: &Announcement) -> String { format!( "{}/{}", diff --git a/crates/signed_git/src/diff.rs b/crates/signed_git/src/diff.rs index ed8a394..81e799a 100644 --- a/crates/signed_git/src/diff.rs +++ b/crates/signed_git/src/diff.rs @@ -5,7 +5,6 @@ use crate::repo::Repo; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DiffLineKind { - /// An unchanged context line, present on both sides. Context, Addition, Deletion, @@ -14,21 +13,15 @@ pub enum DiffLineKind { #[derive(Debug, Clone)] pub struct DiffLine { pub kind: DiffLineKind, - /// 1-based line number in the old version, if the line exists there. pub old: Option, - /// 1-based line number in the new version, if the line exists there. pub new: Option, - /// Line content without the trailing newline. pub text: String, } -/// A hunk of a file diff, like `@@ -a,b +c,d @@`. #[derive(Debug, Clone)] pub struct DiffHunk { - /// 1-based start line in the old version. pub old_start: u32, pub old_lines: u32, - /// 1-based start line in the new version. pub new_start: u32, pub new_lines: u32, pub lines: Vec, @@ -45,18 +38,13 @@ pub enum DiffStatus { #[derive(Debug, Clone)] pub struct FileDiff { - /// Path of the file relative to the repo root. - /// - /// For renames and copies, this is the destination path. + // For renames and copies, the destination path. pub path: String, - /// Previous path, for renames and copies. pub old_path: Option, pub status: DiffStatus, - /// Number of added lines, 0 for binary files. pub insertions: usize, - /// Number of removed lines, 0 for binary files. pub deletions: usize, - /// True if either version is binary, then `hunks` is empty. + // Binary files have empty `hunks`. pub binary: bool, pub hunks: Vec, } @@ -67,9 +55,7 @@ pub struct CommitDiff { } impl Repo { - /// The changes of the commit `id`, short or full. - /// - /// Compared against its first parent, the empty tree for the root commit. + // Compared against the first parent; the empty tree for the root commit. pub fn commit_diff(&self, id: &str) -> Result { let commit_id = self.inner.rev_parse_single(id.as_bytes())?; let commit = commit_id.object()?.into_commit(); @@ -81,9 +67,7 @@ impl Repo { Self::tree_diff(self, old_tree.as_ref(), &new_tree) } - /// The changes between two commits, `base`..`tip`, like `git diff base tip`. - /// - /// Directories and submodules are skipped, files are sorted by path. + // Directories and submodules are skipped, files are sorted by path. pub fn range_diff(&self, base: &str, tip: &str) -> Result { let base_tree = self .inner @@ -119,7 +103,6 @@ impl Repo { for change in changes { let attached = Change::from_change_ref(change.to_ref(), &repo.inner, &repo.inner); - // Skip directory trees and submodule gitlinks, only files are listed. let (path, old_path, status) = match attached { Change::Addition { location, @@ -175,7 +158,6 @@ impl Repo { _ => continue, }; - // Always diff with the built-in algorithm. // External diff drivers would shell out, out of scope for a read-only viewer. let platform = attached.diff(&mut cache)?; platform @@ -224,7 +206,6 @@ impl Repo { } } -/// Collects the hunks of one blob diff while tracking per-line numbers. struct HunkCollector<'a> { hunks: &'a mut Vec, insertions: &'a mut usize, diff --git a/crates/signed_git/src/history.rs b/crates/signed_git/src/history.rs index 2669c4f..d26c916 100644 --- a/crates/signed_git/src/history.rs +++ b/crates/signed_git/src/history.rs @@ -5,33 +5,22 @@ use anyhow::Result; use crate::repo::Repo; -/// Metadata of a commit, as shown in the repository browser's file header. #[derive(Debug, Clone)] pub struct FileCommit { - /// Shortened commit id, 7+ hex chars, disambiguated if needed. pub id: String, - /// First line of the commit message. pub summary: String, - /// Rest of the commit message after the title. - /// - /// `None` for single-line commit messages. pub description: Option, pub author: String, - /// Author time, seconds since the Unix epoch. pub time: i64, } impl FileCommit { - /// A [`FileCommit`] with author, message title, body and shortened id. - /// - /// The diff panel fetches the full commit on demand. fn from_commit(commit: &gix::Commit<'_>) -> Result { Self::from_commit_with_description(commit, true) } - /// A [`FileCommit`] without the message body, for history lists that never display it. - /// - /// Skipping the body saves an allocation per listed commit. + // History lists never display the body, + // skipping it saves an allocation per listed commit. fn from_commit_summary(commit: &gix::Commit<'_>) -> Result { Self::from_commit_with_description(commit, false) } @@ -61,10 +50,7 @@ impl FileCommit { } impl Repo { - /// Newest commit touching each of `rels`, like `git log -1 -- ` per path. - /// `rels` are paths relative to the worktree. - /// - /// Paths without any commit, like untracked files, are absent from the result. + // Paths without any commit, like untracked files, are absent from the result. pub fn last_commits(&self, rels: &[PathBuf]) -> Result> { use gix::traverse::commit::simple::CommitTimeOrder; @@ -125,9 +111,6 @@ impl Repo { Ok(found) } - /// All commits reachable from `HEAD`, newest first, with author and summary. - /// - /// Returns an empty list for a repository without any commits yet. pub fn all_commits(&self) -> Result { use gix::traverse::commit::simple::CommitTimeOrder; @@ -159,7 +142,6 @@ impl Repo { Ok(CommitList { total, commits }) } - /// Commits in the range `base`..`tip`, newest first, like `git log base..tip`. pub fn commit_range(&self, base: &str, tip: &str) -> Result> { use gix::traverse::commit::simple::CommitTimeOrder; @@ -183,9 +165,7 @@ impl Repo { Ok(commits) } - /// The commit HEAD points to, like `git log -1`. - /// - /// `Ok(None)` for a repository without commits yet, an unborn HEAD. + // `Ok(None)` for an unborn HEAD. pub fn head_commit(&self) -> Result> { let Some(head) = self.inner.head_id().ok() else { return Ok(None); @@ -194,10 +174,7 @@ impl Repo { Ok(Some(FileCommit::from_commit(&commit)?)) } - /// Full metadata of the commit `id`, short or full. - /// Like [`Repo::head_commit`] for an arbitrary commit. - /// - /// `Ok(None)` when the id cannot be resolved. + // `Ok(None)` when the id cannot be resolved. pub fn commit(&self, id: &str) -> Result> { match self.inner.rev_parse_single(id.as_bytes()) { Ok(commit_id) => { @@ -209,16 +186,11 @@ impl Repo { } } -/// Cap on [`CommitList::commits`]. The virtual list renders a window at a time, -/// the tab badge shows the real count. -/// -/// A huge history is never fully materialized in memory. +// The virtual list renders a window at a time, the tab badge shows the real +// count: a huge history is never fully materialized in memory. pub const MAX_LISTED_COMMITS: usize = 20_000; -/// Commits reachable from `HEAD`, newest first, possibly capped. pub struct CommitList { - /// Number of commits reachable from HEAD. pub total: usize, - /// Newest commits, capped at [`MAX_LISTED_COMMITS`]. pub commits: Vec, } diff --git a/crates/signed_git/src/nip34.rs b/crates/signed_git/src/nip34.rs index 36aa032..d81caa2 100644 --- a/crates/signed_git/src/nip34.rs +++ b/crates/signed_git/src/nip34.rs @@ -7,11 +7,11 @@ use crate::repo::Repo; /// The kind of NIP-34 relationship a local repository has on disk. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Nip34Kind { - /// Bound to a NIP-34 coordinate, by `nak`'s `nip34.json` or `ngit`'s `nostr.repo`. + // By `nak`'s `nip34.json` or `ngit`'s `nostr.repo`. Initialized, - /// Cloned from a `nostr://` remote but never initialized locally. + // Cloned from a `nostr://` remote but never initialized locally. Cloned, - /// Nostr tooling touched the repository but no binding is recoverable. + // Nostr tooling touched the repository but no binding is recoverable. ToolingOnly, } @@ -22,7 +22,7 @@ pub struct GraspSignals { pub nostr_repo_config: bool, pub nostr_remote: bool, pub grasp_remote: bool, - /// `nip34_grasp_remote` is the `nak`-specific `nip34/grasp/` remote name. + // `nak`-specific `nip34/grasp/` remote name. pub nip34_grasp_remote: bool, pub nip34_state_refs: bool, pub nostr_cache: bool, @@ -41,7 +41,7 @@ impl GraspSignals { pub struct Nip34Binding { pub kind: Nip34Kind, pub signals: GraspSignals, - /// Coordinate owner and identifier, from `nip34.json` or `nostr.repo`. + // From `nip34.json` or `nostr.repo`. pub owner: Option, pub identifier: Option, pub grasp_urls: Vec, @@ -54,9 +54,7 @@ struct Nip34Json { } impl Repo { - /// What the repository's on-disk state says about its NIP-34 binding. - /// - /// `None` when no Nostr tooling left a marker. + // `None` when no Nostr tooling left a marker. pub fn nip34_binding(&self) -> Option { let common_dir = self.inner.common_dir().to_path_buf(); let workdir = self.inner.workdir().map(std::path::Path::to_path_buf); @@ -186,7 +184,6 @@ impl Repo { }) } - /// Record the repository's NIP-34 coordinate in its local `nostr.repo` config. pub fn set_nostr_repo(&self, naddr: &str) -> Result<()> { self.edit_local_config(|config| { config.set_raw_value("nostr.repo", naddr)?; @@ -194,8 +191,8 @@ impl Repo { }) } - /// Mirrors `nak`'s `IsGraspURL`: two path segments, a path of at least 65 bytes, - /// and a first segment that decodes as an `npub`. + // Mirrors `nak`'s `IsGraspURL`: two path segments, + // a path of at least 65 bytes, and a first segment that decodes as an `npub`. fn is_grasp_url(url: &str) -> bool { let Ok(parsed) = Url::parse(url) else { return false; @@ -233,8 +230,8 @@ impl Repo { Some((coordinate.public_key, identifier)) } - /// Handles a bare `naddr`, an `npub`, and the optional `[ssh-key-file@]`, - /// `[protocol/]` and `[relay/]` components. An `nip05` owner yields no binding. + // Handles a bare `naddr`, an `npub`, and the optional `[ssh-key-file@]`, + // `[protocol/]` and `[relay/]` components. An `nip05` owner yields no binding. fn parse_nostr_url(url: &str) -> Option<(PublicKey, String)> { let rest = url.strip_prefix("nostr://")?; @@ -252,7 +249,6 @@ impl Repo { parts.remove(0); } - // `[owner, (relay), identifier]`. if parts.len() < 2 { return None; } diff --git a/crates/signed_git/src/patch.rs b/crates/signed_git/src/patch.rs index 05a1216..2331924 100644 --- a/crates/signed_git/src/patch.rs +++ b/crates/signed_git/src/patch.rs @@ -9,14 +9,10 @@ use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, File use crate::history::FileCommit; use crate::repo::Repo; -/// Pure parsing of `git format-patch` output, no repository involved. pub struct PatchParser; impl PatchParser { - /// Split a `git format-patch` series into its individual patches, mbox messages. - /// - /// A single patch yields one element. - /// A malformed input yields one element covering it. + // A malformed input yields one element covering it. pub fn split_patch_series(patch: &str) -> Vec<&str> { Self::envelopes(patch) .into_iter() @@ -24,11 +20,8 @@ impl PatchParser { .collect() } - /// Parse `git format-patch` output, a single patch or a series. - /// - /// Backed by [`diffy::patch_set`], which implements git's extended diff format: - /// `diff --git` headers, rename and copy detection, binary detection, and - /// C-style quoted or octal-escaped paths. + // Backed by `diffy::patch_set`, which implements git's extended diff format: + // rename and copy detection, binary detection, quoted/escaped paths. pub fn patch_diffs(patch: &str) -> Result { if !patch.lines().any(|line| line.starts_with("diff --git ")) { return Ok(CommitDiff { files: Vec::new() }); @@ -43,9 +36,8 @@ impl PatchParser { Ok(CommitDiff { files }) } - /// Commits of a `git format-patch` output, a single patch or a series. - /// - /// Entries appear in patch order, oldest first as `git format-patch` produces them. + // Entries appear in patch order, oldest first as `git format-patch` + // produces them. pub fn patch_commits(patch: &str) -> Vec { Self::envelopes(patch) .into_iter() @@ -70,15 +62,11 @@ impl PatchParser { .collect() } - /// Splits a `git format-patch` mbox into its messages by their envelopes, - /// the one parser [`Self::split_patch_series`] and [`Self::patch_commits`] share. - /// - /// A malformed input yields one message covering the whole input. + // The one parser `split_patch_series` and `patch_commits` share. + // A malformed input yields one message covering the whole input. fn envelopes(patch: &str) -> Vec> { let mut messages: Vec> = Vec::new(); - // Byte offset of the current envelope line, its commit id, and its headers. let mut current: Option<(usize, &str, Vec<&str>)> = None; - // Headers run up to the blank line before the commit message. let mut headers_closed = false; let mut offset = 0usize; @@ -86,7 +74,6 @@ impl PatchParser { let line_start = offset; offset += line.len() + 1; - // A message starts at its `From ` envelope line. let is_envelope = line .strip_prefix("From ") .and_then(|rest| rest.split_whitespace().next()) @@ -132,7 +119,6 @@ impl PatchParser { } if messages.is_empty() { - // Not an mbox at all: one message covering the whole input. messages.push(Envelope { text: patch, id: "", @@ -150,9 +136,7 @@ impl PatchParser { } } - /// Strip the patch prefix from a `Subject:` header. - /// - /// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`. + // Matches `[PATCH]`, `[PATCH 1/2]`, `[RFC PATCH]`, etc. fn strip_patch_prefix(subject: &str) -> String { let trimmed = subject.trim(); let Some(rest) = trimmed.strip_prefix('[') else { @@ -169,9 +153,9 @@ impl PatchParser { } fn file_diff(file: FilePatch<'_, str>) -> Result { - // The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path - // component is dropped, the same way `git apply -p1` does. - // Rename and copy paths come from their own headers, unprefixed. + // The `---`/`+++` paths carry the `a/`/`b/` prefix, dropped the same way + // `git apply -p1` does; rename and copy paths come from their own + // headers, unprefixed. let stripped; let operation = match file.operation() { operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation, @@ -229,11 +213,8 @@ impl PatchParser { }) } - /// The [`DiffHunk`] of one parsed hunk, including the line number of every line. - /// - /// `diffy` reports only the hunk header ranges. The per-line numbers are - /// counted from them the way the header encodes them: context lines advance - /// both sides, deletions only the old, insertions only the new. + // `diffy` reports only the hunk header ranges; the per-line numbers are + // counted from them the way the header encodes them. fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk { let old_range = hunk.old_range(); let new_range = hunk.new_range(); @@ -285,22 +266,16 @@ impl PatchParser { } } - /// The content of a parsed line without its line ending. - /// - /// `diffy` keeps the trailing `\n`, the way `str::lines` splits it off. fn line_text(text: &str) -> String { let text = text.strip_suffix('\n').unwrap_or(text); text.strip_suffix('\r').unwrap_or(text).to_owned() } } -/// A `git format-patch` mbox message, split on its `From <40-hex> ` envelope. +// A `git format-patch` mbox message, split on its `From <40-hex> ` envelope. struct Envelope<'a> { - /// The whole message, envelope and diff. text: &'a str, - /// Commit id from the `From ` line. id: &'a str, - /// Header lines between the envelope and the commit message. headers: Vec<&'a str>, } @@ -315,11 +290,8 @@ impl Envelope<'_> { } impl Repo { - /// Apply a `git format-patch` patch or series with `git am`. - /// - /// Uses the git CLI because it handles the mbox format natively. - /// - /// TODO: replace with a pure-Rust implementation later without changing callers. + // The git CLI handles the mbox format natively. + // TODO: replace with a pure-Rust implementation later without changing callers. pub fn apply_patch(&self, patch: &str) -> Result<()> { let workdir = self .inner @@ -349,10 +321,8 @@ impl Repo { Ok(()) } - /// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`. - /// Fails when the range has no commits. - /// - /// The mbox is returned untrimmed. Trailing newlines are part of the format. + // Fails when the range has no commits. The mbox is returned untrimmed; + // trailing newlines are part of the format. pub fn format_patch_between(&self, base: &str, tip: &str) -> Result { let output = Repo::run_git( self.workdir_or_dot(), diff --git a/crates/signed_git/src/remote.rs b/crates/signed_git/src/remote.rs index 3c78ffe..7170444 100644 --- a/crates/signed_git/src/remote.rs +++ b/crates/signed_git/src/remote.rs @@ -9,7 +9,6 @@ use gix::progress::Discard; use crate::repo::Repo; impl Repo { - /// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace. pub fn fetch(&self) -> Result<()> { let options = gix::remote::ref_map::Options { extra_refspecs: vec![ @@ -29,7 +28,6 @@ impl Repo { Ok(()) } - /// Push `commit` to `reference` at `url`. pub fn push_ref(&self, url: &str, commit: &str, reference: &str) -> Result<()> { let output = Self::run_git( self.workdir_or_dot(), @@ -46,7 +44,6 @@ impl Repo { Ok(()) } - /// Push the local `main` branch to a grasp server. pub fn push_main(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { self.push_refspecs( base_url, @@ -56,9 +53,6 @@ impl Repo { ) } - /// Push every local branch and tag to a grasp server. - /// - /// This mirrors an initialized repository's whole history. pub fn push_all(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { self.push_refspecs( base_url, @@ -93,12 +87,10 @@ impl Repo { Ok(()) } - /// Whether `url` advertises every ref in `expected` at the given commit. - /// - /// Extra advertised refs are ignored: the question is whether the data this - /// push wanted to land is already there, not whether the remote is an exact mirror. - /// This is the convergence probe for a push that lost the compare-and-swap race - /// to the grasp server's own background ref alignment. + // The convergence probe for a push that lost the compare-and-swap race to + // the grasp server's own background ref alignment. Extra advertised refs + // are ignored: the question is whether the pushed data is already there, + // not whether the remote is an exact mirror. pub fn remote_has_refs(&self, url: &str, expected: &[(String, String)]) -> Result { if expected.is_empty() { return Ok(true); @@ -106,9 +98,8 @@ impl Repo { let url = Self::transport_url(url); - // A URL-created remote has no configured fetch refspecs, and `ref_map` only - // keeps refs that match one. Match each expected ref by its exact name, - // like `git ls-remote ` would; ref maps never write to the repository. + // A URL-created remote has no configured fetch refspecs, and `ref_map` + // only keeps refs matching one; match each expected ref by exact name. let refspecs = expected .iter() .map(|(name, _)| { @@ -135,9 +126,8 @@ impl Repo { .ref_map(Discard, options) .with_context(|| format!("listing refs of {url} failed"))?; - // Peeled tag entries carry the tag object in their direct oid, so mapping - // each advertised ref to its direct oid matches `git ls-remote` while - // skipping the duplicated `^{}` lines. + // Peeled tag entries carry the tag object in their direct oid, matching + // `git ls-remote` while skipping the duplicated `^{}` lines. let advertised: HashMap = refs .remote_refs .iter() @@ -152,9 +142,6 @@ impl Repo { .all(|(name, oid)| advertised.get(name.as_str()) == Some(oid))) } - /// Add `origin` pointing at `url` when the repository has no remote yet. - /// - /// No-op if `origin` already exists. pub fn ensure_origin(&self, url: &str) -> Result<()> { if self.inner.find_remote("origin").is_ok() { return Ok(()); @@ -168,16 +155,12 @@ impl Repo { }) } - /// Point `origin` at `url`, replacing an existing remote, - /// used after a clone whose `origin` points at the cloned-from path. - /// - /// A working copy cloned from a local mirror is re-targeted at the grasp server. + // A working copy cloned from a local mirror is re-targeted at the + // grasp server; a pre-existing fetch refspec is left untouched. pub fn set_origin(&self, url: &str) -> Result<()> { let had_origin = self.inner.find_remote("origin").is_ok(); self.edit_local_config(|config| { - // Replaces the existing url, like `git remote set-url origin `. - // A pre-existing fetch refspec is left untouched. config.set_raw_value("remote.origin.url", url)?; if !had_origin { @@ -189,9 +172,6 @@ impl Repo { }) } - /// The URL of the `origin` remote. - /// - /// `None` when the repository has no `origin` yet. pub fn origin_url(&self) -> Result> { let Ok(remote) = self.inner.find_remote("origin") else { return Ok(None); @@ -202,10 +182,8 @@ impl Repo { .map(|url| url.to_string())) } - /// Fetch `refspec` from the first working URL in `urls`. - /// When no URL works, the last error is returned. - /// - /// Never touches the checked-out refs or the worktree. + // Never touches the checked-out refs or the worktree. The last error is + // returned when no URL works. pub fn fetch_refs>(&self, urls: &[U], refspec: &str) -> Result<()> { let refspec = gix::refspec::parse( gix::bstr::BStr::new(refspec), @@ -247,7 +225,6 @@ impl Repo { } } - /// Apply `edit` to the repository-local configuration and persist it. pub(crate) fn edit_local_config( &self, edit: impl FnOnce(&mut gix::config::File) -> Result<()>, @@ -265,7 +242,6 @@ impl Repo { match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local) { Ok(config) => config, - // A repository without a config file yet starts from scratch. Err(gix::config::file::init::from_paths::Error::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => { @@ -289,9 +265,6 @@ impl Repo { self.inner.workdir().unwrap_or_else(|| Path::new(".")) } - /// Run `git -C dir args`, disabling the terminal prompt and capturing stderr. - /// - /// `what` names the command in the spawn error. pub(crate) fn run_git(dir: &Path, args: &[&str], what: &str) -> Result { Self::git_command(dir) .args(args) @@ -300,7 +273,6 @@ impl Repo { .with_context(|| format!("failed to spawn `{what}`")) } - /// A `git -C dir` command with the terminal prompt disabled. pub(crate) fn git_command(dir: &Path) -> Command { let mut command = Command::new("git"); command.arg("-C").arg(dir).env("GIT_TERMINAL_PROMPT", "0"); diff --git a/crates/signed_git/src/repo.rs b/crates/signed_git/src/repo.rs index 29cbedc..fe6184f 100644 --- a/crates/signed_git/src/repo.rs +++ b/crates/signed_git/src/repo.rs @@ -2,46 +2,34 @@ use std::path::Path; use anyhow::{Context, Result}; -/// In-memory object cache for history walks, see [`Repo::open_cached`]. -/// -/// Without one, a walk re-decodes the same commit objects from the object database. -/// Sized generously: a walk can cover a large portion of the repository's history. +// History walks re-decode the same commit objects without one; sized +// generously, a walk can cover a large portion of the history. const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024; -/// An opened git repository. -/// -/// One `open` per operation instead of every helper re-opening by path. +// One `open` per operation instead of every helper re-opening by path. pub struct Repo { pub(crate) inner: gix::Repository, } impl Repo { - /// Open the repository at `workdir`. pub fn open(workdir: &Path) -> Result { Ok(Self { inner: gix::open(workdir)?, }) } - /// Open the repository at `workdir`, `None` when the path is not one. pub fn try_open(workdir: &Path) -> Option { Self::open(workdir).ok() } - /// Open the repository at `workdir` with an in-memory object cache. - /// - /// Only history walks benefit from it, they re-decode the same commit - /// objects repeatedly. Single-object reads open the repository plain. + // Only history walks benefit from the object cache, they re-decode the + // same commit objects repeatedly; single-object reads open plain. pub fn open_cached(workdir: &Path) -> Result { let mut repo = gix::open(workdir)?; repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES); Ok(Self { inner: repo }) } - /// Create a repository at `path` with an initial `main` branch. - /// Write a `README.md` from `name` and `description`, then create the initial commit. - /// - /// Returns the initial commit id. pub fn init(path: &Path, name: &str, description: &str) -> Result { use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; @@ -53,8 +41,8 @@ impl Repo { let (signature, mut time_buf) = Self::repository_signature(); let signature = signature.to_ref(&mut time_buf); - // The initial branch is `main`, regardless of `init.defaultBranch` in - // the user's git configuration: point the unborn HEAD there. + // The initial branch is `main` regardless of `init.defaultBranch`: + // point the unborn HEAD there. let head = gix::refs::FullName::try_from("HEAD") .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; @@ -107,17 +95,15 @@ impl Repo { Vec::::new(), )?; - // Populate the index so the fresh repository is clean, - // as `git add` and `git commit` would leave it. + // Populate the index so the fresh repository is clean, as `git add` + // and `git commit` would leave it. let mut index = repo.index_from_tree(&tree)?; index.write(gix::index::write::Options::default())?; Ok(commit.to_string()) } - /// Clone into `path` from the first working URL in `clone_urls`. - /// - /// Unlike [`crate::GitCache::ensure_clone`], the clone is not kept in any cache. + // Not kept in any cache, unlike `GitCache::ensure_clone`. pub fn clone>(clone_urls: &[U], path: &Path) -> Result { if path.exists() { anyhow::bail!("destination {} already exists", path.display()); @@ -128,7 +114,7 @@ impl Repo { for url in clone_urls { match Self::clone_from(url.as_ref(), path) { Ok(repo) => { - // The initial clone uses the default refspecs. Also fetch the `refs/nostr/*` PR refs. + // The default-refspec clone misses the `refs/nostr/*` PR refs. repo.fetch().ok(); return Ok(repo); } @@ -159,39 +145,28 @@ impl Repo { &self.inner } - /// The worktree directory, `None` for a bare repository. pub fn workdir(&self) -> Option<&Path> { self.inner.workdir() } - /// The commit id HEAD points to. - /// - /// `None` when the repository has no commits yet, an unborn HEAD. + // `None` for an unborn HEAD. pub fn head(&self) -> Option { self.inner.head_id().ok().map(|id| id.to_string()) } - /// The merge base of two revisions, - /// revisions may be branch names, remote-tracking refs or commit ids. - /// - /// `Ok(None)` when the revisions share no common ancestor. - /// - /// Unresolvable revisions are errors. + // `Ok(None)` when the revisions share no common ancestor — a valid + // outcome for a proposal; unresolvable revisions are errors. pub fn merge_base(&self, a: &str, b: &str) -> Result> { let a = self.inner.rev_parse_single(a.as_bytes())?; let b = self.inner.rev_parse_single(b.as_bytes())?; match self.inner.merge_base(a, b) { Ok(id) => Ok(Some(id.to_string())), - // No common ancestor, a valid outcome for a proposal. Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None), Err(e) => Err(e.into()), } } - /// The commits in `base..HEAD`, oldest first. - /// This is the order `git am` creates them. - /// - /// `HEAD` alone when `base` is `None`. + // Oldest first: the order `git am` creates them. pub fn commits_since(&self, base: Option<&str>) -> Result> { let head = match self.inner.head_id() { Ok(head) => head, @@ -218,16 +193,12 @@ impl Repo { commits.push(info?.id().to_string()); } - // Oldest first, like `git rev-list --reverse`, the order `git am` creates them. commits.reverse(); Ok(commits) } - /// The earliest unique commit of the repository. - /// Used as the NIP-34 announcement's `euc` marker. - /// - /// `None` for a repository without commits. + // The NIP-34 announcement's `euc` marker; `None` without commits. pub fn root_commit(&self) -> Result> { let Ok(head) = self.inner.head_id() else { return Ok(None); @@ -250,10 +221,6 @@ impl Repo { Ok(None) } - /// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`. - /// `prefix` is a ref namespace like `refs/fork//`. - /// - /// Returns an empty list when nothing matches. pub fn refs_with_prefix(&self, prefix: &str) -> Result> { let pattern = prefix.trim_end_matches('/'); let mut names = Vec::new(); @@ -262,7 +229,7 @@ impl Repo { let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; let name = String::from_utf8_lossy(reference.name().as_bstr()).into_owned(); - // Match the pattern itself and everything beneath it, like `git for-each-ref`. + // Match the pattern itself and everything beneath it. let under_pattern = name .strip_prefix(pattern) .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')); @@ -277,8 +244,6 @@ impl Repo { Ok(names) } - /// Delete every ref under `prefix`. - /// `prefix` is a ref namespace like `refs/fork//`. pub fn delete_refs_with_prefix(&self, prefix: &str) -> Result<()> { use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog}; @@ -308,8 +273,6 @@ impl Repo { Ok(()) } - /// Short name of the branch HEAD points to, - /// `None` when detached or unreadable, like `git branch --show-current`. pub fn current_branch(&self) -> Option { let head = self.inner.head().ok()?; let name = head.referent_name()?; @@ -320,7 +283,6 @@ impl Repo { self.inner.find_reference(name).is_ok() } - /// Short names of local branches, `refs/heads/*`, sorted alphabetically. pub fn branches(&self) -> Result> { let mut names = Vec::new(); for reference in self.inner.references()?.local_branches()? { @@ -331,7 +293,6 @@ impl Repo { Ok(names) } - /// Short names of tags, `refs/tags/*`, sorted alphabetically. pub fn tags(&self) -> Result> { let mut names = Vec::new(); for reference in self.inner.references()?.tags()? { @@ -342,9 +303,6 @@ impl Repo { Ok(names) } - /// Branch, tag and HEAD refs of the repository. - /// - /// Ready for a NIP-34 kind-30618 repository state announcement. pub fn ref_state(&self) -> Result { let mut refs = Vec::new(); @@ -376,9 +334,8 @@ impl Repo { Ok(RepoRefState { refs, head }) } - /// Fast-forward local branches that trail their remote-tracking counterpart. - /// - /// Returns whether any branch moved. + // Fast-forward only: local-only commits or diverged history must never + // be rewritten by a refresh. Returns whether any branch moved. pub fn fast_forward_branches(&self) -> Result { use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; @@ -400,7 +357,6 @@ impl Repo { }; let remote = format!("refs/remotes/origin/{branch}"); - // No remote-tracking counterpart means the remote lacks this branch. let Ok(mut remote_reference) = self.inner.find_reference(&remote) else { continue; }; @@ -424,8 +380,7 @@ impl Repo { continue; } - // Only fast-forward. - // Local-only commits or diverged history must never be rewritten by a refresh. + // Only fast-forward: the base must be the local tip. let Ok(base) = self.inner.merge_base(local_oid, remote_oid) else { continue; }; @@ -454,7 +409,6 @@ impl Repo { }; if current.as_deref() == Some(branch) { - // Merge so the checked-out worktree follows the branch. // Only proceed on a clean worktree, like `git merge --ff-only`. if self.is_dirty() { continue; @@ -483,10 +437,8 @@ impl Repo { Ok(moved) } - /// The identity written to reflogs and commits created by this crate itself. - /// - /// Like `git -c user.name=… -c user.email=…` per invocation: the repository works - /// without a global git identity, and `gix` runs no hooks and never signs. + // Like `git -c user.name=… -c user.email=…` per invocation: the repository + // works without a global git identity, and `gix` runs no hooks and never signs. pub(crate) fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) { let seconds = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -502,10 +454,8 @@ impl Repo { (signature, gix::date::parse::TimeBuf::default()) } - /// Rewrite a grasp server URL to the https URL the git transport actually uses. - /// - /// GRASP servers announce `grasp:////` clone URLs. - /// The transport is git smart HTTP, so the scheme is rewritten for gix. + // GRASP servers announce `grasp://` clone URLs but the transport is git + // smart HTTP, so the scheme is rewritten for gix. pub(crate) fn transport_url(url: &str) -> String { url.strip_prefix("grasp://") .map(|rest| format!("https://{rest}")) @@ -513,11 +463,8 @@ impl Repo { } } -/// Branch, tag and HEAD refs of a repository. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepoRefState { - /// `(full refname, commit id)` pairs for heads and tags, sorted. pub refs: Vec<(String, String)>, - /// Short branch name HEAD points to, or `None` when detached. pub head: Option, } diff --git a/crates/signed_git/src/scan.rs b/crates/signed_git/src/scan.rs index 8bb3830..7ef09a2 100644 --- a/crates/signed_git/src/scan.rs +++ b/crates/signed_git/src/scan.rs @@ -5,18 +5,15 @@ use ignore::WalkBuilder; use crate::nip34::Nip34Binding; use crate::repo::Repo; -/// Caps nesting so pathological trees can't stall the scan. +// Caps nesting so pathological trees can't stall the scan. const SCAN_MAX_DEPTH: usize = 12; -/// A git repository discovered under a scan root. #[derive(Debug, Clone)] pub struct LocalRepo { pub path: PathBuf, - /// `None` for a plain repository. pub nip34: Option, } -/// Walk `root` recursively and collect the git repositories below it. pub fn find_git_repos(root: &Path) -> Vec { if !root.is_dir() { return Vec::new(); diff --git a/crates/signed_git/src/tests.rs b/crates/signed_git/src/tests.rs index e470ff6..fcb5f18 100644 --- a/crates/signed_git/src/tests.rs +++ b/crates/signed_git/src/tests.rs @@ -7,7 +7,6 @@ use super::*; fn blocks_parent_components() { assert_eq!(GitCache::sanitize_path_component(".."), "_"); assert_eq!(GitCache::sanitize_path_component("."), "_"); - // Separators are neutralized before the check, so these stay safe. assert_eq!(GitCache::sanitize_path_component("../.."), ".._.."); assert_eq!(GitCache::sanitize_path_component("a/../b"), "a_.._b"); } @@ -21,7 +20,6 @@ fn root_commit_reports_the_first_ancestor() { let root = repo.root_commit().expect("root").expect("commit"); assert_eq!(root.len(), 40); - // The root commit does not change when history grows. std::fs::write(dir.join("b.txt"), b"two").expect("write"); commit_all(&repo, "second"); assert_eq!( @@ -32,8 +30,6 @@ fn root_commit_reports_the_first_ancestor() { #[test] fn push_all_mirrors_branches_and_tags() { - // A bare server repository reachable via a `file://` URL. - // Mirrors a grasp server's `{base}/{owner}/{repo-id}.git` layout. let server = tempfile::tempdir().unwrap(); let server_repo = bare_server(server.path(), "npub1test", "my-repo"); @@ -82,12 +78,9 @@ fn remote_has_refs_reports_whether_pushed_refs_landed() { assert!(repo.remote_has_refs(&url, &expected).expect("probe")); - // A stale expectation - the exact race a retry resolves - is false. let stale = vec![("refs/heads/main".to_owned(), "0".repeat(40))]; assert!(!repo.remote_has_refs(&url, &stale).expect("probe")); - // Extra remote refs (e.g. a tag pushed later) do not invalidate the - // refs this push wanted to land. git_run(dir, &["tag", "v1.0"]); repo.push_all( &format!("file://{}", server.path().display()), @@ -137,7 +130,6 @@ fn repo_ref_state_lists_branches_tags_and_head() { assert_eq!(state.refs.len(), 3); } -/// Build a throwaway non-bare repository from `(rel, bytes)` file pairs. fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repo) { let dir = tempfile::tempdir().expect("tempdir"); gix::init(&dir).expect("init"); @@ -152,8 +144,6 @@ fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repo) { (dir, repo) } -/// Stage everything and create a commit with the git CLI. -/// Like [`Repo::apply_patch`], the crate already shells out to the CLI. fn commit_all(repo: &Repo, message: &str) { git_run(repo.workdir().expect("workdir"), &["add", "-A"]); git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]); @@ -166,8 +156,6 @@ fn merge_base_finds_the_fork_point_and_reports_unrelated_history() { let initial = Repo::init(&path, "My Repo", "desc").expect("init"); let repo = Repo::open(&path).expect("open"); - // A feature branch and a mainline commit diverge from the initial commit. - // The initial commit is their merge base. git_run(&path, &["checkout", "-b", "feature"]); std::fs::write(path.join("feature.txt"), "feature\n").expect("write"); commit_all(&repo, "feature commit"); @@ -182,13 +170,11 @@ fn merge_base_finds_the_fork_point_and_reports_unrelated_history() { Some(initial.as_str()) ); - // An orphan branch shares no history with main, so `Ok(None)`. git_run(&path, &["checkout", "--orphan", "orphan"]); std::fs::write(path.join("orphan.txt"), "orphan\n").expect("write"); commit_all(&repo, "orphan commit"); assert_eq!(repo.merge_base("orphan", "main").expect("ok"), None); - // An unresolvable revision is an error, not a missing ancestor. assert!(repo.merge_base("orphan", "no-such-ref").is_err()); } @@ -213,7 +199,6 @@ fn split_patch_series_splits_real_multi_commit_mboxes() { assert_eq!(parts.len(), 2); assert!(parts[0].contains("Subject: [PATCH 1/2] first commit")); assert!(parts[1].contains("Subject: [PATCH 2/2] second commit")); - // Each part starts its own mbox message with its own commit id. let first = parts[0].lines().next().expect("first header"); let second = parts[1].lines().next().expect("second header"); assert!(first.starts_with("From ") && first.len() >= 45); @@ -228,7 +213,6 @@ fn head_commit_and_commits_since_track_applied_commits() { let repo = Repo::open(&path).expect("open"); assert_eq!(repo.head().as_deref(), Some(initial.as_str())); - // No base given, `HEAD` alone. assert_eq!( repo.commits_since(None).expect("commits"), vec![initial.clone()] @@ -242,7 +226,6 @@ fn head_commit_and_commits_since_track_applied_commits() { commit_all(&repo, "second commit"); let second = repo.head().expect("on a branch"); - // Oldest first, like the order `git am` creates them. assert_eq!( repo.commits_since(Some(&initial)).expect("commits"), vec![first.clone(), second.clone()] @@ -255,10 +238,6 @@ fn head_commit_and_commits_since_track_applied_commits() { #[test] fn working_copy_cloned_from_the_mirror_matches_head_and_origin() { - // The mirror is a freshly initialized repository, standing in for - // the grasp server. Its `origin` points at the (fake) grasp server. - // `GitCache::ensure_clone` lazily clones from a URL shaped like this - // the first time a repository is opened. let dir = tempfile::tempdir().expect("tempdir"); let mirror = dir.path().join("mirror"); let commit = Repo::init(&mirror, "My Repo", "Does things.").expect("init"); @@ -267,9 +246,6 @@ fn working_copy_cloned_from_the_mirror_matches_head_and_origin() { .ensure_origin("https://gitnostr.com/npub1test/my-repo.git") .expect("origin"); - // The working copy is cloned from the mirror. - // It then shares the announced history exactly. - // `origin` is re-pointed at the grasp server instead of the mirror path. let destination = dir.path().join("folder").join("My_Repo"); std::fs::create_dir_all(destination.parent().unwrap()).expect("parent"); Repo::clone(&[format!("file://{}", mirror.display())], &destination).expect("clone"); @@ -289,7 +265,6 @@ fn working_copy_cloned_from_the_mirror_matches_head_and_origin() { #[test] fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { - // A bare server, like a grasp server's `{base}/{owner}/{repo}.git` layout. let dir = tempfile::tempdir().expect("tempdir"); bare_server(dir.path(), "npub1test", "repo"); @@ -301,7 +276,6 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { .push_all(&base_url, "npub1test", "repo") .expect("push"); - // A mirror clone, like the app's GitCache clones. let mirror = dir.path().join("mirror"); git_run( dir.path(), @@ -315,8 +289,6 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { let initial = git_in(&mirror, &["rev-parse", "HEAD"]).expect("initial"); let repo = Repo::open(&mirror).expect("open"); - // The owner pushes a new commit. - // The mirror fetches it, but its local `main` and worktree stay behind. std::fs::write(work.join("new.txt"), b"new\n").expect("write"); commit_all(&work_repo, "new commit"); work_repo @@ -330,8 +302,6 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { ); assert_ne!(remote, initial); - // Fast-forwarding catches the branch and its worktree up. - // The second call has nothing left to move. assert!(repo.fast_forward_branches().expect("ff")); assert_eq!( git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"), @@ -340,7 +310,6 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { assert!(mirror.join("new.txt").is_file()); assert!(!repo.fast_forward_branches().expect("idle")); - // A branch with local commits of its own is never touched. git_run(&mirror, &["checkout", "-b", "wip"]); std::fs::write(mirror.join("wip.txt"), b"wip\n").expect("write"); commit_all(&repo, "local wip"); @@ -356,8 +325,6 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { fn fetch_repo_refs_imports_heads_under_a_prefix() { let dir = tempfile::tempdir().expect("tempdir"); - // A bare base server holding the initial commit. - // Like a grasp server's `{base}/{owner}/{repo-id}.git` layout. let base_server = bare_server(dir.path(), "npub1base", "base"); let (upstream_dir, upstream_repo) = fixture(&[("a.txt", b"one")]); @@ -380,8 +347,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { ); let mirror_repo = Repo::open(&mirror).expect("open"); - // The fork server has the same initial commit. - // It also carries a feature commit on its own `feature` branch. let fork_work = dir.path().join("fork-work"); git_run( dir.path(), @@ -402,8 +367,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { ) .expect("push"); - // Import the fork's heads into the mirror under a private prefix. - // The first dead URL is skipped, the second works. let dead = format!("file://{}/missing.git", dir.path().display()); mirror_repo .fetch_refs( @@ -418,7 +381,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { .expect("refs"), vec!["refs/fork/npub1fork/fork/feature"] ); - // Nothing leaked into the normal ref namespaces. assert_eq!( mirror_repo .refs_with_prefix("refs/heads/fork") @@ -426,8 +388,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { Vec::::new() ); - // The mirror can now range across both histories. - // The fork point is the shared initial commit, the proposal covers the fork commit. assert_eq!( mirror_repo .merge_base( @@ -455,7 +415,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { ); } -/// Run a git command in `dir` with a fixed test identity, asserting success. fn git_run(dir: &Path, args: &[&str]) -> std::process::Output { let output = Command::new("git") .current_dir(dir) @@ -471,7 +430,6 @@ fn git_run(dir: &Path, args: &[&str]) -> std::process::Output { output } -/// Create a bare `{base}/{owner}/{name}.git` repository, like a grasp server. fn bare_server(base: &Path, owner: &str, name: &str) -> PathBuf { let repo = base.join(owner).join(format!("{name}.git")); std::fs::create_dir_all(repo.parent().expect("parent")).expect("mkdir"); @@ -531,10 +489,6 @@ diff --git a/b.txt b/b.txt #[test] fn parses_real_format_patch_output() { - // Build a commit touching a mix of file kinds. - // Feed genuine `git format-patch` output through the parser. - // It covers quoted and octal-escaped paths. - // There are also a rename-free modification, an addition and a binary deletion. let (dir, repo) = fixture(&[ ("src/main.rs", b"fn main() {\n println!(\"one\");\n}\n"), ("my file.txt", b"hello\n"), @@ -570,12 +524,10 @@ fn parses_real_format_patch_output() { .unwrap_or_else(|| panic!("missing file {path:?}")) }; - // Space in the name makes git quote the path in the header. let file = by_path("my file.txt"); assert_eq!(file.status, DiffStatus::Modified); assert_eq!(file.insertions, 1); - // UTF-8 names are emitted as octal escapes. let file = by_path("\u{8bf4}\u{660e}.md"); assert_eq!(file.status, DiffStatus::Modified); assert_eq!(file.insertions, 1); @@ -590,8 +542,6 @@ fn parses_real_format_patch_output() { assert_eq!(file.status, DiffStatus::Added); assert_eq!(file.insertions, 1); - // A binary deletion emits no `---` or `+++` lines. - // Only the mode line and the `Binary files` marker remain. let file = by_path("img.png"); assert_eq!(file.status, DiffStatus::Deleted); assert!(file.binary); diff --git a/crates/signed_git/src/worktree.rs b/crates/signed_git/src/worktree.rs index bb566c4..3e3a350 100644 --- a/crates/signed_git/src/worktree.rs +++ b/crates/signed_git/src/worktree.rs @@ -8,19 +8,17 @@ use crate::history::FileCommit; use crate::repo::Repo; impl Repo { - /// Whether the worktree has uncommitted changes. - /// - /// Best-effort: any read failure is reported as clean. + // Best-effort: any read failure is reported as clean. pub fn is_dirty(&self) -> bool { - // Changes to tracked files, staged or not; untracked files are excluded. + // Tracked files, staged or not; untracked files are handled below. match self.inner.is_dirty() { Ok(true) => return true, Ok(false) => {} Err(_) => return false, } - // Untracked files surface as `DirectoryContents` items of the index-vs-worktree walk, - // tracked files only appear there when modified. + // Untracked files surface as `DirectoryContents` items of the + // index-vs-worktree walk; tracked files only appear there when modified. let Ok(platform) = self.inner.status(Discard) else { return false; }; @@ -41,9 +39,7 @@ impl Repo { false } - /// Commits in `base..branch`. - /// - /// Best-effort: 0 when the range cannot be computed. + // Best-effort: 0 when the range cannot be computed. pub fn commits_ahead(&self, base: &str, branch: &str) -> u32 { let (Some(base), Some(branch)) = (self.resolve_commit(base), self.resolve_commit(branch)) else { @@ -57,15 +53,13 @@ impl Repo { walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32 } - /// Resolve `rev` to a commit id, accepting full refs or the bare branch names - /// callers pass. `gix`'s revision parser already applies git's ref DWIM. + // Accepts full refs or the bare branch names callers pass; `gix`'s + // revision parser already applies git's ref DWIM. fn resolve_commit<'a>(&'a self, rev: &str) -> Option> { self.inner.rev_parse_single(rev.as_bytes()).ok() } - /// Relative paths of all entries in the worktree, files and directories. - /// - /// The `.git` directory is skipped. + // The `.git` directory is skipped. pub fn entries(&self) -> Result> { let workdir = self.inner.workdir().context("repository has no worktree")?; @@ -80,9 +74,7 @@ impl Repo { Ok(entries.into_iter().map(|(path, _)| path).collect()) } - /// Read a file from the worktree. - /// - /// Returns `Ok(None)` if the path is missing or not a regular file. + // `Ok(None)` when the path is missing or not a regular file. pub fn read(&self, rel: &Path) -> Result>> { let workdir = self.inner.workdir().context("repository has no worktree")?; let path = workdir.join(rel); @@ -95,9 +87,7 @@ impl Repo { } } - /// Find the README file in the repository root. - /// - /// Falls back to any other file whose name starts with `readme`. + // Falls back to any other file whose name starts with `readme`. pub fn find_readme(&self) -> Result> { let Some(workdir) = self.inner.workdir() else { return Ok(None); @@ -133,7 +123,6 @@ impl Repo { .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) } - /// Everything the browser needs to refresh after a branch or tag switch. pub fn snapshot(&self) -> Result { let readme_path = self.find_readme()?; let readme = match &readme_path { @@ -151,7 +140,7 @@ impl Repo { }) } - /// Check out the local branch `name`, HEAD stays attached to it. + // HEAD stays attached to the branch. pub fn checkout_branch(&self, name: &str) -> Result<()> { let full = format!("refs/heads/{name}"); @@ -175,7 +164,7 @@ impl Repo { Ok(()) } - /// Check out the tag `name`, HEAD becomes detached at the tagged commit. + // HEAD becomes detached at the tagged commit. pub fn checkout_tag(&self, name: &str) -> Result<()> { let full = format!("refs/tags/{name}"); @@ -207,8 +196,8 @@ impl Repo { let mut index = self.inner.index_from_tree(tree)?; - // Files the previous index tracked but `tree` no longer contains are removed, - // like git deleting files that vanish between branches. + // Files the previous index tracked but `tree` no longer contains are + // removed, like git deleting files that vanish between branches. if let Ok(previous) = self.inner.index_or_empty() { let keep: HashSet = index .entries() @@ -262,7 +251,6 @@ impl Repo { Ok(()) } - /// Point `HEAD` at `target` and record the switch in the reflog. fn move_head( &self, signature: gix::actor::SignatureRef<'_>, @@ -294,7 +282,6 @@ impl Repo { Ok(()) } - /// Relative paths of all entries below `dir`, relative to `root`. fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { for entry in std::fs::read_dir(dir)? { let entry = entry?; @@ -315,20 +302,13 @@ impl Repo { } } -/// Everything the browser needs to refresh after a branch or tag switch. pub struct WorktreeSnapshot { - /// Relative paths of all worktree entries, directories first. pub entries: Vec, - /// README path relative to the worktree, if any. pub readme_path: Option, - /// Contents of the README, if any. pub readme: Option>, - /// Branch HEAD points to, `None` when detached, for example on a tag. + // `None` when detached, for example on a tag. pub current_branch: Option, - /// Commit HEAD points to, if any, see [`Repo::head_commit`]. pub head_commit: Option, - /// Short names of local branches, sorted alphabetically. pub branches: Vec, - /// Short names of tags, sorted alphabetically. pub tags: Vec, } diff --git a/crates/signed_nostr/src/signer.rs b/crates/signed_nostr/src/signer.rs index 8ebf43e..601a286 100644 --- a/crates/signed_nostr/src/signer.rs +++ b/crates/signed_nostr/src/signer.rs @@ -8,7 +8,6 @@ use nostr_connect::client::AuthUrlHandler; use nostr_sdk::error::Error as SignerError; use nostr_sdk::prelude::*; -/// A type-erased signer whose inner signer can be swapped in-place. #[derive(Clone, Debug)] pub struct UniversalSigner { inner: Arc>>, @@ -27,7 +26,6 @@ impl UniversalSigner { } } - /// Swap the inner signer in-place. All clones see the new signer. pub fn swap_inner(&self, new_signer: T) where T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static, @@ -160,7 +158,6 @@ impl AsyncNip44 for UniversalSigner { } } -/// Opens the NIP-46 auth URL in the default browser. #[derive(Debug, Clone)] pub struct SignedAuthUrlHandler; diff --git a/crates/signed_nostr/src/update.rs b/crates/signed_nostr/src/update.rs index a7d1998..277f6c3 100644 --- a/crates/signed_nostr/src/update.rs +++ b/crates/signed_nostr/src/update.rs @@ -1,10 +1,8 @@ use nostr_sdk::prelude::*; -/// A lightweight change notification for the UI. #[derive(Debug, Clone)] pub struct Update { pub kind: Kind, - /// First `a` tag value of the event, if any, for example the repository coordinate. pub coordinate: Option, pub author: PublicKey, } diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 140203b..938a268 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -21,7 +21,6 @@ use crate::push::{GraspPush, PushOutcome, grasp_base_url, grasp_clone_url}; use crate::repos::RepoListStore; pub const USER_KEYRING: &str = "Signed Safe Storage"; -/// Timeout for NIP-46 signer responses. pub const NOSTR_CONNECT_TIMEOUT: u64 = 60; const PUMP_DEBOUNCE: Duration = Duration::from_millis(200); @@ -29,13 +28,9 @@ const PUMP_DEBOUNCE: Duration = Duration::from_millis(200); #[derive(Debug, Clone)] pub enum BackendEvent { SignerRequired, - /// The stored identity is NIP-49 encrypted key. PassphraseRequired, - /// The signer changed on login, logout or account switch. SignerChanged, - /// Kind-0 metadata arrived for these authors; re-read them from the store. ProfileUpdates(Vec), - /// Repository events arrived: announcements, states, activity and deletions. RepoUpdates(Vec), Synced, Error(String), @@ -55,7 +50,6 @@ pub struct Backend { signer: UniversalSigner, current_user: Option, inbox: Entity, - /// True when the stored credential is NIP-49 encrypted. passphrase_required: bool, pushing_repos: Entity>, } @@ -158,10 +152,6 @@ impl Backend { } } - /// Restore the saved session from the Keyring. - /// - /// - Emits [`BackendEvent::SignerRequired`] when no credential is stored. - /// - Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity. fn restore_session(&mut self, cx: &mut Context) { if cfg!(target_arch = "wasm32") { cx.emit(BackendEvent::SignerRequired); @@ -226,7 +216,6 @@ impl Backend { task.detach(); } - /// Decrypt the NIP-49 keyring credential with the given passphrase. pub fn restore_with_passphrase( &mut self, password: &str, @@ -295,7 +284,6 @@ impl Backend { write.await?; this.update(cx, |this, cx| { - // Become the new identity so later publishes are signed with the new keys. this.signer.swap_inner(keys); this.current_user = Some(public_key); this.bootstrap_user(public_key, cx); @@ -352,9 +340,6 @@ impl Backend { }) } - /// Initialize a local clone with a `main` branch and a `README.md`. - /// - /// The task yields the announcement and the path of the working copy. pub fn create_repository( &mut self, name: &str, @@ -400,7 +385,6 @@ impl Backend { let servers = grasp_servers.clone(); let client = self.client.clone(); - // Initialize directly at the user's chosen destination. let destination = { let dir_name = GitCache::sanitize_path_component(&name); let dir_name = if dir_name.is_empty() { @@ -473,7 +457,6 @@ impl Backend { }) } - /// Publish an existing local repository to NIP-34. pub fn publish_local_repo( &mut self, path: PathBuf, @@ -533,7 +516,6 @@ impl Backend { let announcement = repository_announcement(&repo_id, &name, &description, &owner, &servers, euc); - // The state event is the push authorization. It must be accepted before the push below. let event = announce_repository_and_push( &this, &client, @@ -550,7 +532,6 @@ impl Backend { ) .await?; - // Point `origin` at the first grasp server so later pushes have a target. if let Some(base) = servers.first().and_then(grasp_base_url) { let url = format!("{base}/{owner}/{repo_id}.git"); let path = path.clone(); @@ -562,8 +543,8 @@ impl Backend { .await; } - // Record the ngit-compatible `nostr.repo` marker, - // so the next scan detects the repository instead of offering to publish it again. + // The ngit-compatible `nostr.repo` marker makes the next scan + // detect the repository instead of offering to publish it again. let coordinate = RepoAddr::new(event.pubkey, repo_id.clone()); match Nip19Coordinate::new(coordinate.into(), servers.clone()).to_bech32() { Ok(naddr) => { @@ -587,10 +568,8 @@ impl Backend { }) } - /// Re-push the repository's current refs to the grasp servers in its `relays` tag. - /// - /// Errors when no grasp server accepted the push, the outcome reports - /// which servers did when only some accepted it. + // Errors when no grasp server accepted the push; the outcome reports + // which servers did when only some accepted it. pub fn push_repository( &mut self, announcement: Announcement, @@ -600,10 +579,6 @@ impl Backend { self.push_repo_from(announcement, path, None, cx) } - /// Push the refs of a local checkout to the grasp servers in its `relays` tag. - /// The checkout is the working copy of the user's own repository. - /// - /// Publish a fresh state event, then push every branch and tag of the checkout. pub fn push_checkout( &mut self, announcement: Announcement, @@ -614,7 +589,6 @@ impl Backend { self.push_repo_from(announcement, checkout, announced_head, cx) } - /// Shared body of the mirror-based and checkout-based pushes. fn push_repo_from( &mut self, announcement: Announcement, @@ -640,8 +614,8 @@ impl Backend { let relays = announcement.relays.clone(); cx.spawn(async move |this, cx| { - // Held for the whole task. Runs on completion, on error and on - // cancellation alike, since dropping the task drops this guard. + // Runs on completion, on error and on cancellation alike; the + // guard would be dropped with the task if not held. let _guard = cx.on_drop(&this, { let addr = addr.clone(); move |backend, cx| { @@ -660,10 +634,8 @@ impl Backend { work.await? }; - // The state event announces the pushed refs. - // Keep the announced default branch in `HEAD` when it is among the pushed refs. - // - // Otherwise `HEAD` stays the checkout's current branch. + // Keep the announced default branch in `HEAD` when it is among + // the pushed refs; otherwise `HEAD` stays the checkout's branch. let heads: Vec<&str> = state .refs .iter() @@ -676,9 +648,8 @@ impl Backend { state.head = Some(head); } - // Grasp servers authorize a push by the state event they hold in purgatory. - // Stage the state event on each server's own relay, then push the git data, - // retrying transient purgatory denials. + // Grasp servers authorize a push by the state event they hold in + // purgatory: stage it on each server's relay before the git push. let refs = state.refs.clone(); let head = state.head.clone(); @@ -725,8 +696,6 @@ impl Backend { } // Fan the state out to the relays once a git server holds the objects. - // Staging already stored the event locally, publishing notifies - // the repository views and other relays and clients. if let Some(state_event) = &outcome.state_event && let Err(e) = client.send_event(state_event).broadcast().await { @@ -737,9 +706,6 @@ impl Backend { }) } - /// Delete the repository from nostr. - /// - /// Only the repository owner may delete it. pub fn delete_repository( &mut self, addr: RepoAddr, @@ -888,7 +854,6 @@ impl Backend { .detach(); } - /// Connect to a repository's announced relays, its NIP-34 `relays` tag. pub fn connect_repo_relays( &mut self, relays: Vec, @@ -926,7 +891,6 @@ impl Backend { .detach(); } - /// Sync filters through the SDK's NIP-65 gossip targeting. pub fn sync_auto(&mut self, filters: Vec, cx: &mut Context) { let client = self.client.clone(); @@ -958,7 +922,6 @@ impl Backend { .detach(); } - /// Sync several bootstrap filters in order, within a single task. pub fn sync_bootstraps(&mut self, filters: Vec, cx: &mut Context) { let client = self.client.clone(); @@ -997,10 +960,8 @@ impl Backend { .detach(); } - /// Publish a NIP-09 deletion for each of `events`, best-effort. - /// - /// Each target gets its own deletion event: a relay rejecting or - /// dropping one does not affect the others. + // Each target gets its own deletion event: a relay rejecting or dropping + // one does not affect the others. fn retract_events(&mut self, events: &[Event], cx: &mut Context) { let pusher = GraspPush::new(self.client.clone(), self.signer.clone()); @@ -1017,7 +978,6 @@ impl Backend { } } -/// The announcement of one repository, as published to the relays. fn repository_announcement( repo_id: &str, name: &str, @@ -1041,7 +1001,10 @@ fn repository_announcement( } } -/// Announce a repository, stage the announcement on each grasp server's relay +// Announce the repository, then stage the state event and push: the state +// event is the push authorization, so it must be accepted before the push. +// Connect to all servers first — the nostr client queues events until each +// relay is connected. #[allow(clippy::too_many_arguments)] async fn announce_repository_and_push( backend: &WeakEntity, @@ -1057,12 +1020,10 @@ async fn announce_repository_and_push( cx: &mut AsyncApp, push: impl Fn(&Path, &str, &str, &str) -> Result<(), Error> + Send + 'static, ) -> Result { - // The nostr client queues events until each relay is connected. for url in servers { client.add_relay(url).and_connect().await.ok(); } - // The state event is the push authorization. It must be accepted before the push below. let event = { let builder = announcement.into_event_builder(); let event = builder.finalize_async(signer).await?; @@ -1102,8 +1063,8 @@ async fn announce_repository_and_push( }; if outcome.accepted() == 0 { - // The announcement is already published. Retract it so the - // repository is not left announced without content. + // Retract the announcement so the repository is not left announced + // without content. backend .update(cx, |backend, cx| { backend.retract_events(std::slice::from_ref(&event), cx); @@ -1118,8 +1079,6 @@ async fn announce_repository_and_push( } // Fan the state out to the relays once a git server holds the objects. - // Staging already stored the event locally, publishing makes it - // visible to the other relays and clients. if let Some(state_event) = &outcome.state_event && let Err(e) = client.send_event(state_event).broadcast().await { @@ -1129,14 +1088,12 @@ async fn announce_repository_and_push( Ok(event) } -/// A relay event the backend routes to a store group. enum UpdateEvent { Profile(PublicKey), Repo(Update), } impl UpdateEvent { - /// Await the next relay event from the notification stream. async fn next( notifications: &mut (impl futures::Stream + Unpin), seen: &mut HashSet, @@ -1167,7 +1124,6 @@ impl UpdateEvent { } } -/// Split a stored bunker credential into the plain URI and the session key. fn extract_master_key(credential: &str) -> (&str, Keys) { match credential.split_once("master=") { Some((base, nsec)) => { diff --git a/crates/signed_state/src/bootstrap.rs b/crates/signed_state/src/bootstrap.rs index 906ac84..eb6c360 100644 --- a/crates/signed_state/src/bootstrap.rs +++ b/crates/signed_state/src/bootstrap.rs @@ -7,12 +7,9 @@ use nostr_sdk::client::SyncSummary; use nostr_sdk::prelude::*; use signed_core::Filters; -/// Relays connected at startup, before any user-specific relay config is known. pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"]; -/// Relays used to index the user's NIP-65 relay list. pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"]; -/// Add and connect the startup relays. async fn ensure_bootstrap_relays(client: &Client) -> Result<(), Error> { for url in BOOTSTRAP_RELAYS { client.add_relay(url).and_connect().await?; @@ -65,7 +62,6 @@ pub(crate) async fn sync_bootstrap_only( Ok(output.value) } -/// The `g` tag servers of one kind-10317 grasp list event, in tag order. fn grasp_list_servers(event: &Event) -> Vec { event .tags @@ -76,7 +72,6 @@ fn grasp_list_servers(event: &Event) -> Vec { .collect() } -/// Grasp servers of the newest kind-10317 grasp list among `events`. fn latest_grasp_list_servers(events: Vec) -> Vec { events .into_iter() diff --git a/crates/signed_state/src/checkouts.rs b/crates/signed_state/src/checkouts.rs index d102197..1d2b827 100644 --- a/crates/signed_state/src/checkouts.rs +++ b/crates/signed_state/src/checkouts.rs @@ -18,11 +18,8 @@ use crate::repos::RepoListStore; const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); -/// How often the statuses are recomputed against the local refs. const LOCAL_POLL: Duration = Duration::from_secs(2); -/// How often a full pass refreshes the remotes while any repository panel is open. const STATUS_POLL: Duration = Duration::from_secs(15); -/// Remote refresh interval for the `ready to push` badges of the user's own repositories. const PUSH_POLL: Duration = Duration::from_secs(60); const MAX_STATUS_CHECKOUTS: usize = 8; @@ -31,63 +28,37 @@ struct GlobalCheckoutsStore(Entity); impl Global for GlobalCheckoutsStore {} -/// One associated local checkout of a repository. -/// -/// Carries the git facts needed to suggest a pull request. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CheckoutStatus { pub path: PathBuf, - /// The branch checked out. A detached checkout is idle and yields no status. + // A detached checkout is idle and yields no status. pub branch: String, - /// Commit the branch points at, for tip-based PR dedupe. + // For tip-based PR dedupe. pub head: String, - /// What the branch is compared against. - /// - /// It is `refs/remotes/origin/`, else `origin/HEAD` for new branches. + // `refs/remotes/origin/`, else `origin/HEAD` for new branches. pub base: String, - /// Commits in `base..branch`. - /// - /// Zero-ahead checkouts are dropped, so this is always above zero. + // Zero-ahead checkouts are dropped, so always above zero. pub ahead: u32, } -/// A remembered record, with the address already parsed. struct Remembered { path: PathBuf, addr: RepoAddr, last_used: u64, } -/// Global store of local-checkout associations and per-checkout statuses. pub struct CheckoutsStore { - /// Checkout paths per announced repository. by_repo: HashMap>, - /// Ready-to-contribute statuses of the requested repositories. - /// - /// Those are the repository detail panels currently open. statuses: HashMap>, - /// Repositories whose statuses are recomputed on every input change. - /// - /// Those are the repository detail panels currently open. status_requested: HashSet, - /// Repositories whose `ready to push` statuses are recomputed on the same cycle. - /// - /// The sidebar rows of the user's own repositories and their detail panels. push_requested: HashSet, - /// The ready-to-push statuses of the requested own repositories. push_statuses: HashMap>, - /// Last announced head branch per requested repository. - /// - /// A recompute defaults the base the same way. requested_head: HashMap>, refresh: RefreshGate, - /// True while the timer between a scheduled refresh and its run is pending. debounce_pending: bool, local_pending: bool, - /// When the last full pass (with a remote refresh) completed. - /// - /// The local pass runs a full pass again once this is older than the - /// reconciliation cadence, so remote moves still land. + // The local pass runs a full pass again once this is older than the + // reconciliation cadence, so remote moves still land. last_full_sync: Option, _subscriptions: Vec, } @@ -189,17 +160,10 @@ impl CheckoutsStore { }); } - /// The associated checkouts of `addr`, freshest first. - /// - /// Empty when none are known or the resolution has not run yet. pub fn associations_of(&self, addr: &RepoAddr) -> Vec { self.by_repo.get(addr).cloned().unwrap_or_default() } - /// Ask for the `ready to contribute` statuses of `addr` to stay current. - /// Called while the repository's detail panel is open. - /// - /// `announced_head` is the announced HEAD branch, used to default the base. pub fn request_statuses( &mut self, addr: &RepoAddr, @@ -213,26 +177,18 @@ impl CheckoutsStore { self.refresh(cx); } - /// The ready-to-contribute statuses of `addr`. - /// - /// Empty while none are known or nothing is ahead. pub fn ready_statuses_of(&self, addr: &RepoAddr) -> Vec { self.statuses.get(addr).cloned().unwrap_or_default() } - /// Ask for the `ready to push` statuses of `addr` to stay current. pub fn request_push_statuses(&mut self, addr: &RepoAddr, cx: &mut Context) { self.push_requested.insert(addr.clone()); self.refresh(cx); } - /// The checkout at `path` was just pushed to the remote. - /// - /// Its ready-to-push status is obsolete. Drop it from the cached statuses - /// and notify observers right away, so the sidebar badge and the push - /// banner update immediately instead of waiting for the next background - /// pass, which re-scans and re-fetches the remote. The debounced refresh - /// reconciles the remaining checkouts of the repository afterwards. + // Drop the stale ready-to-push status and notify observers right away, so + // the sidebar badge updates immediately instead of waiting for the next + // background pass. The debounced refresh reconciles the remaining checkouts. pub fn checkout_pushed(&mut self, addr: &RepoAddr, path: &Path, cx: &mut Context) { let mut removed = false; @@ -255,9 +211,6 @@ impl CheckoutsStore { self.request_push_statuses(addr, cx); } - /// The ready-to-push statuses of `addr`. - /// - /// Empty while none are known or nothing is unpushed. pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec { self.push_statuses.get(addr).cloned().unwrap_or_default() } @@ -269,7 +222,6 @@ impl CheckoutsStore { .unwrap_or(0) } - /// Re-resolve the associations and the requested statuses. pub fn refresh(&mut self, cx: &mut Context) { if self.debounce_pending || self.refresh.request() != RefreshRequest::Schedule { return; @@ -284,7 +236,6 @@ impl CheckoutsStore { .detach(); } - /// One full resolve and apply cycle, the debounced entry point. fn run_refresh(&mut self, cx: &mut Context) { self.debounce_pending = false; self.refresh.begin(); @@ -325,14 +276,11 @@ impl CheckoutsStore { let poll = !self.status_requested.is_empty() || !self.push_requested.is_empty(); let work = cx.background_spawn(async move { - // Read the git facts of every scanned repository off the main thread. - // - // The facts are the origin URL and the root commit, both CLI reads. let mut facts: Vec<(PathBuf, Option, Option)> = Vec::new(); for scanned in scanned.iter() { let path = &scanned.path; - // The browser's mirror clones share the announce URLs and EUCs. They are not user checkouts. + // The browser's mirror clones are not user checkouts. if cache_root .as_ref() .is_some_and(|root| path.starts_with(root)) @@ -353,7 +301,6 @@ impl CheckoutsStore { let associations = CheckoutsStore::resolve_associations(&remembered, &facts, announcements.iter()); - // Missing directories are stale records, drop them. let associations: HashMap> = associations .into_iter() .map(|(addr, paths)| (addr, paths.into_iter().filter(|p| p.is_dir()).collect())) @@ -369,7 +316,6 @@ impl CheckoutsStore { let (associations, statuses, push_statuses) = match work.await { Ok(results) => results, Err(_) => { - // Git reads are best-effort, keep the last results. return this.update(cx, |this, cx| { this.refresh.abort(); if poll { @@ -388,8 +334,7 @@ impl CheckoutsStore { this.statuses = statuses; this.push_statuses = push_statuses; - // Notify only when something actually changed, so observers - // skip the no-op heartbeats. + // Notify only when something actually changed. if associations_changed || statuses_changed || push_statuses_changed { cx.notify(); } @@ -402,9 +347,6 @@ impl CheckoutsStore { this.update(cx, |this, cx| this.refresh(cx))?; } - // Restart the fast local pass so the freshly resolved - // associations drive it. The pass itself decides when the next - // full pass runs. this.update(cx, |this, cx| { if poll { this.schedule_local_pass(cx); @@ -416,7 +358,6 @@ impl CheckoutsStore { .detach(); } - /// Schedule the fast local status pass, unless one is already pending. fn schedule_local_pass(&mut self, cx: &mut Context) { if self.local_pending { return; @@ -433,20 +374,17 @@ impl CheckoutsStore { .detach(); } - /// The fast local status pass. fn local_tick(&mut self, cx: &mut Context) { - // Nothing watched: the chain idles out until a new request restarts it. + // Nothing watched: the pass idles until a new request restarts it. if self.status_requested.is_empty() && self.push_requested.is_empty() { return; } - // A full pass or a fresh request covers this tick, skip it. if self.refresh.running() || self.debounce_pending { self.schedule_local_pass(cx); return; } - // Open panels get the faster remote cadence. let cadence = if self.status_requested.is_empty() { PUSH_POLL } else { @@ -467,7 +405,6 @@ impl CheckoutsStore { self.schedule_local_pass(cx); } - /// Recompute the requested statuses against the tracking refs only. fn run_local_statuses(&mut self, cx: &mut Context) { let associations = self.by_repo.clone(); @@ -492,13 +429,12 @@ impl CheckoutsStore { let task: gpui::Task> = cx.spawn(async move |this, cx| { let Ok((statuses, push_statuses)) = work.await else { - // Git reads are best-effort, keep the last results. return Ok(()); }; this.update(cx, |this, cx| { - // A full pass or a fresh request will apply fresher data - // (the tracking refs move only when a full pass fetches). + // The tracking refs move only when a full pass fetches; a full + // pass or a fresh request will apply fresher data. if this.refresh.running() || this.debounce_pending { return; } @@ -593,7 +529,7 @@ impl CheckoutsStore { }) } - /// The `ready to push` status of one checkout of the user's own repository. + // Never fetches the checked-out refs; reads the tracking refs as-is. fn checkout_push_status(path: &Path, fetch: bool) -> Option { let repo = Repo::try_open(path)?; if repo.is_dirty() { @@ -611,8 +547,8 @@ impl CheckoutsStore { let remote = format!("refs/remotes/origin/{branch}"); - // A branch never fetched or pushed yet compares against the remote HEAD. - // The remote HEAD is the fork point in practice. + // A branch never fetched or pushed yet compares against the remote + // HEAD, the fork point in practice. let base = if repo.ref_exists(&remote) { remote } else if repo.ref_exists("refs/remotes/origin/HEAD") { @@ -632,7 +568,6 @@ impl CheckoutsStore { }) } - /// Compute the requested statuses against the checkout paths of `associations`. fn compute_statuses( associations: &HashMap>, requested: &[(RepoAddr, Option)], @@ -716,7 +651,6 @@ mod tests { #[test] fn same_repo_url_ignores_the_transport_scheme() { - // grasp announce vs https origin, with and without `.git`. assert!(same_repo_url( "grasp://relay.ngit.dev/npub1test/repo", "https://relay.ngit.dev/npub1test/repo.git" @@ -725,7 +659,6 @@ mod tests { "ws://localhost:8080/npub1test/repo", "http://localhost:8080/npub1test/repo" )); - // The port and the path matter. assert!(!same_repo_url( "wss://localhost:8081/npub1test/repo", "wss://localhost:8080/npub1test/repo" @@ -734,7 +667,6 @@ mod tests { "wss://host/npub1test/repo", "wss://host/npub1other/repo" )); - // Unparseable URLs compare literally. assert!(same_repo_url("/local/path", "/local/path")); assert!(!same_repo_url("/local/path", "/local/other")); } @@ -762,7 +694,6 @@ mod tests { run(&["commit", "-m", message]); }; - // A feature branch ahead of main, ready to contribute. run(&["checkout", "-b", "feature"]); std::fs::write(path.join("feature.txt"), "x\n").expect("write"); commit("feature work"); @@ -772,22 +703,16 @@ mod tests { assert_eq!(status.ahead, 1); assert_eq!(status.head.len(), 40); - // Dirty worktrees are never suggested. std::fs::write(path.join("uncommitted.txt"), "y\n").expect("write"); assert!(CheckoutsStore::checkout_status(&path, Some("main")).is_none()); run(&["checkout", "--", "."]); - // Even on main, nothing to propose. run(&["checkout", "main"]); assert_eq!(CheckoutsStore::checkout_status(&path, Some("main")), None); } #[test] fn checkout_push_status_counts_unpushed_commits_only() { - // The `grasp remote` is a plain repository the checkout clones from. - // Its origin URL is a local path, so the whole cycle runs offline. - // Git refuses pushes to a checked-out branch by default. - // Act like a grasp server and allow them. let dir = tempfile::tempdir().expect("tempdir"); let remote = dir.path().join("remote"); Repo::init(&remote, "My Repo", "").expect("init"); @@ -827,7 +752,6 @@ mod tests { // A fresh clone has nothing to push. assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None); - // One local commit, ready to push, counted against the remote. std::fs::write(checkout.join("work.txt"), "x\n").expect("write"); run(&["add", "-A"]); run(&["commit", "-m", "local work"]); @@ -837,17 +761,12 @@ mod tests { assert_eq!(status.ahead, 1); assert_eq!(status.head.len(), 40); - // The local-only pass reads the tracking refs, no fetch needed: - // a commit lands locally long before the remote is reconciled. let local = CheckoutsStore::checkout_push_status(&checkout, false).expect("local status"); assert_eq!(local.ahead, 1); - // After the push the same commit is on the remote, idle again. run(&["push", "origin", "main"]); assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None); - // A commit made by someone else on the remote must not count as local work. - // It is behind, not ahead. let remote_run = |args: &[&str]| { let status = Command::new("git") .current_dir(&remote) diff --git a/crates/signed_state/src/git_store.rs b/crates/signed_state/src/git_store.rs index c4072a2..8048b90 100644 --- a/crates/signed_state/src/git_store.rs +++ b/crates/signed_state/src/git_store.rs @@ -8,11 +8,9 @@ use signed_git::{GitCache, Repo}; static GIT_CACHE: OnceLock = OnceLock::new(); -/// The global git repository mirror cache. pub struct Mirrors; impl Mirrors { - /// Install the global mirror cache root, once. pub fn install(root: impl Into) { if GIT_CACHE.set(GitCache::new(root.into())).is_err() { log::warn!("git cache root is already set, keeping the first one"); @@ -25,22 +23,18 @@ impl Mirrors { .expect("git cache is initialized by signed_state::init") } - /// The root directory of the repository mirrors. pub(crate) fn root() -> PathBuf { Self::cache().root().to_path_buf() } - /// The on-disk path of the mirror of `addr`. pub fn path(addr: &RepoAddr) -> PathBuf { Self::cache().repo_path(addr) } - /// Open the mirror of `addr`, if it has been cloned. pub fn open(addr: &RepoAddr) -> Result> { Self::cache().open(addr) } - /// Open the mirror of `addr`, cloning it first when it does not exist yet. pub fn ensure(addr: &RepoAddr, clone_urls: &[Url]) -> Result { Self::cache().ensure_clone(addr, clone_urls) } diff --git a/crates/signed_state/src/inbox.rs b/crates/signed_state/src/inbox.rs index 96e56b7..69bab27 100644 --- a/crates/signed_state/src/inbox.rs +++ b/crates/signed_state/src/inbox.rs @@ -7,7 +7,6 @@ use signed_core::{Deletions, Filters, GitEvent, InboxItem, InboxReadState, inbox use crate::backend::Backend; -/// The user's persisted inbox read state. #[derive(Default)] pub struct Inbox { state: InboxReadState, @@ -15,7 +14,6 @@ pub struct Inbox { } impl Inbox { - /// The current read/archive cutoffs. pub fn state(&self) -> &InboxReadState { &self.state } @@ -67,7 +65,6 @@ impl Inbox { cx.notify(); } - /// Sign the state with a random key and store it locally. fn persist(&mut self, cx: &mut Context) { let backend = Backend::global(cx); let (me, client) = { @@ -91,7 +88,6 @@ impl Inbox { task.detach(); } - /// The notifications and authored activity of `me`, grouped into inbox items. pub async fn query( client: &Client, me: PublicKey, @@ -127,7 +123,6 @@ impl Inbox { } impl Inbox { - /// `d` tag identifying the inbox state event of `me`. fn inbox_state_d_tag(me: PublicKey) -> String { format!("signed-inbox-state:{}", me.to_hex()) } @@ -152,7 +147,8 @@ impl Inbox { } } - /// Sign with a random key and store locally. + // Signed with a random key: the state is local-only, authorship does not + // matter and no key material needs to be kept. async fn save_state( client: &Client, me: PublicKey, @@ -167,7 +163,6 @@ impl Inbox { Ok(()) } - /// Notification events and a lookup of every ancestor they reference. async fn fetch_notifications( client: &Client, me: PublicKey, @@ -223,7 +218,6 @@ impl Inbox { Ok((notifications, by_id)) } - /// Event ids referenced by `event` through its `e` and `E` tags. fn event_references(event: &Event) -> impl Iterator + '_ { event.tags.iter().filter_map(|tag| { if tag.kind() != "e" && tag.kind() != "E" { diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 163d5be..9d858ff 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -37,28 +37,17 @@ pub fn init( // rustls uses the `aws_lc_rs` provider by default. let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - // Initialize the nostr client and signer let backend = cx .foreground_executor() .block_on(async move { NostrBackend::open(db_path.as_ref()).await }); let backend = backend.expect("failed to initialize nostr backend"); let (client, signer) = (backend.client, backend.signer); - // Set Git cache for the repos root Mirrors::install(repos_root); - // Set global stores for the backend Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx); - - // Set global stores for the profile ProfileStore::set_global(cx.new(ProfileStore::new), cx); - - // Set global stores for the repo list and local repos RepoListStore::set_global(cx.new(RepoListStore::new), cx); - - // Set global stores for the local repos LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx); - - // Set global stores for the checkouts CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx); } diff --git a/crates/signed_state/src/local_repos.rs b/crates/signed_state/src/local_repos.rs index 12b84d0..5fd5488 100644 --- a/crates/signed_state/src/local_repos.rs +++ b/crates/signed_state/src/local_repos.rs @@ -11,10 +11,8 @@ struct GlobalLocalReposStore(Entity); impl Global for GlobalLocalReposStore {} -/// Store of the git repositories discovered under a set of scan paths. pub struct LocalReposStore { pub roots: Arc>, - /// Git repositories discovered under [`Self::roots`], sorted by path. pub repos: Arc>, pub scanning: bool, scan_dirty: bool, @@ -45,7 +43,6 @@ impl LocalReposStore { } } - /// Forget a repository that has just been published to NIP-34. pub fn remove(&mut self, path: &Path, cx: &mut Context) { self.repos = Arc::new( self.repos @@ -94,7 +91,6 @@ impl LocalReposStore { dirty })?; - // Scans requested while this one ran are coalesced into one follow-up scan. if again { this.update(cx, |this, cx| this.rescan(cx))?; } @@ -106,8 +102,6 @@ impl LocalReposStore { } } -/// The NIP-34 coordinate a repository's detection resolved, when both the owner -/// and the identifier were recovered. pub fn local_repo_addr(repo: &LocalRepo) -> Option { let binding = repo.nip34.as_ref()?; let owner = binding.owner?; @@ -116,18 +110,14 @@ pub fn local_repo_addr(repo: &LocalRepo) -> Option { Some(RepoAddr::new(owner, identifier)) } -/// A scanned repository resolved against the known announcements. #[derive(Debug, Clone, PartialEq)] pub struct ResolvedLocalRepo { pub path: PathBuf, - /// `None` for a plain repository. pub nip34: Option, - /// The known announcement this repository is bound to, when one matched. pub announcement: Option, } impl ResolvedLocalRepo { - /// The repository's directory name, or `Untitled` when the path has none. pub fn name(&self) -> SharedString { self.path .file_name() @@ -136,7 +126,6 @@ impl ResolvedLocalRepo { } } -/// Resolve the scanned repositories against the known announcements. impl LocalReposStore { pub fn resolve( repos: &[LocalRepo], diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index daeffc3..0f59430 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -13,14 +13,10 @@ use utils::shorten_pubkey; use crate::backend::{Backend, BackendEvent}; use crate::bootstrap::sync_bootstrap_only; -/// How long to wait for more requests before firing a batched fetch. const BATCH_TIMEOUT: Duration = Duration::from_millis(500); -/// Max authors per profile request, keeping each filter within relay limits. const REQUEST_CHUNK: usize = 100; -/// Recent profiles prefetched at startup and read back from the cache. const WARM_LIMIT: usize = 500; -/// A user profile as plain data for the UI, from the kind-0 metadata. #[derive(Debug, Clone)] pub struct Profile { public_key: PublicKey, @@ -43,7 +39,7 @@ impl Profile { &self.metadata } - /// Display name, falling back to `name`, then a shortened npub. + // Falls back to `name`, then a shortened npub. pub fn name(&self) -> SharedString { if let Some(display_name) = self.metadata.display_name.as_ref() && !display_name.is_empty() @@ -69,14 +65,9 @@ impl Profile { } } -/// Global profile cache. -/// -/// Profiles are fetched in batches and kept as plain data. pub struct ProfileStore { profiles: HashMap, - /// Public keys requested this session, main thread only. seen: RefCell>, - /// Sender for queuing fetch requests, batched by a background task. sender: Sender, _subscription: Subscription, } @@ -126,9 +117,7 @@ impl ProfileStore { } } - /// Get a profile. - /// - /// Returns a placeholder with default metadata. Queues a fetch when the profile is not cached yet. + // Returns a placeholder until fetched; queues a fetch when uncached. pub fn get(&self, public_key: &PublicKey) -> Profile { if let Some(profile) = self.profiles.get(public_key) { return profile.clone(); @@ -179,7 +168,6 @@ impl ProfileStore { .detach(); } - /// Re-read the latest metadata of `authors` from the local database in one query. fn apply_authors(&mut self, authors: Vec, cx: &mut Context) { if authors.is_empty() { return; @@ -232,13 +220,11 @@ impl ProfileStore { .detach(); } - /// Re-read the latest metadata of every requested author from the local database. fn apply_seen(&mut self, cx: &mut Context) { let authors: Vec = self.seen.borrow().iter().copied().collect(); self.apply_authors(authors, cx); } - /// Fetch metadata for requested authors in batches, debounced to collect requests. async fn handle_requests( this: WeakEntity, client: &Client, diff --git a/crates/signed_state/src/push.rs b/crates/signed_state/src/push.rs index 9bd06af..6858e0a 100644 --- a/crates/signed_state/src/push.rs +++ b/crates/signed_state/src/push.rs @@ -12,14 +12,11 @@ use signed_nostr::UniversalSigner; pub(crate) const GRASP_PUSH_ATTEMPTS: usize = 3; -/// Pause before re-staging a state event after a transient denial. const GRASP_RETRY_DELAY: Duration = Duration::from_secs(1); -/// Base URL of a grasp server, `https://`. -/// -/// `ws://` grasp servers use `http://`, like ngit. +// `ws://` grasp servers, like ngit, use `http://`; secure relays map to +// `https://`. pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option { - // `domain()` drops the port. let parsed = Url::parse(relay.as_str()).ok()?; let host = parsed.host_str()?; let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default(); @@ -37,14 +34,12 @@ pub(crate) fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> O Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok() } -/// GRASP-06 contributor namespace URL of a pull request tip. +// GRASP-06 contributor namespace URL of a pull request tip. pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String { format!("{base_url}/prs/{npub}/{repo_id}.git") } -/// Assemble the `clone` URLs of a pull request. -/// -/// The author's GRASP-06 `/prs/` URLs come first. +// The author's GRASP-06 `/prs/` URLs come first. pub(crate) fn pr_clone_urls(prs_urls: Vec, base_clone_urls: Vec) -> Vec { let mut seen = std::collections::HashSet::new(); let mut urls = Vec::new(); @@ -59,7 +54,6 @@ pub(crate) fn pr_clone_urls(prs_urls: Vec, base_clone_urls: Vec) -> Ve #[derive(Debug, Clone)] pub struct GraspServer { relay: RelayUrl, - /// `None` when the server accepted the data, the reason otherwise. reason: Option, } @@ -82,7 +76,6 @@ impl GraspServer { &self.relay } - /// `None` when the server accepted the data, the reason otherwise. pub fn reason(&self) -> Option<&str> { self.reason.as_deref() } @@ -90,11 +83,9 @@ impl GraspServer { #[derive(Debug, Clone, Default)] pub struct PushOutcome { - /// Per-server results, in the order the servers were listed. pub servers: Vec, - /// The newest state event a grasp relay accepted for this push, if any. - /// - /// Broadcast to the other relays once a git server holds the data. + // The newest state event a grasp relay accepted for this push; broadcast + // to the other relays once a git server holds the data. pub state_event: Option, } @@ -121,9 +112,7 @@ impl PushOutcome { .join("; ") } - /// A warning for a push only some grasp servers accepted. - /// - /// `None` when every server accepted the push or nothing was pushed. + // `None` when every server accepted the push or nothing was pushed. pub fn partial_warning(&self) -> Option { let accepted = self.accepted(); if self.servers.is_empty() || accepted == self.servers.len() { @@ -137,25 +126,23 @@ impl PushOutcome { } } -/// Reasons a push attempt should be retried with a freshly staged state -/// event and a fresh git advertisement. -/// -/// Two families are retried: -/// -/// - **Purgatory denials**: the grasp server sends these when the state -/// event for the push has not reached its purgatory yet. Re-staging a -/// fresh event resolves them. -/// - **Stale advertisement races**: `git receive-pack` compares each ref -/// update against the value it advertised when the push started. The grasp -/// server's own background sync can move a ref in between - typically by -/// aligning the repository to a parked state event once the objects of an -/// earlier attempt land - so the compare-and-swap fails with `cannot lock -/// ref` / `incorrect old value provided`. A retry against the fresh -/// advertisement converges, and when the race is lost the pushed data is -/// usually already on the server (see `is_stale_advertisement_race` and -/// the convergence probe in `push_staged_to_grasps`). -/// -/// Other rejections are not retried. +// Reasons a push attempt should be retried with a freshly staged state event +// and a fresh git advertisement. Two families are retried: +// +// - **Purgatory denials**: the grasp server sends these when the state event +// for the push has not reached its purgatory yet. Re-staging a fresh event +// resolves them. +// - **Stale advertisement races**: `git receive-pack` compares each ref +// update against the value it advertised when the push started. The grasp +// server's own background sync can move a ref in between - typically by +// aligning the repository to a parked state event once the objects of an +// earlier attempt land - so the compare-and-swap fails with `cannot lock +// ref` / `incorrect old value provided`. A retry against the fresh +// advertisement converges, and when the race is lost the pushed data is +// usually already on the server (see `is_stale_advertisement_race` and +// the convergence probe in `push_staged_to_grasps`). +// +// Other rejections are not retried. fn is_transient_grasp_denial(stderr: &str) -> bool { let error = stderr.to_lowercase(); [ @@ -171,19 +158,17 @@ fn is_transient_grasp_denial(stderr: &str) -> bool { .any(|marker| error.contains(marker)) } -/// A push rejected because `git receive-pack`'s compare-and-swap lost to the -/// grasp server's own background ref alignment: the ref moved between this -/// push's advertisement and its ref transaction (`cannot lock ref ... is at -/// ... but expected ...` / `incorrect old value provided`). The pushed data -/// is usually already on the server by then. +// A push rejected because `git receive-pack`'s compare-and-swap lost to the +// grasp server's own background ref alignment: the ref moved between this +// push's advertisement and its ref transaction (`cannot lock ref ... is at +// ... but expected ...` / `incorrect old value provided`). The pushed data +// is usually already on the server by then. fn is_stale_advertisement_race(stderr: &str) -> bool { let error = stderr.to_lowercase(); error.contains("cannot lock ref") || error.contains("incorrect old value provided") } -/// Keep `event` as the push's fan-out state event when it is newer than the -/// current one. All staged events carry the same refs; the newest timestamp -/// wins on the relays. +// All staged events carry the same refs; the newest timestamp wins on the relays. fn keep_newest(state_event: &mut Option, event: Event) { if state_event .as_ref() @@ -193,8 +178,8 @@ fn keep_newest(state_event: &mut Option, event: Event) { } } -/// The grasp push pipeline: stage a signed state event on each server's -/// relay, then push the git data, retrying transient denials. +// The grasp push pipeline: stage a signed state event on each server's +// relay, then push the git data, retrying transient denials. #[derive(Clone)] pub(crate) struct GraspPush { client: Client, @@ -206,7 +191,6 @@ impl GraspPush { Self { client, signer } } - /// The event was accepted by at least one relay, or a descriptive error otherwise. pub(crate) fn require_relay_accepted( output: SendEventOutput, event: Event, @@ -224,10 +208,7 @@ impl GraspPush { Ok(event) } - /// Sign and broadcast `builder`, logging rather than surfacing failures. - /// - /// Used for best-effort identity bootstrap events, where a relay hiccup - /// should not block sign-up. + // A relay hiccup should not block sign-up, so failures are only logged. pub(crate) async fn publish_best_effort(&self, builder: EventBuilder) { let result: Result<(), Error> = async { let event = builder.finalize_async(&self.signer).await?; @@ -242,21 +223,16 @@ impl GraspPush { } } - /// Sign `builder`, broadcast the event and require a relay to accept it. - /// Returns the signed event. pub(crate) async fn publish_one(&self, builder: EventBuilder) -> Result { let event = builder.finalize_async(&self.signer).await?; self.send_accepted(event).await } - /// Broadcast an already signed event and require a relay to accept it. - /// Returns the event. pub(crate) async fn send_accepted(&self, event: Event) -> Result { let output = self.client.send_event(&event).broadcast().await?; Self::require_relay_accepted(output, event) } - /// Sign and send a single NIP-09 deletion request for `event`. pub(crate) async fn retract_event(&self, event: &Event) -> Result<(), Error> { let builder = EventDeletionRequest::new() .id(event.id) @@ -268,12 +244,9 @@ impl GraspPush { Ok(()) } - /// Sign a fresh kind `30618` state event for the push. - /// - /// `last_created_at` is the timestamp of the previous event signed for this push. - /// Retries within the same second get the next second: a grasp relay - /// treats a same-id resend as a duplicate and does not re-run its ingest, - /// so an identical resend cannot re-park a state event lost from its purgatory. + // Retries within the same second get the next second: a grasp relay + // treats a same-id resend as a duplicate and does not re-run its ingest, + // so an identical resend cannot re-park a state event lost from its purgatory. async fn sign_state_event( &self, repo_id: &str, @@ -297,11 +270,8 @@ impl GraspPush { Ok((event, created_at)) } - /// Ensure the relay is known and connected, then publish `event` to it. - /// - /// `Ok` only when the relay confirmed the event. - /// On a grasp relay the accept parks the event in purgatory, - /// which authorizes the paired git push. + // `Ok` only when the relay confirmed the event. On a grasp relay the + // accept parks the event in purgatory, which authorizes the paired git push. async fn stage_event_on_relay(&self, relay: &RelayUrl, event: &Event) -> Result<(), String> { self.client .add_relay(relay) @@ -353,13 +323,12 @@ impl GraspPush { let mut reason = None; let mut last_created_at = 0; - // The last state event staged on this server, for the convergence - // probe below when every push attempt lost the stale-ref race. + // For the convergence probe when every push attempt lost the + // stale-ref race. let mut staged_event = None; 'server: for attempt in 1..=GRASP_PUSH_ATTEMPTS { if attempt > 1 { - // Give the server's ingest a moment before re-staging. executor.timer(GRASP_RETRY_DELAY).await; } @@ -376,14 +345,12 @@ impl GraspPush { last_created_at = created_at; - // Stage the state event on this server's own relay. - // A failed stage means the grasp never parked the state, - // so the git push would be denied anyway: skip it (the eligibility gate). + // A failed stage means the grasp never parked the state, so + // the git push would be denied anyway: skip it. if let Err(e) = self.stage_event_on_relay(relay, &event).await { // One retry absorbs a relay connect blip, on the first // attempt only. if attempt == 1 && self.stage_event_on_relay(relay, &event).await.is_ok() { - // staged on the retry } else { reason = Some(e); break 'server; @@ -410,10 +377,9 @@ impl GraspPush { // The grasp's own background sync aligns refs to staged state // events as soon as the objects land, which can beat every push - // attempt's compare-and-swap (`cannot lock ref ... but expected`). - // When the last denial was that race the sync has usually finished - // by now: verify the advertised refs and accept the server when the - // pushed data is already there. + // attempt's compare-and-swap. When the last denial was that race + // the sync has usually finished by now: verify the advertised refs + // and accept the server when the pushed data is already there. if let Some(last_reason) = &reason && is_stale_advertisement_race(last_reason) && Repo::open(path) @@ -476,7 +442,6 @@ mod tests { grasp06_prs_url("https://relay.ngit.dev", "npub1author", "my-repo"), "https://relay.ngit.dev/prs/npub1author/my-repo.git" ); - // `ws://` grasp servers, local dev, keep their plain-HTTP base. assert_eq!( grasp06_prs_url("http://localhost:8080", "npub1author", "my-repo"), "http://localhost:8080/prs/npub1author/my-repo.git" @@ -508,14 +473,11 @@ mod tests { #[test] fn transient_grasp_denials_are_classified() { - // The exact server rejection that started this work: the state event - // had not reached the grasp's purgatory before the git push. let reported = "remote: ERR authorisation failed: No state events in purgatory\n\ fatal: the remote end hung up unexpectedly\n\ error: failed to push some refs to 'https://relay.ngit.dev/...git'"; assert!(is_transient_grasp_denial(reported)); - // The other purgatory states a fresh event resolves. assert!(is_transient_grasp_denial( "remote: ERR authorisation failed: No matching state event found in purgatory" )); @@ -531,7 +493,6 @@ mod tests { "remote: ERR authorisation failed: No repository announcement found" )); - // Rejections a fresh state event cannot fix are not retried. assert!(!is_transient_grasp_denial( "remote: ERR authorisation failed: not a maintainer of this repository" )); @@ -546,9 +507,6 @@ mod tests { #[test] fn stale_ref_races_are_retried() { - // The grasp's background sync aligned the ref to a parked state event - // between this push's advertisement and its ref transaction. The ref - // is usually already where the push wants it, so a retry converges. let reported = "remote: error: cannot lock ref 'refs/heads/main': is at \ cac2ac91b6f5fb8dfcb6962785babc6e65350cb3 but expected \ bc5e892aa84dc6240a5fbcd59367a4857d26f49b\n\ @@ -558,7 +516,6 @@ mod tests { assert!(is_transient_grasp_denial(reported)); assert!(is_stale_advertisement_race(reported)); - // Markers match independently of the surrounding git output. assert!(is_stale_advertisement_race( "cannot lock ref 'refs/heads/main'" )); @@ -566,10 +523,8 @@ mod tests { "! [remote rejected] main -> main (incorrect old value provided)" )); - // A purgatory denial is not a stale-advertisement race. assert!(!is_stale_advertisement_race("No state events in purgatory")); - // A real divergence is a different error and stays permanent. assert!(!is_transient_grasp_denial( " ! [rejected] main -> main (non-fast-forward)" )); diff --git a/crates/signed_state/src/refresh.rs b/crates/signed_state/src/refresh.rs index e296d72..0f63e56 100644 --- a/crates/signed_state/src/refresh.rs +++ b/crates/signed_state/src/refresh.rs @@ -1,4 +1,3 @@ -/// Refresh coalescing shared by the event stores. #[derive(Debug, Default)] pub struct RefreshGate { running: bool, @@ -7,9 +6,7 @@ pub struct RefreshGate { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RefreshRequest { - /// No run covers the request, start one now. Schedule, - /// A run is in flight and covers the request, fold it into a follow-up. Fold, } @@ -31,13 +28,12 @@ impl RefreshGate { self.running = true; } - /// The run ended. Whether a request arrived while it ran. pub fn finish(&mut self) -> bool { self.running = false; std::mem::take(&mut self.dirty) } - /// The run was abandoned, e.g. on error. Pending follow-up requests survive. + // Pending follow-up requests survive an abandoned run. pub fn abort(&mut self) { self.running = false; } diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 532ac6e..fd4f907 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -21,74 +21,41 @@ use crate::checkouts::CheckoutsStore; use crate::push::{GraspPush, PushOutcome, grasp_base_url, grasp06_prs_url, pr_clone_urls}; use crate::repos::RepoListStore; -/// Maximum size of one patch event. -/// -/// NIP-34 suggests patches when each event is under 60kb. +// NIP-34 suggests patches when each event is under 60kb. const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024; -/// Per-repository store. -/// -/// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses. pub struct RepoStore { - /// NIP-34 address. `None` while the repository is local-only. addr: Option, - /// Latest announcement. Seeded from the open-time hint, replaced by the - /// database's latest on the first pass. `None` while local-only. + // Seeded from the open-time hint, replaced by the database's latest on the + // first pass. `None` while local-only. pub announcement: Option, - /// Local working copy. The scan path for a local repository, kept when it is - /// later announced so the panel keeps its worktree. + // The scan path for a local repository, kept when it is later announced so + // the panel keeps its worktree. pub path: Option, - /// NIP-34 state detected on disk for a local repository, if any. pub nip34: Option, - /// The first local pass has been applied. - /// - /// Views distinguish "no data yet" from a genuinely empty repository with it. + // Views distinguish "no data yet" from a genuinely empty repository with it. pub loaded: bool, - /// Branch pointed to by `HEAD` in the latest state announcement. pub head: Option, pub issues: Vec, pub patches: Vec, pub pull_requests: Vec, - /// Comments on issues / PRs, oldest first. pub comments: Vec, - /// Resolved status per root event, issue, patch or PR. status_by_root: HashMap, - /// Open issue and root PR counts. - /// Computed with [`Self::status_by_root`] on every refresh. open_issue_count: usize, open_pr_count: usize, pub last_error: Option, - /// Non-fatal warning of the last action, if any. - /// - /// Example, a PR published without its commit reaching a grasp server. pub last_warning: Option, - /// Warning of the last push that only some grasp servers accepted. - /// - /// The repository is out of sync on the rejected servers until it is republished. + // The repository is out of sync on the rejected servers until republished. pub last_push_warning: Option, - /// A republish or a checkout push is in flight. - /// - /// Views show a spinner and disable their push triggers while it is set. pub pushing: bool, - /// A clone-into-a-folder operation is in flight. - /// - /// Views show a spinner and disable the clone trigger while it is set. pub cloning: bool, - /// Relays already asked to connect to, from this repository's NIP-34 `relays` tag. - /// - /// Avoids re-subscribing and re-fetching on every refresh. + // Avoids re-subscribing and re-fetching on every refresh. repo_relays: HashSet, - /// Root events, issues, patches and PRs, already fetched per root. - /// - /// The per-root fetches cover NIP-22 comments and statuses without an `a` tag. + // Covers NIP-22 comments and statuses without an `a` tag. root_fetches: HashSet, - /// Maintainers already synced through gossip in Uncensored mode. - /// - /// Avoids re-running the maintainer Auto sync on every refresh. synced_maintainers: HashSet, - /// In-flight tasks, cancelled when the store drops. + // In-flight tasks, cancelled when the store drops. tasks: Vec>>, - /// Backend subscription of an announced repository. `None` while local-only. _subscription: Option, } @@ -141,7 +108,6 @@ impl RepoStore { } } - /// Local repository discovered by the scan, not announced to NIP-34 yet. pub fn new_local(path: PathBuf, nip34: Option) -> Self { Self { addr: None, @@ -170,7 +136,6 @@ impl RepoStore { } } - /// An announced repository whose working copy is already on disk. pub fn from_worktree( addr: RepoAddr, announcement: Announcement, @@ -182,7 +147,6 @@ impl RepoStore { store } - /// Switch a local repository to its NIP-34 mode, keeping its path. pub fn announce(&mut self, announcement: Announcement, cx: &mut Context) { self.addr = Some(announcement.addr()); self.announcement = Some(announcement.clone()); @@ -235,7 +199,6 @@ impl RepoStore { self.addr.as_ref() } - /// Returns the repository's name, or `Unknown` when not known. pub fn name(&self) -> SharedString { self.announcement .as_ref() @@ -244,9 +207,6 @@ impl RepoStore { }) } - /// Filters that make up a repository. - /// - /// Announcement, state, activity and deletions targeting it. fn repo_filters(addr: &RepoAddr) -> Vec { let mut filters = vec![ Filter::new() @@ -255,12 +215,11 @@ impl RepoStore { .identifier(addr.identifier()), addr.activity_filter(), ]; - // Deletion requests, NIP-09/62, must be known before any event is shown. + // Deletion requests must be known before any event is shown. filters.extend(addr.deletion_filters()); filters } - /// Fetch this repository's events from the relays in its NIP-34 `relays` tag. fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context) { let Some(addr) = self.addr.clone() else { return; @@ -285,39 +244,33 @@ impl RepoStore { }); } - /// Filters the SDK resolves through NIP-65 gossip in Uncensored mode. + // NIP-34 events tag the announcement author, which may not be a + // maintainer for subordinate forks. fn maintainer_filters(addr: &RepoAddr, maintainers: &[PublicKey]) -> Vec { let mut pubkeys = maintainers.to_vec(); - // NIP-34 events tag the announcement author, - // which may not be a maintainer for subordinate forks. if !pubkeys.contains(&addr.public_key()) { pubkeys.push(addr.public_key()); } vec![ - // Announcement and state events, including co-maintainer states. Filter::new() .kinds([Kind::GitRepoAnnouncement, Kind::RepoState]) .authors(pubkeys.clone()) .identifier(addr.identifier()), - // Activity tagging a maintainer, resolved to their read relays. Filter::new() .kinds(filters::ACTIVITY_KINDS) .coordinate(addr.coordinate()) .pubkeys(pubkeys.clone()), - // Activity authored by a maintainer, resolved to their write relays. Filter::new() .kinds(filters::ACTIVITY_KINDS) .coordinate(addr.coordinate()) .authors(pubkeys.clone()), - // Deletions authored by a maintainer. Filter::new() .kinds([Kind::EventDeletion, Kind::RequestToVanish]) .authors(pubkeys), ] } - /// In Uncensored mode, sync the maintainer-shaped filters through the SDK's NIP-65 gossip targeting fn sync_maintainer_relays(&mut self, maintainers: &[PublicKey], cx: &mut Context) { let strategy = SettingsStore::try_global(cx) .map(|store| store.read(cx).settings().event_fetching) @@ -358,7 +311,6 @@ impl RepoStore { }); } - /// Re-query the local database and update all fields. pub fn refresh(&mut self, cx: &mut Context) { if self.addr.is_none() { return; @@ -531,7 +483,6 @@ impl RepoStore { this.announcement = announcement; } - // The announcement may list relays for this repository's activity. let relays = this .announcement .as_ref() @@ -540,7 +491,6 @@ impl RepoStore { this.connect_announced_relays(&relays, cx); - // Uncensored mode also covers the maintainers' NIP-65 relays. let maintainers = this .announcement .as_ref() @@ -602,22 +552,18 @@ impl RepoStore { self.tasks.push(task); } - /// Resolve the status of a root event, an issue, patch or PR, per NIP-34. pub fn status_of(&self, root: &Event) -> RepoStatus { status_of(&self.status_by_root, root) } - /// Number of open issues. pub fn issue_count(&self) -> usize { self.open_issue_count } - /// Number of open pull requests. pub fn pull_request_count(&self) -> usize { self.open_pr_count } - /// Whether `user` is the author or owner of this repository. pub fn is_author(&self, user: &PublicKey) -> bool { self.addr .as_ref() @@ -647,14 +593,10 @@ impl RepoStore { .filter(move |e| e.references_root(root)) } - /// Comment on a root event, an issue or PR, per NIP-34, kind 1111. pub fn comment(&mut self, root: &Event, content: String, cx: &mut Context) { self.reply(root, None, content, cx); } - /// Reply to `parent`, a comment on `root`, with a NIP-22 threaded comment. - /// - /// `None` publishes a top-level comment on the root itself. fn reply( &mut self, root: &Event, @@ -710,8 +652,8 @@ impl RepoStore { return; } - // The tip of the series is its last commit. - // `git format-patch` orders patches oldest first. + // The tip of the series is its last commit; `git format-patch` orders + // patches oldest first. let Some(current_commit) = series.tip_commit() else { self.last_error = Some( "Patch must be `git format-patch` output with a `From ` header".into(), @@ -731,7 +673,6 @@ impl RepoStore { cx.notify(); return; }; - // The author's npub names their GRASP-06 namespace, `/prs/...`. let author_npub = user.to_bech32().unwrap(); let owner = addr.public_key(); @@ -762,8 +703,6 @@ impl RepoStore { }; let task: Task> = cx.spawn(async move |this, cx| { - // The PR references the root patch, - // viewers can then find the patch without carrying it inline. let root_patch = match publish_patch_series( &client, &signer, @@ -785,11 +724,10 @@ impl RepoStore { } }; - // GRASP-06 pushes the tip to the author's own grasp servers. - // The path is `/prs//.git`. - // Contributing to another project never depends on that project's servers. - // Resolve the servers from the author's latest kind-10317 grasp list. - // The settings defaults stand in when no list is published or the query fails. + // GRASP-06 pushes the tip to the author's own grasp servers at + // `/prs//.git`; contributing to another + // project never depends on that project's servers. Resolve the + // servers from the author's latest kind-10317 grasp list. let author_servers = { let query_client = client.clone(); let published = cx @@ -866,7 +804,6 @@ impl RepoStore { } })?; - // Sign before publishing. let event = cx .background_spawn({ let signer = signer.clone(); @@ -883,7 +820,6 @@ impl RepoStore { let path = path.clone(); let tip = tip.clone(); let reference = reference.clone(); - // Author servers first, then the announced base grasp servers. let targets: Vec<(String, String)> = author_targets .into_iter() .chain(base_targets) @@ -933,8 +869,6 @@ impl RepoStore { } }; - // A draft PR carries a kind-1633 status event, NIP-34. - // Publish it right after the PR event so viewers never show it open. if draft { this.update(cx, |this, cx| { this.set_status(&pr_event, RepoStatus::Draft, cx); @@ -946,12 +880,6 @@ impl RepoStore { self.tasks.push(task); } - /// Generate the patch between `merge_base` and `compare_ref` in `repo_path`, - /// then open a pull request from it. - /// - /// Fails descriptively when there are no commits to propose or the patch - /// could not be generated; otherwise publishes exactly like - /// [`Self::open_pull_request`]. #[allow(clippy::too_many_arguments)] pub fn open_pull_request_from_refs( &mut self, @@ -965,8 +893,8 @@ impl RepoStore { cx: &mut Context, ) -> Task> { cx.spawn(async move |this, cx| { - // Regenerate the series at submit time. - // The published patch covers the current tip of the compare branch. + // Regenerate at submit time so the published patch covers the + // current tip of the compare branch. let patch = cx .background_spawn({ let repo_path = repo_path.clone(); @@ -999,9 +927,6 @@ impl RepoStore { }) } - /// Update a pull request. - /// - /// Other authors must open a new PR. pub fn update_pull_request(&mut self, root: &Event, patch: String, cx: &mut Context) { self.last_error = None; self.last_warning = None; @@ -1034,7 +959,7 @@ impl RepoStore { return; } - // The new tip of the PR is the last commit of the series. + // The tip of the updated PR is the last commit of the series. let Some(current_commit) = series.tip_commit() else { self.last_error = Some( "Patch must be `git format-patch` output with a `From ` header".into(), @@ -1043,8 +968,8 @@ impl RepoStore { return; }; - // The first revision patch replies to the original root patch, NIP-34. - // Use the PR's `e` tag, or the oldest patch of the linked set if the PR has none. + // The first revision patch replies to the original root patch, NIP-34: + // use the PR's `e` tag, or the oldest patch of the linked set. let root_patch_id = root.tags.event_ids().next().or_else(|| { PullRequest::new(root) .patches(self.patches.iter()) @@ -1097,8 +1022,8 @@ impl RepoStore { } .into_event_builder(); - // The `r` EUC tag lets clients subscribe to all PR updates. - // The SDK builder omits it. + // The `r` EUC tag lets clients subscribe to all PR updates; the + // SDK builder omits it. match euc.as_deref() { Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")), None => builder, @@ -1122,9 +1047,6 @@ impl RepoStore { self.tasks.push(task); } - /// Set the status of a root event. - /// - /// Only the root author or a maintainer may set it, per NIP-34. fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context) { self.last_error = None; @@ -1166,8 +1088,6 @@ impl RepoStore { self.publish(builder, cx); } - /// The latest announcement of this repository, - /// for operations that need its clone URLs and relays. fn action_announcement(&self, cx: &App) -> Option { let addr = self.addr.as_ref()?; self.announcement.clone().or_else(|| { @@ -1227,11 +1147,6 @@ impl RepoStore { self.run_push(push, Some((addr, path)), cx) } - /// Run a backend push task, tracking progress in [`Self::pushing`] and - /// the outcome in [`Self::last_error`] and [`Self::last_push_warning`]. - /// - /// `pushed_checkout` names the checkout whose ready-to-push statuses - /// should be recomputed after the remote moved. fn run_push( &mut self, push: Task>, @@ -1252,11 +1167,8 @@ impl RepoStore { match &result { Ok(outcome) => { this.last_error = None; - // A push only some grasp servers accepted is a warning: - // the repo is out of sync on the rest until it is republished. this.last_push_warning = outcome.partial_warning(); if let Some((addr, path)) = &pushed_checkout { - // The remote moved, so recompute the ready-to-push statuses. CheckoutsStore::global(cx).update(cx, |store, cx| { store.checkout_pushed(addr, path, cx); }); @@ -1275,10 +1187,6 @@ impl RepoStore { }) } - /// Delete the repository from nostr, announcement, state and activity. - /// - /// Only the repository owner may delete it. The lists update when the - /// deletion events arrive. pub fn delete_repository(&mut self, cx: &mut Context) -> Task> { let Some(addr) = self.addr.clone() else { return self.action_error("This repository is not published to Nostr yet", cx); @@ -1300,8 +1208,7 @@ impl RepoStore { }) } - /// Clone the repository into `destination`, a user-chosen folder outside - /// the cache, and remember the clone as a checkout of this repository. + // A user-chosen folder outside the cache; remembered as a checkout. pub fn clone_to_folder( &mut self, destination: PathBuf, @@ -1329,8 +1236,7 @@ impl RepoStore { let clone = { let destination = destination.clone(); - // The clone's repository handle is dropped in the task: gix handles - // are not `Send`, they must not cross the spawn boundary. + // gix handles are not `Send` and must not cross the spawn boundary. cx.background_spawn(async move { Repo::clone(&clone_urls, &destination).map(|_| ()) }) }; @@ -1376,12 +1282,8 @@ impl RepoStore { Task::ready(Err(anyhow::anyhow!("{message}"))) } - /// Sign `builder`, broadcast it and track the outcome in [`Self::last_error`]. - /// - /// Every one-shot repository event (issue, comment, status) goes through - /// this. Multi-step flows (opening or updating a pull request, a patch - /// series) call the SDK directly instead, since their error handling and - /// post-conditions differ per step. + // Every one-shot repository event (issue, comment, status) goes through + // this; multi-step flows call the SDK directly instead. fn publish(&mut self, builder: EventBuilder, cx: &mut Context) { self.last_error = None; @@ -1446,15 +1348,13 @@ fn resolve_statuses( .collect() } -/// The proposed commit of a `git format-patch` output. -/// It is the `From ` header on the first line. +// The `From ` header on the first line. fn patch_current_commit(patch: &str) -> Option<&str> { let line = patch.lines().next()?; let hex = line.strip_prefix("From ")?; hex.split_whitespace().next().filter(|hex| hex.len() == 40) } -/// A `git format-patch` series with the facts derived from its parts. struct PatchSeries { parts: Vec, } @@ -1469,7 +1369,7 @@ impl PatchSeries { } } - /// The byte length of the first part over the NIP-34 size suggestion. + // The byte length of the first part over the NIP-34 size suggestion. fn oversized_length(&self) -> Option { self.parts .iter() @@ -1477,8 +1377,8 @@ impl PatchSeries { .find(|length| *length > MAX_PATCH_EVENT_BYTES) } - /// The tip of the series is its last commit; - /// `git format-patch` orders patches oldest first. + // The tip of the series is its last commit; `git format-patch` orders + // patches oldest first. fn tip_commit(&self) -> Option { self.parts .last() @@ -1494,9 +1394,7 @@ impl PatchSeries { } } -/// Publish a `git format-patch` series as chained kind-1617 events. -/// -/// Returns the root event, the one a PR references. +// Returns the root event, the one a PR references. #[allow(clippy::too_many_arguments)] async fn publish_patch_series( client: &Client, @@ -1566,7 +1464,6 @@ async fn publish_patch_series( root.ok_or_else(|| anyhow::anyhow!("patch series is empty")) } -/// Build a NIP-22 kind-1111 comment. fn comment_builder( root: &Event, parent: Option<&Event>, @@ -1630,19 +1527,15 @@ mod tests { assert!(kinds.contains(&expected), "missing {expected} tag"); } - // The uppercase `E` tag scopes the root, with its id, relay hint and author. let e = event.tags.iter().find(|t| t.kind() == "E").expect("E tag"); let slice = e.as_slice(); assert_eq!(slice[1], root.id.to_hex()); assert_eq!(slice[2], relay.as_str()); assert_eq!(slice[3], root.pubkey.to_hex()); - // The lowercase `e` tag references the parent. - // For a top-level comment the parent is the root itself. let e = event.tags.iter().find(|t| t.kind() == "e").expect("e tag"); assert_eq!(e.as_slice()[1], root.id.to_hex()); - // Signed's own `references_root` must keep matching the comment. assert!(event.references_root(&root.id)); } @@ -1652,13 +1545,11 @@ mod tests { let maintainer = Keys::generate().public_key(); let addr = signed_core::RepoAddr::new(owner, "my-repo"); - // The owner is not among the maintainers, as on a subordinate fork. let filters = RepoStore::maintainer_filters(&addr, &[maintainer]); assert_eq!(filters.len(), 4); let expected = HashSet::from([owner, maintainer]); - // Gossip only resolves pubkeys from `authors` and the lowercase `#p` tag. let named = |filter: &Filter| -> HashSet { let authors = filter.authors.iter().flatten().copied(); let p_tag = filter @@ -1670,7 +1561,6 @@ mod tests { authors.chain(p_tag).collect() }; - // Announcement and state events, scoped to the repository identifier. let announcement = &filters[0]; assert_eq!(named(announcement), expected); assert!( @@ -1679,7 +1569,6 @@ mod tests { .contains_key(&SingleLetterTag::LOWERCASE_D) ); - // Activity filters, scoped to the repository coordinate. for filter in &filters[1..3] { assert_eq!(named(filter), expected); assert!( @@ -1689,7 +1578,6 @@ mod tests { ); } - // Deletions, named by author only. let deletions = &filters[3]; assert_eq!( deletions diff --git a/crates/signed_state/src/repos.rs b/crates/signed_state/src/repos.rs index 469974b..b3faba6 100644 --- a/crates/signed_state/src/repos.rs +++ b/crates/signed_state/src/repos.rs @@ -17,37 +17,27 @@ struct GlobalRepoListStore(Entity); impl Global for GlobalRepoListStore {} -/// NIP-34 activity event counts per repository, ranking the explore list by popularity. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct RepoActivityCounts { - /// Root `30611` issue events addressed to the repository. pub issues: u32, - /// Root `3063` pull request events addressed to the repository. - /// - /// PR updates are not new PRs and do not count. + // PR updates are not new PRs and do not count. pub pull_requests: u32, - /// `1617` patch events addressed to the repository. pub commits: u32, } impl RepoActivityCounts { - /// Total issues, pull requests and commits, the popularity ranking key. + // The popularity ranking key. pub fn score(self) -> u32 { self.issues + self.pull_requests + self.commits } } -/// Store listing the discovered repository announcements, newest first. pub struct RepoListStore { - /// Shared so views can clone the list per frame without a deep copy. + // Shared so views can clone the list per frame without a deep copy. pub announcements: Arc>, - /// Latest known activity timestamp per repository. pub last_activity: Arc>, - /// Issues, pull requests and commits per repository. - /// - /// Used for the Popular ranking of the explore list. + // For the Popular ranking of the explore list. pub counts: Arc>, - /// Own repositories whose state events were fetched from their announced relays. state_synced_repos: HashSet, refresh: RefreshGate, _subscription: Subscription, @@ -100,7 +90,6 @@ impl RepoListStore { } } - /// The announcements of `user`, newest first. pub fn announcements_of(&self, user: &PublicKey) -> Vec { self.announcements .iter() @@ -125,7 +114,6 @@ impl RepoListStore { }); } - /// Fetch the state events of the user's own repositories. fn sync_own_repo_states(&mut self, cx: &mut Context) { let backend = Backend::global(cx); let Some(me) = backend.read(cx).current_user() else { @@ -149,10 +137,8 @@ impl RepoListStore { } } - /// Re-query the local database. - /// - /// Runs immediately. The backend pump already batches the relay events that - /// trigger a refresh, so no per-store debounce is needed. + // Runs immediately: the backend pump already batches the relay events + // that trigger a refresh, so no per-store debounce is needed. pub fn refresh(&mut self, cx: &mut Context) { if self.refresh.request() != RefreshRequest::Schedule { return; @@ -174,8 +160,8 @@ impl RepoListStore { let deletion_events = client.database().query(Filters::deletions()).await?; let deletions = Deletions::from_events(deletion_events); - // Dedup and sort off the main thread. - // Only the final list crosses back into the entity. + // Dedup and sort off the main thread; only the final list crosses + // back into the entity. let mut by_repo: HashMap = HashMap::new(); for event in events { @@ -220,7 +206,6 @@ impl RepoListStore { *entry = (*entry).max(event.created_at); } - // Bound the activity query to a recent window. // Older repos fall back to their announcement or state timestamps. let activity_filter = Filter::new() .kinds(filters::ACTIVITY_KINDS) @@ -242,8 +227,8 @@ impl RepoListStore { } } - // Popularity counts per repository, issues, pull requests and patches. - // Unbounded, unlike the windowed activity query above, so totals are exact. + // Unbounded, unlike the windowed activity query above, so totals + // are exact. let mut counts: HashMap = HashMap::new(); let count_filter = Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]); @@ -274,7 +259,6 @@ impl RepoListStore { cx.spawn(async move |this, cx| { let (announcements, last_activity, counts) = match work.await { Ok(results) => results, - // Database errors are transient, keep the last list. Err(_) => { return this.update(cx, |this, _cx| { this.refresh.abort(); @@ -292,8 +276,6 @@ impl RepoListStore { this.refresh.finish() })?; - // Requests that arrived while the refresh was running. - // They are coalesced into one follow-up refresh. if again { this.update(cx, |this, cx| this.refresh(cx))?; } -- 2.54.0 From f12feeb1960d6f020917a15d185a4b2339f963b1 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 3 Oct 2026 16:38:25 +0700 Subject: [PATCH 9/9] clean up --- CHANGELOG.md | 6 + crates/signed_core/src/status.rs | 18 +-- docs/REFACTOR_PLAN.md | 264 ------------------------------- docs/TODO.md | 17 -- 4 files changed, 9 insertions(+), 296 deletions(-) delete mode 100644 docs/REFACTOR_PLAN.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a8b1f6..3cec89a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,16 @@ ### Changed +- Surface backend errors as error notifications in the workspace instead of silently dropping them +- Break ties in repository activity lists by event id, so same-second events order deterministically +- Restructure the backend around domain types: git operations behind a `Repo` type, the grasp push pipeline behind `GraspPush`, nostr connectivity behind `NostrBackend`, and shared helpers consolidated into `utils` + ### Fixed ### Removed +- Remove dead code: the unused `login`/`logout` family, the unwired `SyncProgress` pipeline, `merge_pull_request`, inbox mark-read/archive APIs and the wasm32-only code paths + ### Deprecated ## v0.2.0-alpha - 2026/09/27 diff --git a/crates/signed_core/src/status.rs b/crates/signed_core/src/status.rs index 091ed21..17f73d9 100644 --- a/crates/signed_core/src/status.rs +++ b/crates/signed_core/src/status.rs @@ -47,19 +47,10 @@ impl RepoStatus { } } -// NIP-10 and NIP-34 use the lowercase `e` tag; NIP-22 comments, kind `1111`, -// use the uppercase `E` tag for the thread root. -pub fn references_root(event: &Event, root: &EventId) -> bool { - let root = root.to_hex(); - event - .tags - .iter() - .any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str())) -} - #[cfg(test)] mod tests { use super::*; + use crate::GitEvent; const ROOT_ID_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111"; const OTHER_ID_HEX: &str = "2222222222222222222222222222222222222222222222222222222222222222"; @@ -90,11 +81,8 @@ mod tests { )) .expect("signed event"); - assert!(references_root(&event, &root)); - assert!(!references_root( - &event, - &EventId::from_hex(OTHER_ID_HEX).expect("valid id") - )); + assert!(event.references_root(&root)); + assert!(!event.references_root(&EventId::from_hex(OTHER_ID_HEX).expect("valid id"))); } #[test] diff --git a/docs/REFACTOR_PLAN.md b/docs/REFACTOR_PLAN.md deleted file mode 100644 index c4ff04c..0000000 --- a/docs/REFACTOR_PLAN.md +++ /dev/null @@ -1,264 +0,0 @@ -# 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` | diff --git a/docs/TODO.md b/docs/TODO.md index 6393d0b..4640904 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,18 +1 @@ # TODO - -## `BackendEvent::SyncProgress` - -File: `crates/signed_state/src/backend.rs` - -Kept intentionally. The progress pipeline (the `SyncProgress` variant, the `sync_progress` -field and its accessor, and the progress task in `sync_bootstrap`) is retained for a planned -sync progress indicator. No subscriber exists yet. Do not remove it without revisiting that -plan. - -## `login` / `logout` family - -File: `crates/signed_state/src/backend.rs` - -No UI path calls these. `import_dialog::open` is an empty stub. Decide whether to -delete the family (`login`, `login_with_new_identity`, `login_with_nsec`, -`login_with_bunker`, `logout`) or wire the stub to `Backend::login`. -- 2.54.0