wip
This commit is contained in:
+216
-644
@@ -1,657 +1,229 @@
|
||||
# PLAN — PR contribution flows: fork compare + GRASP-06 hosting + checkout suggestions
|
||||
# PLAN — Codebase audit: over-engineering, dead code & simplification
|
||||
|
||||
> **Status (2026-09-03): implemented.** Steps 1-9 and 11 are done on
|
||||
> `feat/fork`; step 10 (sidebar "Ready to contribute" group) remains
|
||||
> deferred as planned (v2, optional). See `docs/PR_FLOW.md` for the
|
||||
> resulting flow; the per-step sections below record what shipped and
|
||||
> where the plan was refined during implementation.
|
||||
|
||||
Combined implementation plan for three coordinated improvements to Signed's pull
|
||||
request experience:
|
||||
|
||||
- **A. Fork-aware compare** — the New PR panel's compare side can come from an
|
||||
announced fork repository's branch (fetched into the base repo's GitCache
|
||||
mirror), instead of only from a user-picked local checkout.
|
||||
- **B. GRASP-06 hosting** — PR tips are pushed to the *author's own* grasp
|
||||
servers under `/prs/<author-npub>/<repo-id>.git` and advertised in the PR's
|
||||
`clone` tag, so contributing to someone else's project never depends on their
|
||||
servers accepting anything from you.
|
||||
- **C. Checkout associations & suggestions** — remember/derive which local
|
||||
folders are checkouts of which announced repos, auto-prefill the New PR panel
|
||||
(no folder picker for the common case), and suggest creating a PR when a
|
||||
branch is ahead with no open PR (GitHub-like nudge, NIP-34-native dedupe).
|
||||
|
||||
Guiding principles (agreed): follow nostr + grasp + ngit, not GitHub;
|
||||
NIP-34/GRASP-06 event surface stays untouched (no new tags/kinds, forks never
|
||||
appear in events); every flow keeps the patch series as the source of truth;
|
||||
no over-engineering — reuse existing stores, patterns and git helpers.
|
||||
> **Status (2026-09-04): proposed.** Full-repo audit (~27k lines, 13 crates).
|
||||
> Every finding was cross-checked against consumers and verified against the
|
||||
> locked library sources (gpui-component `18922d6`, rust-nostr `472c883`,
|
||||
> gix 0.87.1). Pick the steps you want before processing; ordered
|
||||
> safest-first. Estimated total: **~3,000+ lines removable (~12%)**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Protocol grounding (what we may and may not do)
|
||||
## Step 1 — Zero-risk deletions (~700 L, pure removals)
|
||||
|
||||
### 1.1 NIP-34 facts used by this plan
|
||||
1. **Unused Cargo deps** (verified by grep):
|
||||
- `desktop`: `gpui_linux`, `gpui_windows`, `gpui_macos` (only
|
||||
`gpui_platform` is used — it's the meta-crate that picks the backend),
|
||||
`log`
|
||||
- `workspace`: `chrono`
|
||||
- `signed_nostr`: `signed_core`, `nostr`
|
||||
- `assets`: `log`
|
||||
2. **`signed_core::clone_url` module** (whole file) — only self-tests
|
||||
reference it; `repo_detail/mod.rs:2418` *produces* the format but never
|
||||
parses it. (`clone_url.rs` + `lib.rs:13`) <- acceptable
|
||||
3. **`signed_core::comments` module** (`CommentThread`, `comment_threads`) —
|
||||
no consumers; SDK has `nip22::extract_parent` anyway.
|
||||
(`comments.rs` + `lib.rs:14`)
|
||||
4. **`paths` dead accessors**: `cache_dir()`, `logs_dir()`, `keymap_file()`,
|
||||
`set_custom_data_dir()` + `CUSTOM_DATA_DIR`/`OnceLock` machinery
|
||||
(`paths/src/lib.rs:13,50-61,112-169`). Used: `desktop_dir`,
|
||||
`documents_dir`, `settings_file`, `nostr_dir`, `repos_dir`.
|
||||
5. **`signed_git::patch_applies`** (+test) — own TODO says superseded by the
|
||||
live compare view (`lib.rs:252-277, 2294-2324`).
|
||||
6. **`signed_git::worktree_tags`** — both UI sites call `repo_tags` directly
|
||||
(`lib.rs:1811-1813`).
|
||||
7. **Unused re-exports**: `workspace::image_cache` (`workspace/src/lib.rs:6`);
|
||||
`signed_state::lib`'s `pub use utils::shorten_pubkey`
|
||||
(`signed_state/src/lib.rs:21`).
|
||||
8. **Sidebar placeholder nav items** "Inbox", "Search", "Guide" — all three
|
||||
just open the Explore panel (`sidebar/mod.rs:578-618`). <- acceptable
|
||||
9. **`import_dialog.rs`** — opens an empty 400px dialog; the "Import identity"
|
||||
sidebar button is a dead end (`sidebar/mod.rs:405-408,493-504`). <- acceptable
|
||||
10. **All `wasm32` cfg paths + `nostr-memory` dep** — GPUI has no wasm
|
||||
backend; the target cannot link (`signed_state/src/lib.rs:52-63`,
|
||||
`checkouts.rs:108-165`, `signed_nostr/src/backend.rs:9-29`,
|
||||
`signed_nostr/Cargo.toml`). <- acceptable, note: GPUI have support for wasm via gpui_web, updated your memory or check before make changes
|
||||
11. **`dock::t()` unreachable `"Dock.Unnamed"` arm** (`dock/src/lib.rs:21-30`).
|
||||
12. Two handler-less `Button`s ("user" with fake `dropdown_caret`,
|
||||
"maintainers") — render as plain `h_flex` or wire real menus
|
||||
(`sidebar/mod.rs:426-434`, `repo_detail/mod.rs:2289-2307`). <- acceptable
|
||||
13. Stale doc references to nonexistent `helpers::track`
|
||||
(`diff.rs:328`, `mod.rs:201-203`).
|
||||
|
||||
- A PR (kind-1618) is addressed to the **base** repo coordinate (`a` tag) and
|
||||
carries `c` (tip), `merge-base` (common ancestor with the target branch),
|
||||
`branch-name`, `clone` (≥1 URL where the tip commit can be downloaded),
|
||||
`e` → root patch event, `r` (EUC), `p` (base owner). Patches are
|
||||
NIP-10-chained kind-1617 events, ≤60 KB each. Statuses 1630–1633 resolve the
|
||||
PR.
|
||||
- Repository announcements (30617): `u` tag marks a subordinate fork
|
||||
(`30617:<pubkey>:<id>` coordinate or git URL); the `r`/`euc` tag identifies
|
||||
the earliest unique commit, shared by every repo of the same project family
|
||||
(forks, mirrors). Both are **read-only inputs** for discovery.
|
||||
- Kind-10317 is the user grasp list (`g` tags, in preference order) — read-only
|
||||
input for hosting.
|
||||
- Anybody may open a PR on any announced repo; only the author may update it
|
||||
(1619); only the author or a maintainer may set status; merge is the
|
||||
maintainer's action.
|
||||
- "Patches and PRs to a repository SHOULD be sent to the relays specified in
|
||||
that repository's announcement" — i.e. the **base** repo's relays, always.
|
||||
## Step 2 — Dead feature removal (~500 L)
|
||||
|
||||
### 1.2 GRASP-06 facts (as ngit implements it — verified against ngit-cli)
|
||||
1. **Login/logout API** — `login`, `login_with_new_identity`,
|
||||
`login_with_nsec`, `login_with_bunker`, `logout`, `with_master_key`
|
||||
(`backend.rs:879-988,1537-1542`). UI only uses `create_identity` + keyring
|
||||
restore. ⚠️ `login_with_new_identity` stores unencrypted nsec — security
|
||||
footgun. **Keep** `extract_master_key` + the `bunker://` branch of
|
||||
`restore_session` (services older keyring entries). <- acceptable
|
||||
2. **`RepoStore::merge_pull_request` + `publish_applied_status`** — no merge
|
||||
action exists in the UI (`repo.rs:1122-1256`). <- acceptable
|
||||
3. **`RepoStore::publish_state`** — superseded by `Backend::push_repo_from`
|
||||
(`repo.rs:1070-1120`).
|
||||
4. **Annotation machinery** — `cover_note_of`/`labels_of`/`subject_of` (no
|
||||
callers) + `cover_notes`/`labels` fields + per-root DB query loop +
|
||||
`annotations_for` relay fetch. **Every kind-1624/1985 event currently
|
||||
triggers a full refresh of every open RepoStore for data nothing
|
||||
displays** (`repo.rs:52-56,339-362,489,531-563`).
|
||||
5. **`BackendEvent::Error` variant + `emit_error` + ~16 emission sites** —
|
||||
matched nowhere (consumers only match `SignerChanged`, `SignerRequired`,
|
||||
`PassphraseRequired`, `NostrUpdate`, `Synced`, `SyncProgress`,
|
||||
`Published`) (`backend.rs:64,67-74,1047-1050`). <- acceptable
|
||||
6. **`BackendEvent::Connected` + `connected` field + `is_connected()`** —
|
||||
emitted twice, never consumed (`backend.rs:49,83,154,1110-1111,1052-1055`).
|
||||
7. **`sync_progress` field + getter + `SyncProgress::channel` watch-loop
|
||||
math** — payload discarded; used only as a dumb refresh tick
|
||||
(`backend.rs:84,1057-1060,1227-1258`). <- acceptable
|
||||
8. **`Backend::subscribe`, `add_discovery_relays`, `publish_announcement`** —
|
||||
zero callers (`backend.rs:1123-1158,1388-1395`).
|
||||
9. **Write-only fields**: `RepoStore::refs` (`repo.rs:37-38,449`),
|
||||
`RepoListStore::set_author` (`repo_list.rs:135-140`),
|
||||
`Update::event_id` (`signed_nostr/src/update.rs:10`).
|
||||
10. **NIP-44 half of `UniversalSigner`** (`AsyncNip44` bounds,
|
||||
encrypt/decrypt plumbing, ~60 of 200 L) — app never touches DMs
|
||||
(`signed_nostr/src/signer.rs`). <- acceptable
|
||||
|
||||
- GRASP-06 servers expose a contributor namespace:
|
||||
`http(s)://<host>/prs/<contributor-npub>/<repo-id>.git` (input
|
||||
`ws://`/`wss://` base URLs normalize to `http(s)://`; npub in the URL, hex
|
||||
on the server's disk — a server detail). Anyone can push there; no
|
||||
announcement, no maintainer rights, no fork repo required.
|
||||
- The author's server is tried **first**; the base repo's announcement grasps
|
||||
still receive the same `refs/nostr/<event-id>` push as redundancy.
|
||||
- The PR event shape is unchanged; only *which URLs the `clone` tag lists*
|
||||
differs.
|
||||
## Step 3 — Library swaps (~600 L)
|
||||
|
||||
### 1.3 Consequences (locked decisions)
|
||||
1. **`signed_ui::DropdownButton` → `gpui_component::button::DropdownButton`**
|
||||
— present in the locked revision, same `new/button/dropdown_menu` surface
|
||||
(the local doc even says it matches). Migrate 3 call sites
|
||||
(`pull_requests.rs`, `repo_detail/mod.rs`), delete the ~200 L file. <- acceptable
|
||||
2. **`wire_number_input` → `SettingField::number_input`**
|
||||
(`settings_dialog.rs:691-761`, ~90 L) — you already import
|
||||
`NumberFieldOptions` from gpui-component's setting module. <- acceptable
|
||||
3. **Custom C-unquoting → `gix::quote::ansi_c::undo`**
|
||||
(`signed_git/src/lib.rs:1610-1683` + call sites, ~100 L) — already in the
|
||||
dep tree, octal/escape semantics identical. Keep tests as regression tests.
|
||||
4. **`ensure_origin` redundant refspec write** (`signed_git/src/lib.rs:523-530`)
|
||||
— `git remote add` creates `remote.origin.fetch` by default.
|
||||
5. **Two hand-rolled tab bars → gpui-component `TabBar`**
|
||||
(`repo_detail/mod.rs:2120-2265`, `new_pull_request.rs:1236-1305`, ~110 L) —
|
||||
`pull_request_detail.rs` already uses `TabBar` correctly (proves the fit). <- acceptable
|
||||
6. **CLI `merge_base` → `gix::Repository::merge_base`**
|
||||
(`signed_git/src/lib.rs:201-222`) — the codebase already uses the gix one
|
||||
in `pull_request_detail.rs:244`.
|
||||
7. **`image_cache` → `gpui::retain_all` or single-map LRU** — vendored copy
|
||||
with a `max_items` param every call site passes `MAX_IMAGES=128` to
|
||||
(`signed_ui/src/image_cache.rs`, 139 L). Delete the param + dual-structure
|
||||
LRU or use upstream unbounded cache.
|
||||
|
||||
- Publishing keeps today's event set and tag semantics. The fork changes only
|
||||
where the patch series is generated from; GRASP-06 changes only where the tip
|
||||
is pushed and advertised; suggestions change nothing on the wire.
|
||||
- We never publish a reference to the fork or to `/prs/` hosting beyond legal
|
||||
`clone` URLs.
|
||||
- The PR `clone` tag is fixed before signing (the `refs/nostr/<event-id>` ref
|
||||
name embeds the event id), so it carries the full candidate URL set
|
||||
(author `/prs/` URLs + base announcement clone URLs). Dead URLs are inert —
|
||||
patch events remain the truth — and ngit readers fail over across URLs.
|
||||
(ngit instead rebuilds the event per server to keep a single clone URL; we
|
||||
deliberately do not copy that.)
|
||||
## Step 4 — Dedup passes (~1,200 L)
|
||||
|
||||
1. **`PullRequestDetailView` → hold `Entity<DiffPane>`** instead of its ~200 L
|
||||
inline copy of `diff.rs` (`pull_request_detail.rs:310-552`). The other two
|
||||
consumers already do this.
|
||||
2. **Issue/PR detail shared sections** (~300 L): comments list, comment form,
|
||||
participants/labels sidebar, `sidebar_title` — extract into `helpers.rs`
|
||||
(`issue_detail.rs` vs `pull_request_detail.rs`).
|
||||
3. **Dialog scaffolding helper** (~150-200 L): 4 copies of `{busy, error}`
|
||||
state structs, verbatim error rows, identical
|
||||
`cx.spawn → close_dialog / show error` plumbing
|
||||
(`onboarding_dialog.rs`, `passphrase_dialog.rs`, `create_repo_dialog.rs`,
|
||||
`init_dialog.rs`, +2 more sites).
|
||||
4. **Triplicated debounce state machine → one helper** — `refreshing` /
|
||||
`refresh_dirty` / `debouncing` trio copied into `repo.rs`, `checkouts.rs`,
|
||||
`repo_list.rs` (~105 L).
|
||||
5. **signed_git helpers**: `push_main`/`push_all` twin bodies → one
|
||||
`push_refspecs`; git-CLI spawn boilerplate → one `git_output`;
|
||||
grasp→https rewrite → one fn; "try each mirror URL" loop → one helper.
|
||||
6. **grasp-list parsing ×3 → one helper** — `backend.rs:996-1012`,
|
||||
`backend.rs:1586-1618`, `grasp_servers.rs:240-265`.
|
||||
7. Smaller copies: ref-selector trigger ×2, count badge ×3 (use
|
||||
`signed_ui::CountBadge`), grasp-server editor duplicated in
|
||||
`settings_dialog.rs:408-614` vs `grasp_servers.rs` (~90 L), folder-picker
|
||||
prompts ×4, "add panel to dock Center" ×10, fork-label upstream lookup ×2,
|
||||
avatar+name row ×9 (→ one `user_row` helper in signed_ui).
|
||||
8. **`Backend::send` vs `publish_event`** — copy-pasted bodies differing only
|
||||
in `finalize_async` (`backend.rs:1293-1386`).
|
||||
|
||||
## Step 5 — Structural (do deliberately)
|
||||
|
||||
1. **`UniversalSigner` → enum** — verified: nostr-sdk 0.45 `Client` has no
|
||||
signer slot (external `SignerAuthenticator` by value at build time), so a
|
||||
swap-in-place wrapper IS needed — but only `Keys`/`NostrConnect` ever
|
||||
occur. Replace 200-L vtable (`InnerSigner` trait + `InnerSignerImpl<T>` +
|
||||
custom error + boxed futures) with
|
||||
`enum Signer { Keys(Keys), Connect(NostrConnect) }` in
|
||||
`Arc<RwLock<Signer>>` (~40 L). <- acceptable
|
||||
2. **`GitStore` single install** — `signed_state::init` installs empty root
|
||||
(`lib.rs:44`), immediately replaced by `desktop/main.rs:81`. Pass the root
|
||||
into `init`.
|
||||
3. **Error handling in signed_git**: `map_err(|e| anyhow!("{e}"))` → plain
|
||||
`?` (preserves source chain; `lib.rs:1787,1798,1845,1852`); blanket
|
||||
`.ok()`/`.unwrap_or_default()` → matched cases
|
||||
(`lib.rs:919,995,1761,364,682,1779`).
|
||||
4. **`ProfileStore` second flume channel → `WeakEntity` + `update()`**
|
||||
(~25 L; the results channel only exists to get back to main thread).
|
||||
5. **Unbounded `tasks` Vec growth** — only `repo.rs` prunes; `backend.rs`
|
||||
(~18 push sites), `checkouts.rs` (grows every 15-60 s poll cycle),
|
||||
`profile.rs` (2 per metadata event) accumulate finished handles forever.
|
||||
One-line `retain(|t| !t.is_ready())` per store.
|
||||
6. **Redundant `observe → cx.notify()` subscriptions**
|
||||
(`sidebar/mod.rs:81-88`, `repo_detail/mod.rs:354-355,1914-1921`) — this
|
||||
gpui revision auto-tracks entities read during render, so re-render-only
|
||||
observers are belt-and-suspenders. ⚠️ Verify before deleting; see Step 7.2.
|
||||
7. **`open_upstream` sleep-poll → `cx.observe`** (`repo_detail/mod.rs:1113-1152`)
|
||||
— 60×250 ms race-prone loop re-implementing the store's notify mechanism.
|
||||
8. **Checkouts map `Arc` removal** — accessors deep-clone anyway
|
||||
(`checkouts.rs:69-81`).
|
||||
9. Minor: `CheckoutRecord` manual `Default` → derive; `dock` re-export trim
|
||||
(27 items, ~10 used); `PixelAvatar.size` field with no setter → const;
|
||||
`SCAN_SKIPPED_DIRS` 1-element array → direct compare; `RepoAction` menu
|
||||
indirection → `on_click`; `Announcement::from_event` round-trip after
|
||||
building from typed data.
|
||||
|
||||
## Step 6 — Over-optimization (optional, unmeasured machinery)
|
||||
|
||||
1. **`HeaderCache`** (`repo_detail/mod.rs:113-123,1426-1461`) — keyed
|
||||
invalidation + 3 `Rc` layers memoizing 2 bech32 encodes + 2 `format!`s
|
||||
(~45 L). Compute inline.
|
||||
2. **Comment-body memoization** `contents: HashMap<EventId, SharedString>`
|
||||
(`issue_detail.rs`, `pull_request_detail.rs`) — caches one small alloc per
|
||||
render; convert inline.
|
||||
3. **`OBJECT_CACHE_BYTES = 64 MiB`** indiscriminately applied — scope to
|
||||
history-walk entry points or drop (`signed_git/src/lib.rs:770,859-863`).
|
||||
4. **`file_commit(…, include_description: bool)`** — boolean flag saving one
|
||||
alloc; split into named constructors or always include.
|
||||
5. **`signed_core` could drop its `gpui` dep** if `Announcement`'s
|
||||
`SharedString` fields became `String` (`model.rs:9-35`).
|
||||
6. **`build_state`** raw `Tag::parse(...).expect()` → typed `Nip34Tag::to_tag`
|
||||
(`signed_core/src/state.rs:7-18`).
|
||||
7. **`PixelAvatar` FNV/RNG stack** (~60 L) — deterministic-hashing requirement
|
||||
is legit; reconsider if `DefaultHasher` stability is acceptable.
|
||||
|
||||
## Step 7 — Clean verdicts + one correctness note (no action)
|
||||
|
||||
1. **Confirmed clean, keep as-is**: `signed_core` correctly uses
|
||||
`Nip34Tag::parse` / SDK builders (only the `u` tag is hand-parsed — SDK
|
||||
doesn't model it); `dock` crate is a genuine thin skin over
|
||||
`gpui_base::dock` renderer traits (keep; only trim re-exports); LMDB is
|
||||
justified (instant startup lists); `signed_git` test suite + `tempfile`
|
||||
healthy. Gossip/NIP-65 machinery serves only one login query — *consider*
|
||||
dropping if NIP-65 routing isn't on the roadmap.
|
||||
2. **Correctness note**: `IssuesView` / `PullRequestsView` /
|
||||
`IssueDetailView` / `PullRequestDetailView` never observe their
|
||||
`RepoStore` — refresh only works because this gpui revision auto-tracks
|
||||
render reads. Accidental; pinning a different gpui breaks them silently.
|
||||
Relevant to Step 5.6.
|
||||
|
||||
---
|
||||
|
||||
## 2. Workstream A — Fork-aware compare
|
||||
|
||||
### 2.1 Model
|
||||
|
||||
The panel keeps today's behavior as the default source and adds a second:
|
||||
|
||||
- **Checkout** (existing): both selectors list a user-picked local checkout's
|
||||
branches; git ops + tip push run in the checkout.
|
||||
- **Fork** (new): the flow runs against the **base repo's GitCache mirror**
|
||||
`P_base = GitStore::global(cx).cache().repo_path(&base_addr)` (ensured via
|
||||
`GitCache::ensure_clone(&base_addr, &base_clone_urls)` + `fetch_all`):
|
||||
- "Merge Into" lists `P_base` branches (`refs/remotes/origin/*`);
|
||||
- "Pull From" lists the chosen fork's branches, imported into `P_base`;
|
||||
- `merge-base`, range commits/diff, `format-patch`, and the tip push all run
|
||||
against `P_base` — both histories share one object store, and commit-diff
|
||||
rows work because fork commits live there.
|
||||
|
||||
### 2.2 Git mechanics (import namespace)
|
||||
|
||||
Fork heads are fetched into `P_base` under a private namespace:
|
||||
|
||||
```
|
||||
git -C P_base fetch <fork-clone-url> '+refs/heads/*:refs/fork/<owner-hex>/<sanitized-id>/*'
|
||||
```
|
||||
|
||||
- `refs/fork/…` keeps imported refs away from `refs/remotes/*` and
|
||||
`refs/heads/*`, so the repo browser, `repo_branches` and DWIM checkout never
|
||||
see them.
|
||||
- Fetch tries each announced `clone` URL until one works (`grasp://` →
|
||||
`https://` rewrite, `GIT_TERMINAL_PROMPT=0`), like `clone_repo` /
|
||||
`push_commit_ref`.
|
||||
- Switching fork or refreshing: prune the old `refs/fork/<owner>/<id>/*`
|
||||
prefix first (`git update-ref --stdin` fed by `for-each-ref`), then
|
||||
re-import. All-heads import in one fetch; subsequent branch switches within
|
||||
the same fork are offline.
|
||||
- Range work uses full refs: `merge_base(P_base,
|
||||
"refs/remotes/origin/<base>", "refs/fork/…/<compare>")`, then the existing
|
||||
`worktree_commit_range_commits`/`worktree_commit_range_diff` /
|
||||
`format_patch_between`. None of these touch the checkout state.
|
||||
- Base `main` and fork `main` are different refs: the "choose different
|
||||
branches" guard compares full refs, display names stay short.
|
||||
|
||||
### 2.3 Fork discovery
|
||||
|
||||
Candidates = `RepoListStore::global(cx).read(cx).announcements` (already
|
||||
deletion-filtered, latest-wins) where
|
||||
`Announcement::is_fork_of(base_addr, base_euc)`:
|
||||
|
||||
- `upstream.addr == Some(base_addr)` (the `u` tag — also covers permanent
|
||||
forks whose EUC changed), **or**
|
||||
- `euc == base announcement's euc` (shared earliest-unique-commit family),
|
||||
excluding the base repo itself.
|
||||
|
||||
Ordering (identity-coherent, ngit-style): **your own forks first** (30617
|
||||
owner == signed-in user), then other authors' related repos (same mechanics,
|
||||
marked, niche). Announcements without `clone` URLs are excluded (unfetchable).
|
||||
Restricting to your own forks only later is a one-line ownership filter.
|
||||
|
||||
### 2.4 Panel behavior
|
||||
|
||||
- Defaults mirror `apply_checkout`: base = announced `store.head` if present in
|
||||
mirror branches, else `main`, else first; compare = fork's `main`, else
|
||||
first fork branch.
|
||||
- `submit`: `format_patch_between(P_base, merge_base, compare_ref)`; published
|
||||
`branch-name` = compare short name; `push_from = Some(P_base)` (fork objects
|
||||
are there after import). Publishing itself is workstream B.
|
||||
- `open_commit_diff` uses `P_base` in fork mode.
|
||||
- Errors: no common ancestor → existing message; unreachable base mirror or
|
||||
fork → inline error; empty range → existing "no commits to propose".
|
||||
|
||||
---
|
||||
|
||||
## 3. Workstream B — GRASP-06 author hosting
|
||||
|
||||
Applies to **every** PR publish from a repo path that has the objects —
|
||||
checkout mode and fork mode alike. `RepoStore::open_pull_request` keeps its
|
||||
signature; internals change:
|
||||
|
||||
1. **Resolve author grasp servers** (new shared helper): latest kind-10317
|
||||
grasp list of the signed-in user from the local DB (`filters::grasp_list`,
|
||||
`g` tags in order) → **fallback to settings defaults**
|
||||
(`GraspServersSettings.default_servers` / `DEFAULT_GRASP_SERVERS`, the same
|
||||
source the create-repo dialogs use) when no list is published.
|
||||
2. **Build `/prs/` URLs**: `grasp_base_url(server) + "/prs/" + user_npub +
|
||||
"/" + base_repo_id + ".git"` (npub form, like ngit; `grasp_base_url` maps
|
||||
wss→https, ws→http).
|
||||
3. **`clone` tag** = dedup of `/prs/` URLs plus the current base-announcement
|
||||
clone URLs (order: `/prs/` first — the author's servers are the most likely
|
||||
to be alive and author-controlled).
|
||||
4. **Push loop** = author `/prs/` servers first (guaranteed writable — the
|
||||
point of GRASP-06), then the base announcement grasp servers (existing
|
||||
behavior), all `refs/nostr/<event-id>` from `push_from`. Best-effort;
|
||||
zero successes → existing `last_warning` banner; publishing always
|
||||
proceeds.
|
||||
|
||||
Effect: a repo announced with relays but no reachable grasp hosting still gets
|
||||
a downloadable tip (on the author's own hosting), and git-native clients
|
||||
(ngit, `git-remote-nostr`) can fetch Signed PR tips from the `clone` URL.
|
||||
|
||||
**1619 updates are out of scope for v1**: the update dialog is paste-only, so
|
||||
no repo path holds the new tip's objects. Deferred until the existing
|
||||
"local-checkout generation for the update-PR dialog" TODO lands; then push the
|
||||
new tip to the same `/prs/` set under the PR's stable ref
|
||||
(`refs/nostr/<root-pr-event-id>`, advanced per revision — convention to verify
|
||||
against ngit-grasp first).
|
||||
|
||||
---
|
||||
|
||||
## 4. Workstream C — Checkout associations & suggestions
|
||||
|
||||
Three tiers: **Remember → Auto-pick → Suggest**.
|
||||
|
||||
### 4.1 Remember (associations)
|
||||
|
||||
A local folder ↔ announced repo association comes from two sources:
|
||||
|
||||
- **Explicit** (persistent settings records `{path, addr, last_used}`):
|
||||
recorded when the repo header **Clone** action succeeds (addr known) and
|
||||
when a folder pick succeeds in the New PR panel (store addr known).
|
||||
- **Implicit** (derived, no persistence): among `LocalReposStore` scan results
|
||||
(settings `local_repos.scan_paths`), a repo whose
|
||||
- `origin` URL matches an announcement `clone` URL (compare host+path,
|
||||
ignoring scheme: ws/wss/http/https/grasp are equivalent transports of the
|
||||
same grasp URL), or
|
||||
- root commit equals the announcement EUC
|
||||
is a checkout of that announced repo.
|
||||
|
||||
Resolution order per repo: remembered (freshest first) ∪ scanned-matched,
|
||||
deduplicated by path, skipping missing directories.
|
||||
|
||||
### 4.2 Auto-pick (New PR panel prefill)
|
||||
|
||||
`open_new_pull_panel(…)` gains a suggested-checkout parameter, resolved by the
|
||||
caller from the association store:
|
||||
|
||||
- **Exactly one** checkout → auto-apply it: selectors populate, base =
|
||||
announced HEAD, compare = current branch, diff loads. The folder button
|
||||
becomes "Change…".
|
||||
- **Several** → a small folder combobox instead of the modal folder picker.
|
||||
- **None** → today's flow unchanged.
|
||||
- Successful manual folder picks are recorded back (learning).
|
||||
|
||||
### 4.3 Suggest (status + surfaces)
|
||||
|
||||
A small checkout-status computation (part of the association store), scoped to
|
||||
the bounded set of associated checkouts, on background threads:
|
||||
|
||||
- Triggers: app open, window focus (debounced ~5 s), `LocalReposStore` rescan,
|
||||
`BackendEvent::Synced`.
|
||||
- Per checkout: current branch; commits ahead of the base branch (announced
|
||||
HEAD name if present locally, else `main`, else first local branch — the
|
||||
same rule as `apply_checkout`), via `rev-list --count`; whether the user has
|
||||
an **open** PR from that branch on the target repo (author == me,
|
||||
`branch-name` tag == branch, fallback: tip `c` tag == local HEAD).
|
||||
- Result states: `ReadyToCreate { target, branch, ahead, base }` /
|
||||
`HasOpenPr { … }` / `Idle`.
|
||||
- Noise rules: only when ahead > 0 and branch ≠ base; nothing for dirty
|
||||
worktrees; one entry per target repo.
|
||||
|
||||
Surfaces:
|
||||
|
||||
| Surface | Shows | Dedupe data source | Scope |
|
||||
|---|---|---|---|
|
||||
| Repo **PR list** banner (`PullRequestsView`) | "branch `feature` is 3 commits ahead of `main` — Create pull request →" (opens prefilled New PR) | live open `RepoStore` (precise) | v1 |
|
||||
| **Sidebar** "Ready to contribute" group | row per `ReadyToCreate`: target repo, branch ↑N → opens target repo + prefilled New PR | v1: only targets with a live open store, else a local-DB query refreshed after a lazy per-target bootstrap activity sync; if the repo has no data yet, the group omits it (no false "ready") | v2 (after v1 proves out) |
|
||||
| Repo detail header chip | tiny `feature ↑3` on the repo whose checkout is ahead | as PR-list banner | optional |
|
||||
|
||||
The PullRequestsView banner reuses the existing dismissible `Alert` banner
|
||||
pattern already used for store errors/warnings.
|
||||
|
||||
---
|
||||
|
||||
## 5. Combined flow (fork mode, end to end)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User (New PR panel)
|
||||
participant P as Base mirror (GitCache)
|
||||
participant F as Fork grasp server
|
||||
participant A as Author grasp (GRASP-06 /prs/)
|
||||
participant B as Base repo grasps
|
||||
participant R as Nostr relays
|
||||
|
||||
U->>U: pick fork repo (u/EUC relation, yours first) + branch
|
||||
U->>P: ensure_clone(base) + fetch_all
|
||||
P-->>F: fetch +refs/heads/*:refs/fork/<owner>/<id>/*
|
||||
P-->>U: base branches (origin/*) + fork branches (refs/fork/…)
|
||||
U->>P: merge-base, range commits, range diff (Files/Commits tabs)
|
||||
U->>P: submit: format-patch merge-base..fork-ref
|
||||
U->>R: publish kind-1617 series (root + NIP-10 chain, ≤60 KB each)
|
||||
U->>R: sign kind-1618 (a=base, c=fork tip, merge-base, branch-name, clone=[/prs/…, base clone URLs], e=root patch, r=EUC)
|
||||
U->>A: push tip → refs/nostr/<event-id> (author servers, first)
|
||||
U->>B: push tip → refs/nostr/<event-id> (best-effort redundancy)
|
||||
U->>R: publish kind-1618
|
||||
Note over R: zero successful pushes → last_warning banner only
|
||||
```
|
||||
|
||||
Checkout mode is identical except the fork-import step; suggestions (workstream
|
||||
C) only add entry-point shortcuts into this flow.
|
||||
|
||||
---
|
||||
|
||||
## 6. Step-by-step implementation
|
||||
|
||||
Phases are ordered so each step lands on green: foundations first, then the
|
||||
publish-side change (benefits the existing checkout flow immediately), then
|
||||
the fork UI, then the UX layer. Every step compiles, passes its tests, and
|
||||
keeps existing behavior unchanged.
|
||||
|
||||
### Phase 0 — Foundations
|
||||
|
||||
#### Step 1 — `signed_core`: fork relation predicate
|
||||
|
||||
- File: `crates/signed_core/src/model.rs`.
|
||||
- Add `Announcement::is_fork_of(&self, base: &RepoAddr, base_euc:
|
||||
Option<&str>) -> bool`:
|
||||
`upstream.addr == Some(base)` OR (`base_euc` present AND `self.euc ==
|
||||
base_euc`), excluding self (same owner + id).
|
||||
- Tests: u-tag coordinate match; shared EUC match; permanent fork with
|
||||
different EUC matched via `u`; no-match; base-self exclusion.
|
||||
- Done when: predicate + tests green; used by Step 5.
|
||||
|
||||
#### Step 2 — `signed_git`: mirror/import primitives
|
||||
|
||||
- File: `crates/signed_git/src/lib.rs`.
|
||||
- `fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) ->
|
||||
Result<()>` — CLI `git fetch <url> <refspec>`, grasp:// → https rewrite,
|
||||
`GIT_TERMINAL_PROMPT=0`, try each URL until one works (last-error on all
|
||||
failing, like `clone_repo`).
|
||||
- `refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>>` —
|
||||
full refnames under `prefix` (`git for-each-ref --format=%(refname)`),
|
||||
sorted.
|
||||
- `delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()>` —
|
||||
collect via `for-each-ref`, delete via `git update-ref --stdin` lines.
|
||||
- `origin_url(workdir: &Path) -> Result<Option<String>>` (used by Step 7).
|
||||
- Tests (existing `file://` fixture infra): import under a target prefix;
|
||||
URL fallback to the working server; prefix listing; prefix deletion;
|
||||
origin URL read.
|
||||
- Done when: helpers + tests green.
|
||||
|
||||
### Phase 1 — GRASP-06 hosting (publish side)
|
||||
|
||||
#### Step 3 — author grasp-server resolution
|
||||
|
||||
- File: `crates/signed_state/src/backend.rs` (or a small new module).
|
||||
- `resolve_user_grasp_servers(cx, user) -> Vec<RelayUrl>`: latest kind-10317
|
||||
of `user` from the local DB (`filters::grasp_list`; latest event wins; `g`
|
||||
tags in order) → fallback to settings `GraspServersSettings`
|
||||
defaults/`DEFAULT_GRASP_SERVERS` when the user has no grasp list.
|
||||
- Refactor the create-repo/init dialogs to share it (optional, keeps one
|
||||
resolution path).
|
||||
- Tests: latest-wins selection; missing list falls back; `g` order preserved.
|
||||
- Done when: helper + tests green.
|
||||
|
||||
#### Step 4 — `open_pull_request` hosting
|
||||
|
||||
- File: `crates/signed_state/src/repo.rs` (`open_pull_request`, ~L657).
|
||||
- Pure helpers (unit-testable): `grasp06_prs_url(base_url: &str, npub:
|
||||
&str, repo_id: &str) -> String`; push-target assembly (author `/prs/`
|
||||
URLs first, then announcement grasp URLs; dedup).
|
||||
- `clone` tag = `/prs/` URLs (author npub from `Backend::current_user()`,
|
||||
repo id from `self.addr().identifier`) + base announcement clone URLs.
|
||||
- Push loop extended: existing per-relay loop stays; author servers push to
|
||||
the `/prs/` URL instead of the repo URL. Warning semantics unchanged.
|
||||
- Tests: URL building; target order; dedup. Behavioral coverage of the full
|
||||
publish is manual/e2e (see §8) until a harness exists.
|
||||
- Done when: checkout-mode PRs push to the author's grasp list
|
||||
(`/prs/<npub>/<repo-id>.git`) first and the `clone` tag carries those URLs;
|
||||
all-servers-fail still publishes with a warning.
|
||||
|
||||
### Phase 2 — Fork-aware compare
|
||||
|
||||
#### Step 5 — fork candidates
|
||||
|
||||
- File: `crates/workspace/src/views/repo_detail/new_pull_request.rs` (helper)
|
||||
or `crates/signed_state`.
|
||||
- `fork_candidates(cx) -> Vec<Announcement>`: filter
|
||||
`RepoListStore::global().announcements` with `is_fork_of` (Step 1); exclude
|
||||
empty `clone`; sort own forks (owner == current user) first, then others,
|
||||
each group by recency/name. Re-read each time the picker opens.
|
||||
- Done when: helper returns the expected ordering for a mixed list.
|
||||
|
||||
#### Step 6 — New PR panel fork mode
|
||||
|
||||
- File: `crates/workspace/src/views/repo_detail/new_pull_request.rs`.
|
||||
- State: `CompareSource { Checkout, Fork { announcement } }`; per-mode item
|
||||
sets for both selectors; `mirror_path`; fork branch list; full-ref
|
||||
base/compare tracking (display keeps short names).
|
||||
- `choose_fork(announcement)` + `prepare_fork` (mirror `choose_checkout` /
|
||||
`apply_checkout` async shape, `compare_generation` guard): ensure base
|
||||
mirror (`ensure_clone` + `fetch_all`), prune previous `refs/fork/…`
|
||||
prefix, import fork heads (`fetch_repo_refs`), list both ref sets
|
||||
(`refs_with_prefix`), populate selectors with defaults, `reload_compare`.
|
||||
- `reload_compare` / `submit` / `open_commit_diff` become mode-aware (path +
|
||||
base ref + compare ref resolution; `format_patch_between` and
|
||||
`push_from` on `P_base`; published `branch-name` = short name).
|
||||
- UI (compare bar): source control next to "Pull From" (local checkout /
|
||||
announced fork), fork-repo combobox (grouped, own forks first), refresh
|
||||
affordance, "Change…" back to checkout; loading spinner; inline errors.
|
||||
- Done when: checkout mode is byte-identical in behavior; fork mode shows
|
||||
base/fork selectors, Files/Commits tabs, commit diffs, and publishes with
|
||||
the correct tags (manual §8).
|
||||
|
||||
### Phase 3 — Checkout associations & suggestions
|
||||
|
||||
#### Step 7 — association store
|
||||
|
||||
- Files: `crates/settings` (extend the settings model like
|
||||
`local_repos.scan_paths` with remembered checkouts
|
||||
`{path, addr, last_used}`); new `crates/signed_state/src/checkouts.rs`
|
||||
(global store, `Arc` + debounce pattern from `LocalReposStore`/
|
||||
`RepoListStore`).
|
||||
- API: `associations_for(addr) -> Vec<PathBuf>` (remembered freshest-first ∪
|
||||
scanned-matched by origin URL/EUC via Step 2's `origin_url` +
|
||||
`signed_git::root_commit`; scheme-insensitive URL compare; dedup; skip
|
||||
missing dirs); `record(path, addr)`.
|
||||
- Recording hooks: repo header `clone_to_folder` success
|
||||
(`repo_detail/mod.rs`) and `choose_checkout` success (panel).
|
||||
- Tests: matching by origin URL (scheme variants), by EUC, no match; dedup
|
||||
and ordering.
|
||||
- Done when: associations resolve correctly and persist.
|
||||
|
||||
#### Step 8 — New PR prefill
|
||||
|
||||
- Files: `new_pull_request.rs` (`open_new_pull_panel` + `new`); callers
|
||||
`repo_detail/mod.rs` header and `pull_requests.rs`.
|
||||
- Entry param `suggested_checkout: Option<PathBuf>` (default `None`);
|
||||
panel applies it on construction when the folder still exists, else falls
|
||||
back to the empty state. When several candidates exist the caller passes
|
||||
the freshest and the panel offers the others through a folder combobox
|
||||
(new small control next to the source button).
|
||||
- Done when: opening New PR on a repo with a remembered/matched checkout
|
||||
never shows the folder dialog; manual picks get remembered.
|
||||
|
||||
#### Step 9 — status computation + PR-list banner
|
||||
|
||||
- Files: `checkouts.rs` (status states + triggers + debounce), workspace
|
||||
`pull_requests.rs` (banner), `new_pull_request.rs` (accepts the banner's
|
||||
"create" click by opening prefilled).
|
||||
- Status rules from §4.3; banner dedupe against the live open `RepoStore`
|
||||
(author + `branch-name`, fallback tip match, open status only).
|
||||
- Done when: after committing on an associated checkout and opening the
|
||||
target repo's PR list, the banner appears exactly when ahead > 0 and no
|
||||
open PR exists, and disappears after creating/merging/evening.
|
||||
|
||||
#### Step 10 — sidebar "Ready to contribute" group (v2, optional)
|
||||
|
||||
- File: `crates/workspace/src/views/sidebar/mod.rs` (+ `checkouts.rs`
|
||||
support).
|
||||
- Only list targets with reliable dedupe data (live open store, else a
|
||||
local-DB activity query refreshed after a lazy per-target bootstrap
|
||||
activity sync); omit everything uncertain. Clicking a row opens the target
|
||||
repo (`open_repo_panel`) + prefilled New PR.
|
||||
- Done when: rows appear without false "ready" entries (dedupe-uncertain
|
||||
targets omitted).
|
||||
|
||||
### Phase 4 — Docs & validation
|
||||
|
||||
#### Step 11 — documentation and final validation
|
||||
|
||||
- Update `docs/PR_FLOW.md`: fork compare path, GRASP-06 server set + clone
|
||||
tag, suggestion surfaces; the mermaid sequence in §5.
|
||||
- Update `docs/TODO.md`: tick "Fork-aware compare…", "GRASP-06 …"; add the
|
||||
checkout-suggestions item; keep deferred items (1619 update push, sidebar
|
||||
group, reading-side clone-URL fetch) explicit.
|
||||
- Run the manual validation checklist (§8) end to end.
|
||||
|
||||
---
|
||||
|
||||
## 7. Error handling & edge cases (all inline or warnings, as today)
|
||||
|
||||
- Base mirror unreachable / no base `clone` URLs → panel error in fork mode;
|
||||
checkout mode unaffected.
|
||||
- Fork unreachable / without `clone` URLs (excluded from candidates) → panel
|
||||
error.
|
||||
- No common ancestor → existing error (range flow needs shared history; Send
|
||||
Patch remains the fallback).
|
||||
- Author has no 10317 list and no default servers → GRASP-06 adds nothing;
|
||||
today's warning stands.
|
||||
- Author's grasp server does not implement `/prs/` → its push fails silently
|
||||
in the loop; its URL in `clone` is inert; base grasps still tried.
|
||||
- Fork branch deleted upstream / fork switched → prune prefix + re-import;
|
||||
generation guard discards stale compares.
|
||||
- Concurrency on `P_base` with the repo browser: we never checkout; git ref
|
||||
locks make overlapping fetches safe (same class as today's browser refresh).
|
||||
- Multiple checkouts of one repo → freshest first, "Change…"/combobox for the
|
||||
rest.
|
||||
- Branch renamed after a PR → dedupe falls back to tip-commit matching;
|
||||
otherwise a duplicate suggestion may appear once (accepted v1 tradeoff).
|
||||
- Suggestions never block UI; results arrive as `Arc` swaps.
|
||||
|
||||
## 8. Non-goals / deferred (explicitly out of scope)
|
||||
|
||||
- 1619 update hosting (depends on the local-checkout update-dialog TODO).
|
||||
- Paste/Send-Patch flow keeps no git push (no object store; patches = truth;
|
||||
no scratch-apply resurrection).
|
||||
- Reading side: fetching other clients' PR tips from `clone` URLs into the
|
||||
mirror (`ngit pr checkout` analog) — only needed for patch-less PRs.
|
||||
- Fork creation UI (Signed still cannot announce forks; they come from ngit or
|
||||
by publishing a clone) — fork candidates simply won't include non-existent
|
||||
ones.
|
||||
- GitHub-isms rejected: no fork-network browser, no per-fork PR pages, no
|
||||
fork identity in events, no "compare across forks" for strangers' branches
|
||||
beyond what is listed above.
|
||||
- The panel never auto-submits anything; suggestions only navigate and
|
||||
prefill.
|
||||
|
||||
## 9. Validation checklist (manual e2e)
|
||||
|
||||
1. Checkout mode regression: clone a repo to disk, branch + commit (external
|
||||
git), New PR → choose folder → diff/commits → Create → PR appears on the
|
||||
target repo's PR list; tip pushed to the author's `/prs/` server(s) from
|
||||
the 10317 list (fallback: defaults); `clone` tag lists `/prs/` URLs first.
|
||||
2. All grasp servers down/absent → PR still publishes; warning banner shows.
|
||||
3. Fork mode: with a fork announcement related to the base (own fork first,
|
||||
other author's fork listed), pick repo + branch → selectors, Files/Commits
|
||||
tabs, commit-diff rows correct; published 1618 carries `a` = base
|
||||
coordinate, `c` = fork tip, `merge-base` = fork point, `branch-name` =
|
||||
fork branch; tip fetchable from the advertised `/prs/` URL via a plain
|
||||
`git fetch`.
|
||||
4. Prefill: reopen New PR for the same repo → folder auto-chosen, selectors
|
||||
populated; "Change…" works.
|
||||
5. Banner: with the repo's PR list open and an associated checkout ahead with
|
||||
no open PR → banner appears; disappears after publishing a PR, after
|
||||
merging, and when the branch is even.
|
||||
6. Interop: an ngit/git client fetches a Signed PR's tip from the `/prs/`
|
||||
clone URL (requires a GRASP-06-enabled server).
|
||||
|
||||
## 10. Implementation log (2026-09-03, branch `feat/fork`)
|
||||
|
||||
All steps below landed with unit tests; `cargo test` across `signed_core`
|
||||
(44), `signed_git` (61), `signed_state` (15), `workspace` (14) and
|
||||
`settings` (9) is green, and `cargo check` on the whole workspace passes.
|
||||
The manual e2e checklist above still needs a real GRASP-06 server run.
|
||||
|
||||
- **Step 1** — `Announcement::is_fork_of` (`signed_core::model`) + 4 tests.
|
||||
- **Step 2** — `signed_git`: `fetch_repo_refs`, `refs_with_prefix`,
|
||||
`delete_refs_with_prefix`, `origin_url`, `GitCache::root()` + 4 tests
|
||||
(import/list/prune against `file://` fixtures incl. URL fallback).
|
||||
- **Step 3** — backend grasp-list resolution: `grasp_list_servers`,
|
||||
`latest_grasp_list_servers`, `user_grasp_list_servers` (DB query, latest
|
||||
wins) + `grasp06_prs_url` and `pr_clone_urls` (author-first, dedup) + 4
|
||||
tests. `signed_state` gained a `settings` dependency for the defaults
|
||||
fallback.
|
||||
- **Step 4** — `RepoStore::open_pull_request` (signature unchanged):
|
||||
resolves the author's grasp servers (10317 → settings defaults) inside
|
||||
the publish task, builds the `clone` tag from `/prs/` URLs first, pushes
|
||||
author `/prs/` targets before the base announcement's servers, deduped;
|
||||
all-fail keeps the `last_warning` banner. 1619 updates untouched
|
||||
(deferred, as planned).
|
||||
- **Step 5** — `fork_candidates` ordering helper + 2 tests (own forks
|
||||
first; base/unrelated/no-clone excluded; EUC-less base still matches via
|
||||
`u`).
|
||||
- **Step 6** — New PR panel fork mode: `ForkCompare` state, `choose_fork`/
|
||||
`apply_fork` (mirror `ensure_clone` → prune `refs/fork` → import → list
|
||||
both ref sets), mode-aware `base_ref`/`compare_ref`/`work_path` used by
|
||||
`reload_compare`/`submit`/`open_commit_diff`, stale-result guard,
|
||||
refresh-by-re-picking + refresh button, and a "Source" picker menu
|
||||
(checkout rows + forks) replacing the folder button. Checkout mode stays
|
||||
byte-identical in behavior. Deviations from the plan: selectors and the
|
||||
source picker keep one shared layout (no separate fork-repo combobox —
|
||||
the source menu lists forks grouped own-first, matching the ordering
|
||||
requirement); `IconName::GitBranch` does not exist upstream so fork rows
|
||||
use the project's `CustomIconName::GitBranch`.
|
||||
- **Step 7** — settings `CheckoutRecord`/`CheckoutsSettings` group + new
|
||||
`signed_state::checkouts::CheckoutsStore` global (observe settings /
|
||||
local scan / announcements; debounced, coalesced, Arc-swapped):
|
||||
scheme-insensitive `same_repo_url`, `resolve_associations` (remembered
|
||||
freshest-first ∪ scanned origin/EUC matches, dedup, mirror-cache paths
|
||||
excluded), `record()`, per-repo `request_statuses`/`statuses_of` with
|
||||
`CheckoutStatus` (branch/head/base/ahead; dirty and detached checkouts
|
||||
never suggested; 15 s poll while any PR list is open). 7 tests.
|
||||
Deviations: settings records store the address as a string (the settings
|
||||
crate stays free of nostr types); mirror exclusion uses the cache root
|
||||
(new `GitCache::root()`); statuses are computed per requested repo with
|
||||
the announced HEAD supplied by the open list panel rather than from a
|
||||
30618 DB query.
|
||||
- **Step 8** — New PR panel prefills the freshest associated checkout on
|
||||
construction (no folder dialog); `apply_folder_path` applies a given
|
||||
path; successful folder picks and header clones are recorded back; the
|
||||
Source menu lists associated checkouts (checked when applied) plus
|
||||
"Choose another folder…". Deviations: instead of a separate folder
|
||||
combobox, the alternatives live in the Source menu (fewer controls, same
|
||||
outcome); `open_new_pull_panel` needed no signature change because the
|
||||
panel reads the association store itself.
|
||||
- **Step 9** — "ready to contribute" banner: `RepoDetailView` requests
|
||||
the statuses while the repository panel is open (re-requested when the
|
||||
announced HEAD lands or changes) and renders the banner under the repo
|
||||
header; the first ready checkout not covered by an open PR of the
|
||||
signed-in user (`branch-name`, fallback `c`-tag tip) and not dismissed
|
||||
(per-panel dismissal set) is offered with a Create button opening the
|
||||
prefilled panel. The dedupe predicate is the tested
|
||||
`pr_proposes_checkout` in `signed_state::checkouts`. Deviation from the
|
||||
plan: the surface is the repository panel (not the PR-list panel, as the
|
||||
user requested after v1; the PR list keeps only its error/warning
|
||||
banners), and there is no window-focus trigger (no precedent in the
|
||||
codebase; the 15 s poll plus open/rescan/settings triggers cover the
|
||||
plan's "done when" cases).
|
||||
- **Step 10** — deferred (v2, optional), per plan; the banner is the v1
|
||||
surface.
|
||||
- **Step 11** — `docs/PR_FLOW.md` rewritten for the current panel flow
|
||||
(fork import, GRASP-06 hosting, suggestions, deferred items explicit);
|
||||
`docs/TODO.md` updated; this log added. Manual e2e (§9) not yet run
|
||||
against a live GRASP-06 server.
|
||||
- **Fix (after e2e, user report)** — creating a repository left the
|
||||
project only inside the app's GitCache mirror: the announcement, state
|
||||
event and push happened, but the folder chosen in the Create Repository
|
||||
dialog was just remembered as a settings default. `Backend::
|
||||
create_repository` now also materializes a working copy at
|
||||
`<folder>/<sanitized-name>` (cloned from the mirror via a `file://` URL
|
||||
so it shares the announced history exactly, then `origin` re-pointed at
|
||||
the first grasp server through the new `signed_git::set_origin`), and
|
||||
the dialog records it as a checkout (`CheckoutsStore`, so the New PR
|
||||
panel pre-fills it), opens it in the system file manager and opens the
|
||||
repository panel. Materialization runs before any event is published,
|
||||
so a failure aborts creation cleanly with nothing announced. Two new
|
||||
`signed_git` tests (`set_origin_creates_or_replaces_the_remote`,
|
||||
`working_copy_cloned_from_the_mirror_matches_head_and_origin`).
|
||||
- **Add (after e2e, user request)** — "ready to push" watch for the
|
||||
user's own repositories: local commits made in a checkout (external
|
||||
git) surface as a **sidebar badge** on the repository row (a
|
||||
`CountBadge` with the unpushed commit count) and, when the repository
|
||||
panel is open, as an info **banner with a Push button**. The
|
||||
`CheckoutsStore` gains a second status family (`request_push_statuses`/
|
||||
`push_statuses_of`): per checked-out branch it refreshes the remote
|
||||
view (`git fetch` of the checkout's origin, offline-tolerant) and
|
||||
counts `origin/<branch>..<branch>` (`origin/HEAD` for branches the
|
||||
remote does not have yet); dirty/detached checkouts are skipped like
|
||||
the PR suggestions. Poll cadence: 15 s while a repository panel is
|
||||
open (`status_requested`), 60 s for the sidebar-only background watch;
|
||||
request sets are cleared on signer change. The repo panel's
|
||||
ready-to-contribute banner now applies only to repositories of other
|
||||
authors — owned repositories get the push banner instead, whose Push
|
||||
action calls the new `Backend::push_checkout` (shared body with the
|
||||
existing mirror-based `push_repository`): publishes a fresh 30618
|
||||
state event (keeping the announced `HEAD` branch when the checkout is
|
||||
on a side branch) then pushes every branch and tag to the announced
|
||||
grasp servers. New test
|
||||
`checkout_push_status_counts_unpushed_commits_only`. The mirror's file
|
||||
browser stays a snapshot (new commits appear after a branch switch),
|
||||
like the rest of the browser.
|
||||
- **Fix (after e2e, user report)** — a push warning "cannot lock ref
|
||||
'refs/heads/main': is at X but expected Y" (server-side compare-and-
|
||||
swap rejection, `incorrect old value provided`). Reproduced locally:
|
||||
two concurrent plain pushes of the *same* ref from the same base make
|
||||
the loser fail exactly this way — the app can race itself when two
|
||||
push sources for one repository run at once (two panels of the same
|
||||
repo, or the banner Push racing the header's Republish; each guard was
|
||||
per-view only). Fix: pushes are now single-flight per repository in
|
||||
`Backend::push_repo_from` via an `Arc<Mutex<HashSet<RepoAddr>>>` guard
|
||||
(`PushGuard`, RAII: the lock is released on completion, on error and on
|
||||
task cancellation alike); a second concurrent push fails fast with
|
||||
"A push to this repository is already in progress" instead of racing.
|
||||
Racing an external `git push` against the same server remains possible
|
||||
(benign: the ref converges; the loser logs a warning only).
|
||||
- **Fix (after e2e, user report)** — after a successful push the
|
||||
repository panel's commit list stayed on the old commit (even across
|
||||
restarts): the browser reads the GitCache mirror, and a fetch never
|
||||
moves a mirror's *local* branches — `origin/main` advanced while local
|
||||
`main` (what the commit list walks) stayed behind. ngit/nak never hit
|
||||
this because they operate on real clones the user `git pull`s; nak also
|
||||
publishes the updated 30618 state *before* each push, which Signed
|
||||
already did. Fixes, mirroring a `git pull --ff-only` on the browser
|
||||
clone: new `signed_git::fast_forward_branches(workdir)` (per local
|
||||
branch, when it is an ancestor of its `refs/remotes/origin/*`
|
||||
counterpart: the checked-out branch is merged so its worktree follows,
|
||||
dirty worktrees and local-only commits are never touched; returns
|
||||
whether anything moved); `RepoDetailView::load_repo`'s background
|
||||
refresh fast-forwards after `fetch_all` and rebuilds the explorer,
|
||||
previews and commit list (`reload_worktree`) when anything moved;
|
||||
`push_unpushed_checkout` reloads the mirror on success so an owned
|
||||
repo's pushed commit appears immediately; `ensure_origin` now also
|
||||
configures the standard `remote.origin.fetch` refspec (create-flow
|
||||
mirrors otherwise never map heads on fetch). New test
|
||||
`fast_forward_branches_moves_the_mirror_and_keeps_local_work`; the
|
||||
remote-only-branch limitation stays (a branch the mirror has never
|
||||
checked out is not listed), as documented.
|
||||
## Suggested order of operations
|
||||
|
||||
1. Step 1 + Step 2 first — pure deletions, verify with `cargo check` +
|
||||
`cargo test`.
|
||||
2. Step 3 (library swaps) next — mechanical, mostly call-site migrations.
|
||||
3. Step 4 (dedup) — largest line savings.
|
||||
4. Step 5 (structural) — deliberate, one at a time.
|
||||
5. Step 6 (over-optimization) — optional, only if you want the extra ~110 L.
|
||||
|
||||
Each step below is self-contained; tick what you want done.
|
||||
|
||||
- [ ] Step 1 — zero-risk deletions
|
||||
- [ ] Step 2 — dead feature removal
|
||||
- [ ] Step 3 — library swaps
|
||||
- [ ] Step 4 — dedup passes
|
||||
- [ ] Step 5 — structural changes
|
||||
- [ ] Step 6 — over-optimization cleanup
|
||||
|
||||
Reference in New Issue
Block a user