update pull request
This commit is contained in:
+584
@@ -0,0 +1,584 @@
|
||||
# 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 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.
|
||||
|
||||
### 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.
|
||||
Reference in New Issue
Block a user