Files
signed/docs/PLAN.md
T
2026-09-03 15:22:37 +07:00

658 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PLAN — PR contribution flows: fork compare + GRASP-06 hosting + checkout suggestions
> **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.
---
## 1. Protocol grounding (what we may and may not do)
### 1.1 NIP-34 facts used by this plan
- 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 16301633 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.
### 1.2 GRASP-06 facts (as ngit implements it — verified against ngit-cli)
- 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.
### 1.3 Consequences (locked decisions)
- 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.)
---
## 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.