This commit is contained in:
2026-09-10 10:23:57 +07:00
parent 68dbc5a731
commit a2ae86dae2
5 changed files with 304 additions and 649 deletions
Generated
+10
View File
@@ -1690,6 +1690,15 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "diffy"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e3dc2f773b6aaa63b1a7684b8589f670a8a0146a510b74d23a401c882364b49"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -7958,6 +7967,7 @@ name = "signed_git"
version = "0.1.0-alpha"
dependencies = [
"anyhow",
"diffy",
"gix",
"gix-worktree",
"gix-worktree-state",
+1
View File
@@ -12,6 +12,7 @@ gix = { workspace = true, features = ["revision", "blob-diff"] }
gix-worktree = "0.56"
gix-worktree-state = "0.34"
anyhow.workspace = true
diffy = "0.5"
[dev-dependencies]
tempfile = "3"
+134 -282
View File
@@ -4,6 +4,8 @@ use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{Context, Result, bail};
use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
use diffy::{Hunk, Line};
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
use gix::interrupt::IS_INTERRUPTED;
use gix::progress::Discard;
@@ -1596,24 +1598,145 @@ fn tree_diff(
}
/// Parse `git format-patch` output, a single patch or a series.
///
/// Backed by [`diffy::patch_set`], which implements git's extended diff format:
/// `diff --git` headers, rename and copy detection, binary detection, and
/// C-style quoted or octal-escaped paths.
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
let lines: Vec<&str> = patch.lines().collect();
let mut files = Vec::new();
let mut i = 0;
// `PatchSet` reports an error when the input holds no patch at all,
// while a patch without git diff sections is simply empty here.
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
return Ok(CommitDiff { files: Vec::new() });
}
while i < lines.len() {
let Some(header) = lines[i].strip_prefix("diff --git ") else {
i += 1;
continue;
};
let (file, next) = parse_diff_section(header, &lines, i + 1)?;
files.push(file);
i = next;
let mut files = Vec::new();
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
files.push(file_diff(file?)?);
}
Ok(CommitDiff { files })
}
/// The [`FileDiff`] of one parsed file patch.
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
// The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path
// component is dropped, the same way `git apply -p1` does.
// Rename and copy paths come from their own headers, unprefixed.
let stripped;
let operation = match file.operation() {
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
operation => {
stripped = operation.strip_prefix(1);
&stripped
}
};
let (path, old_path, status) = match operation {
FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added),
FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted),
FileOperation::Modify { modified, .. } => (modified.as_ref(), None, DiffStatus::Modified),
FileOperation::Rename { from, to } => {
(to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed)
}
FileOperation::Copy { from, to } => (to.as_ref(), Some(from.as_ref()), DiffStatus::Copied),
};
let mut insertions = 0usize;
let mut deletions = 0usize;
let mut hunks = Vec::new();
let patch = file.patch();
if let Some(text) = patch.as_text() {
for hunk in text.hunks() {
let hunk = hunk_diff(hunk);
insertions += hunk
.lines
.iter()
.filter(|line| line.kind == DiffLineKind::Addition)
.count();
deletions += hunk
.lines
.iter()
.filter(|line| line.kind == DiffLineKind::Deletion)
.count();
hunks.push(hunk);
}
}
Ok(FileDiff {
path: path.to_owned(),
old_path: old_path.map(str::to_owned),
status,
insertions,
deletions,
binary: patch.is_binary(),
hunks,
})
}
/// The [`DiffHunk`] of one parsed hunk, including the line number of every line.
///
/// `diffy` reports only the hunk header ranges. The per-line numbers are
/// counted from them the way the header encodes them: context lines advance
/// both sides, deletions only the old, insertions only the new.
fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk {
let old_range = hunk.old_range();
let new_range = hunk.new_range();
let mut old = old_range.start() as u32;
let mut new = new_range.start() as u32;
let mut lines = Vec::with_capacity(hunk.lines().len());
for line in hunk.lines() {
let (kind, text) = match line {
Line::Context(text) => (DiffLineKind::Context, *text),
Line::Delete(text) => (DiffLineKind::Deletion, *text),
Line::Insert(text) => (DiffLineKind::Addition, *text),
};
let (old_no, new_no) = match kind {
DiffLineKind::Context => {
let numbers = (Some(old), Some(new));
old += 1;
new += 1;
numbers
}
DiffLineKind::Addition => {
let number = Some(new);
new += 1;
(None, number)
}
DiffLineKind::Deletion => {
let number = Some(old);
old += 1;
(number, None)
}
};
lines.push(DiffLine {
kind,
old: old_no,
new: new_no,
text: line_text(text),
});
}
DiffHunk {
old_start: old_range.start() as u32,
old_lines: old_range.len() as u32,
new_start: new_range.start() as u32,
new_lines: new_range.len() as u32,
lines,
}
}
/// The content of a parsed line without its line ending.
///
/// `diffy` keeps the trailing `\n`, the way `str::lines` splits it off.
fn line_text(text: &str) -> String {
let text = text.strip_suffix('\n').unwrap_or(text);
text.strip_suffix('\r').unwrap_or(text).to_owned()
}
/// Commits of a `git format-patch` output, a single patch or a series.
///
/// Entries appear in patch order, oldest first as `git format-patch` produces them.
@@ -1695,277 +1818,6 @@ fn strip_patch_prefix(subject: &str) -> String {
}
}
/// Parse one file's diff section.
///
/// Returns the section and the index of the first unconsumed line.
fn parse_diff_section(header: &str, lines: &[&str], start: usize) -> Result<(FileDiff, usize)> {
let (header_old, header_new) = header_paths(header)?;
// The `---` and `+++` lines name the two sides unambiguously.
// The `diff --git` header cannot distinguish spaces in paths.
// Fall back to the header for sections without them, pure renames and mode changes.
let mut old_path = header_old;
let mut new_path = header_new;
let mut status = DiffStatus::Modified;
let mut binary = false;
let mut hunks = Vec::new();
let mut insertions = 0usize;
let mut deletions = 0usize;
let mut i = start;
while i < lines.len() {
let line = lines[i];
// The next file's section starts at this line.
if line.starts_with("diff --git ") {
break;
}
i += 1;
if line.starts_with("@@ -") {
let (hunk, next) = parse_hunk(lines, i - 1)?;
i = next;
insertions += hunk
.lines
.iter()
.filter(|line| line.kind == DiffLineKind::Addition)
.count();
deletions += hunk
.lines
.iter()
.filter(|line| line.kind == DiffLineKind::Deletion)
.count();
hunks.push(hunk);
} else if let Some(rest) = line.strip_prefix("--- ") {
if rest == "/dev/null" {
status = DiffStatus::Added;
} else {
old_path = diff_line_path(rest, "a/")?;
}
} else if let Some(rest) = line.strip_prefix("+++ ") {
if rest == "/dev/null" {
status = DiffStatus::Deleted;
} else {
new_path = diff_line_path(rest, "b/")?;
}
} else if line.starts_with("new file mode ") {
status = DiffStatus::Added;
} else if line.starts_with("deleted file mode ") {
status = DiffStatus::Deleted;
} else if line.starts_with("copy from ") {
status = DiffStatus::Copied;
} else if line.starts_with("rename from ") {
status = DiffStatus::Renamed;
} else if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") {
binary = true;
// A literal binary patch may follow.
// Skip it without consuming the next section's header.
while i < lines.len() && !lines[i].starts_with("diff --git ") {
i += 1;
}
break;
}
// Everything else, index, mode and similarity lines, is ignored.
}
Ok((
FileDiff {
path: new_path,
old_path: matches!(status, DiffStatus::Renamed | DiffStatus::Copied)
.then_some(old_path),
status,
insertions,
deletions,
binary,
hunks,
},
i,
))
}
/// Parse one hunk, the `@@ -a,b +c,d @@` header plus every body line.
///
/// Returns the hunk and the index of the first unconsumed line.
fn parse_hunk(lines: &[&str], start: usize) -> Result<(DiffHunk, usize)> {
let (old_start, old_lines, new_start, new_lines) = hunk_header(lines[start])?;
let mut diff_lines = Vec::new();
let mut old = old_start;
let mut new = new_start;
let mut i = start + 1;
while i < lines.len() {
let line = lines[i];
let Some(kind) = line_prefix_kind(line) else {
break;
};
i += 1;
// Context lines advance both counters.
// Deletions advance only the old counter, additions only the new one.
// Every line then carries its real number in both versions.
let (old_no, new_no) = match kind {
DiffLineKind::Context => {
let numbers = (Some(old), Some(new));
old += 1;
new += 1;
numbers
}
DiffLineKind::Addition => {
let number = Some(new);
new += 1;
(None, number)
}
DiffLineKind::Deletion => {
let number = Some(old);
old += 1;
(number, None)
}
};
diff_lines.push(DiffLine {
kind,
old: old_no,
new: new_no,
text: line[1..].to_owned(),
});
}
Ok((
DiffHunk {
old_start,
old_lines,
new_start,
new_lines,
lines: diff_lines,
},
i,
))
}
/// The kind of a hunk body line, from its first character.
///
/// Lines outside a hunk, headers, `\ No newline...` and the next section, yield `None`.
fn line_prefix_kind(line: &str) -> Option<DiffLineKind> {
match line.as_bytes().first()? {
b' ' => Some(DiffLineKind::Context),
b'+' => Some(DiffLineKind::Addition),
b'-' => Some(DiffLineKind::Deletion),
_ => None,
}
}
/// Parse a unified-diff hunk header, `@@ -a,b +c,d @@`.
///
/// Omitted line counts default to 1.
fn hunk_header(header: &str) -> Result<(u32, u32, u32, u32)> {
let rest = header
.strip_prefix("@@ ")
.context("malformed hunk header")?;
let (old_spec, rest) = rest.split_once(' ').context("malformed hunk header")?;
let new_spec = rest.split_once(' ').map(|(new, _)| new).unwrap_or(rest);
fn parse(spec: &str) -> Result<(u32, u32)> {
let spec = spec.strip_prefix(['-', '+']).unwrap_or(spec);
let (start, count) = match spec.split_once(',') {
Some((start, count)) => (start, count.parse::<u32>()?),
None => (spec, 1),
};
Ok((start.parse::<u32>()?, count))
}
let (old_start, old_lines) = parse(old_spec)?;
let (new_start, new_lines) = parse(new_spec)?;
Ok((old_start, old_lines, new_start, new_lines))
}
/// The old and new paths of a `diff --git a/X b/Y` header.
fn header_paths(header: &str) -> Result<(String, String)> {
if header.starts_with('"') {
// Quoted paths include the `a/` / `b/` prefix inside the quotes.
let (old, rest) = take_quoted(header).context("unterminated quoted path")?;
let rest = rest.trim_start();
let new = if rest.starts_with('"') {
take_quoted(rest).context("unterminated quoted path")?.0
} else {
rest.split_whitespace().next().unwrap_or(rest)
};
let old = old
.strip_prefix("a/")
.context("old path without `a/` prefix")?;
let new = new
.strip_prefix("b/")
.context("new path without `b/` prefix")?;
Ok((unquote_path(old)?, unquote_path(new)?))
} else {
let (old, rest) = header
.rsplit_once(" b/")
.context("malformed diff --git header")?;
let old = old
.strip_prefix("a/")
.context("old path without `a/` prefix")?;
Ok((old.to_owned(), rest.to_owned()))
}
}
/// The path of a `--- a/X` or `+++ b/Y` line.
fn diff_line_path(line: &str, prefix: &str) -> Result<String> {
let line = line.trim_end_matches('\t');
if line.starts_with('"') {
let (path, _) = take_quoted(line).context("unterminated quoted path")?;
let path = path
.strip_prefix(prefix)
.context("diff line path without `a/` or `b/` prefix")?;
unquote_path(path)
} else {
Ok(line
.strip_prefix(prefix)
.context("diff line path without `a/` or `b/` prefix")?
.to_owned())
}
}
/// The content of a git C-style quoted path and the rest of the input.
/// The path spans the opening `"`, escaped content and closing `"`.
///
/// `None` if unterminated.
fn take_quoted(input: &str) -> Option<(&str, &str)> {
let mut end = 1; // byte after the opening quote
let mut rest = &input[1..];
while let Some(ch) = rest.chars().next() {
let len = ch.len_utf8();
match ch {
'\\' => {
// Consume the escaped character too, it may be multi-byte.
let escaped = rest[len..].chars().next()?;
let consumed = len + escaped.len_utf8();
end += consumed;
rest = &rest[consumed..];
}
'"' => return Some((&input[1..end], &input[end + len..])),
_ => {
end += len;
rest = &rest[len..];
}
}
}
None
}
/// Undo git's C-style path quoting, `\NNN` octal escapes, `\"` and `\\`.
///
/// Delegates to gitoxide's C-style quote implementation, `gix::quote::ansi_c::undo`.
/// It expects the surrounding double quotes, which are re-added around the interior.
fn unquote_path(path: &str) -> Result<String> {
if !path.contains('\\') {
return Ok(path.to_owned());
}
let quoted = format!("\"{path}\"");
let (unquoted, _) = gix::quote::ansi_c::undo(gix::bstr::BStr::new(quoted.as_bytes()))
.map_err(|e| anyhow::anyhow!("malformed quoted path: {e}"))?;
String::from_utf8(unquoted.into_owned().to_vec()).context("invalid UTF-8 in quoted path")
}
/// Collects the hunks of one blob diff while tracking per-line numbers.
struct HunkCollector<'a> {
hunks: &'a mut Vec<DiffHunk>,
+4 -1
View File
@@ -203,7 +203,10 @@ impl RepoListStore {
announcement || deletion
}
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
// Only a completed sync refreshes the list.
// Progress ticks would re-scan the whole database several times
// per sync to reveal entries incrementally.
BackendEvent::Synced => true,
_ => false,
};
+155 -366
View File
@@ -1,4 +1,4 @@
# Backend re-architecture: findings and plan
# Backend re-architecture: findings and outcome
This is a follow-up to an initial architecture review. It re-checks every claim
against the **actual `nostr`/`nostr-sdk` source pinned by `Cargo.lock`**
@@ -14,6 +14,12 @@ Scope: `crates/signed_nostr`, `crates/signed_state`, `crates/signed_core`,
`crates/signed_git`, and `crates/workspace` (the actual call sites of the
backend, audited for business-logic flaws and redundant conversions).
**Status: complete.** All 14 items of the plan are implemented and verified;
the compact record is the **Outcome** section at the end. Sections 117 are
kept as the analysis each change was based on — they describe the code as it
was *before* the change, so read them as rationale, not as current
documentation.
## Summary of the ask
1. Never call `fetch_events`. Bootstrap only via `subscribe`/`sync` (negentropy), read from `client.database()`.
@@ -30,17 +36,6 @@ Each is addressed below with concrete file:line references and a verified replac
## 1. `fetch_events` — one call site, and it should go too
> **Status: done.** `bootstrap_user` now calls `sync_bootstrap_only` (the
> same helper `Backend::sync_bootstrap` already used) against
> `filters::grasp_list(public_key)`, then reads the result back out through
> the existing `user_grasp_list_servers` query helper instead of hand-rolling
> a second `BTreeSet<Event>``Vec<Event>` collect. `client.add_relay(url)`
> no longer round-trips through `.as_str()`, and connects with
> `.and_connect()` (see §8) instead of a trailing pool-wide `client.connect()`.
> `grep -rn "fetch_events" crates/` now returns nothing in the whole
> workspace. `cargo check --workspace`, `cargo clippy -p signed_state`, and
> `cargo test -p signed_state` (24 tests, unchanged) all pass.
```
grep -rn "fetch_events" crates/
crates/signed_state/src/backend.rs:1065
@@ -122,41 +117,6 @@ everywhere, no exceptions.
## 2. The "send an event" functions — there are 8, there should be roughly 2
> **Status: done.** `Backend::send`, `Backend::publish_event`, `Backend::publish_task`,
> `Backend::send_fire_and_forget` and the free fn `broadcast_event` are all
> deleted. In their place: `require_relay_accepted(output, event)` (the one
> "empty success ⇒ Err" check, `pub(crate)` so `repo.rs` can use it too) and
> `Backend::announce_published(event, cx)` (one line, emits
> `BackendEvent::Published`). Every call site now calls
> `client.send_event(&event).broadcast()` directly — the `.broadcast()` is
> the additive gossip-bypass from §4, added here since every one of these
> call sites already has a well-defined target (the relays this app
> explicitly added). `RepoStore::send` is also gone; its 4 identical
> one-shot callers (`open_issue`, `reply`, `set_status`,
> `publish_applied_status`) now call a private `RepoStore::publish` that
> does the same sign+send+check+`last_error` bookkeeping — kept as **one**
> small store-local helper rather than inlining the same ~20 lines 4 times,
> since all 4 call sites have byte-for-byte identical post-conditions (this
> is a deliberate, narrow exception to "delete `RepoStore::send`";
> `stage_event_on_relay` was already the same shape of exception before this
> change). The 3 call sites with genuinely divergent control flow
> (`open_pull_request`, `update_pull_request`, `publish_patch_series`) now
> call `client.send_event(...)`/`require_relay_accepted` directly inline,
> fixing the inconsistent error surfacing this section originally flagged
> (all three now set `last_error` on failure, like every other `RepoStore`
> mutation). `retract_events` is rewritten per the NIP-09 section below.
> `stage_event_on_relay` is untouched, it already followed this pattern.
> Verified against the pinned `nostr-sdk` source that `SendEventOutput`
> (`= Output<EventId, EventSendStatus, String>`) and `EventDeletionRequest`
> (`nostr/src/nips/nip09.rs`) have the shapes assumed here, and that
> `UniversalSigner` implements the `AsyncGetPublicKey + AsyncSignEvent`
> bounds `FinalizeEventAsync` requires. `cargo check --workspace`,
> `cargo clippy --workspace --all-targets` and `cargo test --workspace` all
> pass unchanged (165+ tests, no failures) — none of the deleted/rewritten
> functions had direct unit test coverage (they all require a live relay),
> so this was verified by compilation plus a careful line-by-line diff
> against the previous control flow for each of the 8 call sites.
Grep for anything that ends up calling `client.send_event`:
| Function | File:line | What it adds over `client.send_event` |
@@ -361,13 +321,6 @@ invariant, not something to paper over with a fingerprint cache.
## 4. Gossip is enabled, and stays enabled — but today's git-domain sends should bypass it explicitly
> **Status: done**, implemented as part of §2's send-path consolidation.
> Every direct `client.send_event(...)` call added while deleting the 8
> send-path layers uses `.broadcast()` explicitly (repository announcements,
> state, issues, PRs, patches, comments, statuses and NIP-09 deletions).
> `.gossip(...)` stays configured in `signed_nostr::backend::with_database`
> for future NIP-17/NIP-65 features, per the recommendation below.
Per team direction: gossip is a deliberate, load-bearing choice for this
client (it's not fully wired up to a feature yet, but it's not incidental
configuration either). `.gossip(...)` stays in `signed_nostr::backend::with_database`
@@ -489,22 +442,52 @@ path handling and quoted-path unescaping need to be checked against this
project's test fixtures (`signed_git/src/lib.rs:3917-3962`,
octal-escaped/non-ASCII quoted paths) before committing to the swap.
**Outcome of the spike: the swap is sound, and was committed.** Both specific
risks flagged above checked out:
- **Renamed paths.** `diffy` produces `FileOperation::Rename { from, to }` from
the `rename from`/`rename to` extended headers, and those paths are *not*
`a/`/`b/`-prefixed, unlike `Create`/`Delete`/`Modify`, which come from the
`---`/`+++` lines *with* the prefix. The adapter therefore calls
`FileOperation::strip_prefix(1)` (git's `-p1`) only for the non-rename
variants — exactly the split `FileOperation`'s own doc comment and
`diffy`'s `examples/apply.rs` describe. This is the one place the two APIs
differ in shape, and the one place a naive port would have broken.
- **Quoted-path unescaping.** `diffy` decodes git's full C-style quoting —
named escapes *and* 3-digit octal — via `escaped_filename`, and rejects
non-UTF-8 in the `str` variant with `InvalidUtf8Path`. That matches the old
`gix::quote::ansi_c::undo` + `String::from_utf8` behavior exactly, error
case included.
Two encoding details had to be matched rather than assumed:
- `HunkRange::start()`/`len()` are the **literal hunk-header numbers**
(`@@ -1,3 +1,3 @@``start == 1`), not 0-based indices — `diffy`'s own
`diff/mod.rs` adds 1 when *building* a range from an index. So
`old_start`/`new_start`/`old_lines`/`new_lines` map across directly, and the
`@@ -0,0 +1 @@` empty-range case falls out for free.
- `Line`'s text **keeps** the trailing newline and has already had the
`+`/`-`/` ` prefix stripped. The adapter re-derives `DiffLine.old`/`new` by
counting from the hunk header (context advances both, deletion only old,
insertion only new, same as before) and strips the line ending the way
`str::lines` does.
One deliberate behavior difference: `PatchSet` yields a single
`Err("no valid patches found")` for input containing no patch at all, where a
patch with no `diff --git` section used to yield an empty file list.
`patch_diffs` now short-circuits to an empty `CommitDiff` when no line starts
with `diff --git ` — the same guard `diffy`'s internal `find_gitdiff_start`
uses — so `empty_or_unparseable_patch_yields_no_files` still holds.
Note: the earlier estimate above ("~700 line hand-rolled parser") was too
high; the parser itself was 370 lines, and the ~1300 lines of tests for it
remain, now serving as the fixture-by-fixture verification for the
crate-backed implementation.
---
## 6. Remove the `tasks: Vec<Task<...>>` + `push_task` boilerplate — use `Task::detach()`
> **Status: done.** Removed the `tasks` field and `push_task` from all six
> stores (`backend.rs`, `checkouts.rs`, `local_repos.rs`, `profile.rs`,
> `repo.rs`, `repo_list.rs`); every call site now ends in `.detach()`
> instead. As with §14, most `cx.spawn` sites lost their type-inference
> anchor and needed an explicit `let task: Task<Result<(), Error>> = ...`
> (or `gpui::Task<...>` where `Task` wasn't imported) before `.detach()`.
> A few closures that captured a variable also named `task` (the awaited
> inner task) were given a distinct outer name (`notify_task`, `publish`,
> `fetch`, `sync`) to avoid a confusing shadow. `cargo check --workspace`
> and `cargo test -p signed_state` (24 tests) / `cargo test -p workspace`
> (14 tests) all pass.
Verified against the actual pinned GPUI revision
(`~/.cargo/git/checkouts/zed-a70e2ad075855582/1870e26/crates/scheduler/src/executor.rs:375-573`
and `crates/gpui/src/executor.rs:32-63`).
@@ -627,26 +610,6 @@ to `RepoListStore`.
## 8. Relay add/connect: stop round-tripping through strings, stop reconnecting the whole pool
> **Status: done.** Every `add_relay` call site now passes the `RelayUrl`
> directly (no `.as_str()`/`ToString` round trip) and chains `.and_connect()`
> instead of a separate, pool-wide `client.connect()`/`client.connect_relay()`
> call: `Backend::bootstrap` (both the `BOOTSTRAP_RELAYS` and `INDEXER_RELAYS`
> loops), `bootstrap_user`, `create_repository`, `publish_local_repo`,
> `connect_repo_relays`, and `stage_event_on_relay`. `Backend::add_relays` is
> deleted entirely — its two callers (`create_repository`, `publish_local_repo`)
> now capture `client` once before the surrounding `cx.spawn` and loop
> `client.add_relay(url).and_connect().await.ok();` directly over the
> `Vec<RelayUrl>` they already had, with no `Vec<String>` conversion at all.
> Verified `.and_connect()` (`client/api/add.rs`) and pool-wide `.connect()`
> semantics (`client/api/connect.rs`) against the pinned nostr-sdk source
> before making the change (see chat history), plus that `pool.sync()`
> requires relays to already be present in the pool
> (`pool/mod.rs:679-693`, `relays.get(&url).ok_or_else(...)`), which is why
> `sync_bootstrap_only`/`bootstrap_user` can safely assume `BOOTSTRAP_RELAYS`
> are already added by `Backend::bootstrap` before any sync runs.
> `cargo check --workspace`, `cargo clippy -p signed_state`, and
> `cargo test -p signed_state` (24 tests) all pass.
Flagged example (`backend.rs:1071-1074`):
```rust
@@ -733,24 +696,6 @@ Delete `Backend::add_relays` entirely once both call sites are inlined.
## 9. `create_repository`'s flow is backwards: it inits a mirror, then clones it into the real destination
> **Status: done.** `Backend::create_repository` now computes `destination`
> (`folder.join(dir_name)`) up front and calls `signed_git::init_repository`
> directly on it — no mirror path, no `Url::from_file_path`, no `clone_repo`
> call, no double `origin` setup. An explicit `destination.exists()` check
> (mirroring what `clone_repo` used to guard for free) replaces the removed
> clone step's own guard. The push at the end now runs against `destination`
> instead of the mirror path, and the task returns `(announcement,
> destination)` as before — no caller-visible signature change. This also
> dropped `GitStore`/`GitCache::repo_path`/`repo_addr` usage from the
> function entirely, since no mirror is created there anymore; `repo_addr`
> and `Context as AnyhowContext` became unused imports in `backend.rs` and
> were removed. Updated a stale comment in `signed_git`'s
> `working_copy_cloned_from_the_mirror_matches_head_and_origin` test, which
> referenced this flow by name even though it's a generic `clone_repo`
> fixture unrelated to `Backend::create_repository`. `cargo check --workspace`,
> `cargo clippy --workspace`, and `cargo test --workspace` (signed_git 67,
> signed_state 24, workspace 14) all pass.
This is a real business-logic flaw, not just a style issue. Today
(`backend.rs:437-501`):
@@ -826,27 +771,6 @@ source of truth.
## 10. Bootstrap-on-construction should go through `cx.defer`, not run synchronously in `new`
> **Status: done.** All six constructors listed in the table below now build
> `Self` with no side effects, capture `cx.entity().downgrade()`, and defer
> the bootstrap call(s) with `cx.defer(move |cx| { weak.update(cx, |this,
> cx| ...).ok-or-log(); })`. Verified `cx.entity()` is safe to call before
> the entity is registered: `App::new`'s `cx.entities.reserve()` bumps the
> ref count to 1 before `build_entity` runs (`app/entity_map.rs:114-117`),
> so `weak_entity().upgrade()` succeeds throughout construction, and the
> deferred closure only runs after `cx.new`'s `insert_entity` call has fully
> populated the entity, so the weak upgrade inside the deferred closure
> always succeeds too (barring the caller synchronously dropping the
> just-created `Entity` before yielding, an edge case worth a log line, not
> a crash). `RepoStore::new` bundles its three previously-sequential calls
> (`subscribe_remote`, `connect_announced_relays`, `refresh`) into one
> deferred closure to preserve their relative order. Failure to upgrade is
> logged with `log::warn!` rather than silently discarded with `.ok()`, per
> this project's error-handling rule. `cargo check --workspace`,
> `cargo clippy -p signed_state --all-targets` and `cargo test --workspace`
> all pass unchanged — none of the existing tests construct these stores
> through a `TestAppContext` and assert state immediately after `cx.new`,
> so no test needed a `cx.run_until_parked()` addition.
Verified against the pinned GPUI revision
(`crates/gpui/src/app.rs:1999-2005`, `crates/gpui/src/app/context.rs:296-315`).
@@ -903,39 +827,6 @@ talk to other entities" in the same synchronous call, which is exactly what
## 11. Split independently-observed state into child entities
> **Status: done**, with one correction to the approach originally sketched
> below. `Backend::pushing_repos` is now `Entity<HashSet<RepoAddr>>`,
> created with `cx.new(|_| HashSet::new())` in `Backend::new` and exposed
> via `Backend::pushing_repos() -> Entity<HashSet<RepoAddr>>` for future
> `cx.observe` callers (nothing reads it today — the actual UI-facing "is
> this repo pushing" indicator is the pre-existing, already-observable
> `RepoStore::pushing: bool`; this field is purely `push_repo_from`'s
> internal re-entrancy guard).
>
> The blocker: `Drop::drop(&mut self)` has no `cx` parameter, so `PushGuard`
> could not literally call `pushing_repos.update(cx, ...)` on drop as first
> sketched below — confirmed by checking Zed's own codebase, which hits the
> same wall and falls back to a raw `Mutex` for exactly this reason
> (`crates/project/src/project.rs`'s `RemotelyCreatedModelGuard`). The fix is
> `AsyncApp::on_drop(&self, entity: &WeakEntity<T>, f: impl FnOnce(&mut T,
> &mut Context<T>) + 'static) -> Deferred<impl FnOnce()>`
> (`gpui/src/app/async_context.rs:266-276`), which is exactly what several
> Zed crates already use for this "clean up an entity when a spawned task is
> cancelled" pattern (e.g. `git_ui/src/git_panel.rs`'s
> `_clear_pending_remote_operation = cx.on_drop(&this, |this, cx| ...)`).
> `push_repo_from` now inserts into `pushing_repos` synchronously before
> `cx.spawn` (using the already-available `&mut Context<Backend>`), and
> holds `let _guard = cx.on_drop(&this, move |backend, cx| { ... remove ...
> });` for the lifetime of the spawned task — removal fires on completion,
> error, or cancellation alike, same as the old `Drop for PushGuard`, but
> now through a real, observable entity update with `cx.notify()`. The old
> `PushGuard` struct and its `Drop` impl are deleted; `Arc`/`Mutex` are no
> longer imported in `backend.rs` at all. `cargo check --workspace`,
> `cargo clippy --workspace --all-targets` and `cargo test --workspace` all
> pass unchanged; no call site outside `signed_state` touched
> `pushing_repos`, confirming it had zero external readers before this
> change.
`Backend::pushing_repos` (`backend.rs:88`) is `Arc<Mutex<HashSet<RepoAddr>>>`
— it bypasses GPUI's entity system entirely. A view that wants to show "is
repository X currently pushing" has no way to `cx.observe` this; it can
@@ -983,33 +874,6 @@ This principle is also the reason **not** to merge `LocalReposStore` and
## 12. One debounce at the source, not one per store
> **Status: done.** `Backend::new`'s pump now batches: it waits for the
> first `ClientNotification::Event`, then races `notifications.next()`
> against a `PUMP_DEBOUNCE` (200ms) timer in a loop, collecting every event
> that arrives before the deadline into one `Vec<Update>`, then emits a
> single `BackendEvent::NostrUpdate(Vec<Update>)`. Implemented with
> `futures::future::select` + `futures::pin_mut!`, matching the existing
> debounce idiom already used by `ProfileStore::handle_requests` in the same
> crate (not `select_biased!`, which isn't used anywhere else here).
> Verified `Client::notifications()` returns a `Pin<Box<dyn Stream<Item =
> ClientNotification> + Send>>` backed by a `broadcast::Receiver`
> (`nostr-sdk/src/client/mod.rs:199-205`, `pool/mod.rs:96`), so cancelling a
> `.next()` future mid-poll to race it against the timer cannot drop a
> notification — the broadcast cursor only advances on a completed receive.
> `BackendEvent::NostrUpdate` changed from `Update` to `Vec<Update>`; its 3
> actual subscribers (`ProfileStore`, `RepoStore`, `RepoListStore`
> `CheckoutsStore` only observes `RepoListStore`/`LocalReposStore`, it never
> matched on `NostrUpdate` directly) were updated to iterate the batch
> (`.any(...)` for the two relevance checks, a `for` loop over the
> metadata-kind updates in `ProfileStore`). Also fixed a `let _ =` silently
> discarding a `WeakEntity::update` result in `ProfileStore::handle_requests`,
> found while touching this file, replaced with the `.ok()` idiom used
> everywhere else in this crate for the same "entity may already be gone"
> case. Downstream per-store `RefreshGate` debounce windows are left
> unchanged for now, per the "measure before resizing" note below.
> `cargo check --workspace`, `cargo clippy --workspace --all-targets` and
> `cargo test --workspace` all pass unchanged.
Flagged example — the notification pump (`backend.rs:126-146`):
```rust
@@ -1085,16 +949,6 @@ than speculatively resizing four timers up front.
## 13. `local_repos.rs` + `repo_list.rs`: merge the files, not the entities
> **Status: done.** Merged both files into `signed_state/src/repos.rs`, keeping
> `LocalReposStore` and `RepoListStore` as two fully independent structs, each
> still its own `Entity`/`Global` with the same `global()`/`set_global()` pairs
> and public API as before — zero call-site churn beyond fixing the `use`
> paths (`crate::local_repos`/`crate::repo_list``crate::repos`) in
> `checkouts.rs`, `repo.rs` and `lib.rs`. `cargo check --workspace`,
> `cargo clippy -p signed_state --all-targets` and `cargo test --workspace`
> (signed_state 24 tests, workspace 14 tests, full suite 165+ tests) all pass
> unchanged.
These two are structurally near-identical: both hold an `Arc<Vec<T>>`
snapshot, refresh it in the background on a trigger, swap it in with
`cx.notify()`, and carry their own `Global` wrapper + `global()`/`set_global()`
@@ -1146,14 +1000,6 @@ entirely disjoint observers.
## 14. `crates/workspace` has the same task-list pattern as §6 — and there it's an actual bug
> **Status: done.** The `tasks` field and all 17 push sites were removed from
> `RepoDetailView`, `NewPullRequestView`, `CommitDiffView` and
> `PullRequestDetailView`, replaced with `.detach()`. `cargo check -p workspace`
> and `cargo test -p workspace` (14 tests) pass. Removing the field cost each
> `cx.spawn`/`cx.spawn_in` call site its type-inference anchor, so every
> remaining spawn site needed an explicit `let task: gpui::Task<Result<(), ...>> = ...`
> annotation — expect the same when doing §6's `signed_state` half.
§6 covers `signed_state`'s 6 stores, where the unpruned-`Vec<Task>` pattern
is a style/complexity concern with no observed failure, because
`push_task` always pruned before pushing. `crates/workspace` has the exact
@@ -1197,21 +1043,6 @@ already exists once in the same crate.
## 15. `Vec<Url>``Vec<String>` conversion sprawl — fix the 3 `signed_git` signatures, not the 8 call sites
> **Status: done.** `try_each_url`, `clone_repo`, `GitCache::ensure_clone`
> and `fetch_repo_refs` are now generic over `U: AsRef<str>`. All 7 call
> sites (`repo.rs::merge_pull_request`/`clone_to_folder`,
> `repo_detail/mod.rs::load_repo`, `new_pull_request.rs::choose_fork` x2,
> `pull_request_detail.rs::load`/`clone_urls_of`) now pass the
> `Vec<Url>`/`Vec<Url>`-derived value straight through with a plain
> `.clone()` of the field, no `.iter().map(ToString::to_string).collect()`
> anywhere left in non-test code. One test in `signed_git` passed an empty
> untyped `&[]` literal to `fetch_repo_refs`, which lost its type-inference
> anchor once the function went generic — fixed with an explicit
> `&[] as &[String]` annotation. `cargo check --workspace`,
> `cargo clippy -p signed_state -p workspace -p signed_git --all-targets`,
> and `cargo test --workspace` (signed_git 67, signed_state 24, workspace 14)
> all pass.
`Announcement::clone` is `Vec<Url>` (`signed_core/src/model.rs`, `Url` being
`nostr`'s re-export of the `url` crate's `Url`, `nostr/src/types/url.rs:15`,
`pub use url::*;`). Every call site that needs to hand those URLs to
@@ -1300,47 +1131,6 @@ than avoidable duplication.
## 17. Business logic that leaked into `crates/workspace` and should move to `signed_core`/`signed_state`
> **Status: done.** All four sub-items landed:
>
> - `current_commit_of` is now `pub fn` in `signed_core::model`; the
> byte-for-byte duplicate in `pull_request_detail.rs` is deleted, replaced
> by an import.
> - `merge_base_of`, `clone_urls_of`, `branch_name_of` and `latest_update`
> moved to `signed_core::model` as `pub fn`s with their tests (the same
> `signed()`/`pr_root()`-style fixtures the doc predicted, renamed
> `signed_at`/`pr_root` to avoid colliding with `model.rs`'s existing
> single-owner `keys()`/`announcement_event` fixtures used by unrelated
> `is_fork_of` tests in the same file). `pull_request_detail.rs` lost the
> now-unused `Nip34Tag`/`Url` imports as a result.
> - `fork_candidates` moved to `signed_core::model` as planned. The doc's
> wording was ambivalent about where `fork_namespace` should go ("safe,
> low-risk move to `signed_core`" vs. "pairs naturally with `signed_git`'s
> ref-naming conventions" in the same paragraph) — turns out only one is
> actually possible: `fork_namespace` calls `signed_git::sanitize_path_component`,
> and `signed_git` **depends on** `signed_core` (`signed_git/Cargo.toml`),
> so moving it to `signed_core` would be a circular dependency. It moved to
> `signed_git` instead, next to `sanitize_path_component`, with a new unit
> test (it had none before). `fork_candidates` has no such constraint (only
> touches `Announcement`/`RepoAddr`/`PublicKey`) and moved to `signed_core`
> as planned, tests included. `new_pull_request.rs`'s entire `mod tests`
> block was deleted — both moved functions were the only things it tested.
> - `NewPullRequestView::submit` no longer calls `format_patch_between`
> itself: `RepoStore::open_pull_request_from_refs(repo_path, merge_base,
> compare_ref, subject, description, branch_name, draft, cx) ->
> Task<Result<(), Error>>` does the `format_patch_between` + empty-check +
> `open_pull_request` sequence internally, with the exact same two error
> messages ( "No commits between the branches to propose" /
> "Failed to generate the patch: {error}") the view used to produce
> inline, now surfaced through the returned `Task`'s `Err` and displayed
> via the view's existing `self.error` field — no observable UI change.
> `submit` shrank to gathering form values and awaiting the store call;
> `format_patch_between` is no longer imported in `new_pull_request.rs`.
>
> `cargo check --workspace`, `cargo clippy --workspace --all-targets` and
> `cargo test --workspace` all pass; test counts moved with the functions
> (`signed_core` 41 → 48, `signed_git` 67 → 68 for the new `fork_namespace`
> test, `workspace` 14 → 7), no failures, no coverage lost.
Direct answer to "can the view side be thinner": yes, and not speculatively —
found one confirmed duplicate, one cluster of misplaced domain parsing, and
one mutating-flow split across the view/store boundary. The test used to
@@ -1475,121 +1265,120 @@ method.
---
## Action plan, in order of risk/reward
## Outcome
1. ✅ **Delete the fetch/sync dedup cache** (§3). Pure removal, no behavior
change for the intended usage pattern (each call site already has, or
trivially gets, its own guard). Lowest risk, do first.
All 14 items below are implemented and verified, listed in the order they were
done — mechanical removals first, the largest diff (§2) and the riskiest swap
(§5) last.
Done: removed `recent_fetches`/`fetch_recently_started`/`fetch_fingerprint`/
`FETCH_DEDUP_WINDOW` and the now-unused `DefaultHasher`/`Hash`/`Hasher`/
`Instant` imports from `signed_state/src/backend.rs`. `connect_repo_relays`
and `sync_bootstrap` no longer fingerprint or gate on a cache; callers keep
their own guards (`RepoStore::repo_relays`, one-shot construction-time call
in `RepoListStore::subscribe_remote`). `cargo check --workspace` and
`cargo test -p signed_state` (24 tests) both pass unchanged.
2. ✅ **Remove the `tasks: Vec<Task<...>>` + `push_task` boilerplate**, in
both `signed_state` (§6) and `crates/workspace` (§14), in favor of
`.detach()`/`.detach_and_log_err(cx)`. Independent of every other change
here, touches 10 files, all mechanical — and fixes a real unbounded-growth
bug in `RepoDetailView`/`NewPullRequestView` along the way.
1. **Delete the fetch/sync dedup cache** (§3). `recent_fetches`,
`fetch_recently_started`, `fetch_fingerprint` and `FETCH_DEDUP_WINDOW` are
gone, along with the `DefaultHasher`/`Hash`/`Hasher`/`Instant` imports they
needed. Each call site keeps its own guard.
2. **Remove the `tasks: Vec<Task<...>>` + `push_task` boilerplate** on both
sides (§6, §14) — all six `signed_state` stores and all four
`crates/workspace` views, 17 push sites, most never pruned. In the views
this was a real unbounded-growth bug, not just style.
3. **Fix the relay add/connect calls** (§8). No `.as_str()`/`ToString` round
trips; `add_relay(url).and_connect()` instead of a separate, pool-wide
`connect()`; `Backend::add_relays` deleted.
4. **Fix `bootstrap_user`** (§1) to sync via negentropy and query the local
database. `grep -rn "fetch_events" crates/` now returns nothing.
5. **Generalize the three `signed_git` URL-list signatures** to
`U: AsRef<str>` (§15) and drop the seven
`.iter().map(ToString::to_string).collect()` call sites, which now pass
`&[Url]` straight through.
6. **Fix `create_repository`'s init/clone ordering** (§9). It initializes and
pushes directly at the destination; no mirror, no `clone_repo`, no double
`origin`.
7. **Merge `local_repos.rs` and `repo_list.rs`** into
`signed_state/src/repos.rs` (§13), keeping both stores independent
`Entity`/`Global`s — no call-site change beyond `use` paths.
8. **Route construction-time bootstrap through `cx.defer`** in all six stores
(§10), with a failed weak upgrade logged rather than silently dropped.
9. **Consolidate the send paths** (§2, §4). The eight layers (`Backend::send`,
`publish_event`, `publish_task`, `send_fire_and_forget`, `broadcast_event`,
`RepoStore::send`, …) collapsed to direct
`client.send_event(&event).broadcast()` calls plus one
`require_relay_accepted` helper. `retract_events` now sends one NIP-09
deletion per target, with no `k` tag. The rewrite also fixed the
inconsistent error surfacing this section flagged: the three call sites
with divergent control flow now set `last_error` on failure like every
other `RepoStore` mutation.
10. **Split `pushing_repos` into a child entity** (§11). It is now an
observable `Entity<HashSet<RepoAddr>>`; the old `PushGuard` and the
`Arc`/`Mutex` around it are gone.
11. **Centralize the notification-pump debounce** (§12). The pump batches into
a single `BackendEvent::NostrUpdate(Vec<Update>)` behind a 200 ms window,
and its three subscribers iterate the batch.
12. **Drop progressive reveal** (§7). `RepoListStore` refreshes once per
completed sync instead of several times mid-sync.
13. **Replace the hand-rolled `git format-patch` parser with
`diffy::patch_set`** (§5). 370 lines to 216, no test changed.
14. **Move the misplaced `workspace` domain logic** (§17) to
`signed_core`/`signed_git`, and give `RepoStore` a refs-in-patch-out
method so `NewPullRequestView::submit` no longer generates patches itself.
Done: both halves are complete, see §6 and §14 for details.
3. ✅ **Fix the relay add/connect calls** (§8): drop `.as_str()`/`ToString`
round trips, replace `add_relay` + blanket `client.connect()`/
`connect_relay` pairs with `add_relay(url).and_connect()`, and delete
`Backend::add_relays`. Mechanical, no behavior change beyond "connect
only what was just added."
### Deviations, corrections, and findings worth keeping
Done: see §8 for the full list of call sites and verification notes.
4. ✅ **Fix `bootstrap_user`** to sync+query instead of `fetch_events` (§1).
One function, fully covered by existing tests for
`latest_grasp_list_servers`.
Most items landed exactly as planned. These are the ones that did not, plus
the non-obvious findings that were only ever recorded in the per-item status
notes this section replaced:
Done: see §1. `fetch_events` no longer appears anywhere in the workspace.
5. ✅ **Generalize the 3 `signed_git` URL-list signatures** to `&[impl AsRef<str>]`
(§15), then delete the now-redundant `.map(ToString::to_string).collect()`
at all 7 call sites. Self-contained to `signed_git`'s public API plus a
one-line change per call site; re-run `signed_git`'s existing tests
(`clone_repo`/`fetch_repo_refs` already have coverage).
- **`RepoStore::publish` was deliberately kept** (§2), a narrow exception to
"delete `RepoStore::send`": its four callers (`open_issue`, `reply`,
`set_status`, `publish_applied_status`) have byte-for-byte identical
sign+send+check+`last_error` post-conditions. The three callers with
genuinely divergent control flow (`open_pull_request`,
`update_pull_request`, `publish_patch_series`) call the SDK inline.
- **`fork_namespace` went to `signed_git`, not `signed_core`** as §17
sketched. It calls `signed_git::sanitize_path_component`, and `signed_git`
already depends on `signed_core`, so the sketched direction would have been
a circular crate dependency.
- **`pushing_repos` was made observable with `AsyncApp::on_drop`, not a `Drop`
impl** (§11). `Drop::drop(&mut self)` has no `cx`, so it cannot update a GPUI
entity; Zed's own codebase hits the same wall and falls back to a raw
`Mutex` (`crates/project/src/project.rs`, `RemotelyCreatedModelGuard`).
- **Calling `cx.entity()` before the entity is registered is safe** (§10) —
what makes the deferred bootstrap sound. `App::new`'s `cx.entities.reserve()`
bumps the ref count before `build_entity` runs
(`app/entity_map.rs:114-117`), and the deferred closure only runs after
`cx.new`'s `insert_entity`.
- **Gossip stays enabled** in `ClientBuilder` for future NIP-17/NIP-65 work;
every git-domain send bypasses it explicitly with `.broadcast()` (§4).
- **`pool.sync()` requires the relays to already be in the pool**
(`pool/mod.rs:679-693`), which is why `Backend::bootstrap` adds
`BOOTSTRAP_RELAYS` before `sync_bootstrap_only`/`bootstrap_user` run — the
precondition §8's change relies on.
- **`pushing_repos` has no readers today** (§11): it is `push_repo_from`'s
internal re-entrancy guard. The UI-facing "is pushing" indicator is the
pre-existing, already-observable `RepoStore::pushing` boolean.
- **`patch_diffs` short-circuits on input with no `diff --git ` line** (§5),
because `PatchSet` yields `Err("no valid patches found")` for input holding
no patch at all, where the old parser returned an empty list.
- **A `let _ =` on a `WeakEntity::update` in `ProfileStore::handle_requests`
became `.ok()`** (§12), found while touching that file. It now follows the
project's error-handling rule.
- **Two type-inference anchors had to be re-added by hand**, a recurring cost
of both §6/§14 and §15: removing a `Vec<Task<...>>` field and going generic
over `AsRef<str>` both strip the anchor from call sites with untyped `&[]`
literals, fixed with explicit `Task<Result<(), Error>>` and
`&[] as &[String]` annotations.
- **Not covered by tests:** the deleted send paths (§2) and the
`create_repository` fix (§9) need a live relay or a live GRASP server, so
they were verified by compilation plus a line-by-line diff against the old
control flow. A manual create-repository-then-open-detail-view pass is still
the recommended pre-ship check for §9.
Done: see §15 for the full list of call sites and verification notes.
6. ✅ **Fix `create_repository`'s init/clone ordering** (§9): initialize and
push directly at the user's chosen destination, drop the mirror
pre-population entirely and let `ensure_clone` populate it lazily like
every other repo. Self-contained to one function; verify against this
crate's existing `init_repository`/push tests plus a manual
create-repository-then-open-detail-view pass.
Done: see §9. Manual create-repository-then-open-detail-view pass still
recommended before shipping, since it depends on the grasp push actually
succeeding end-to-end against a live server.
7. ✅ **Merge `local_repos.rs` and `repo_list.rs` into one file** (§13),
keeping both stores as independent entities. Purely organizational, zero
call-site changes, safe to do any time.
Done: merged into `signed_state/src/repos.rs`, see §13 for verification
notes.
8. ✅ **Route construction-time bootstrap through `cx.defer`** (§10) in all
six stores listed there. Mechanical per store, but touch them one at a
time and re-run each store's test suite, since ordering-sensitive
assumptions (e.g. a test that asserts state right after `cx.new`) may
need `cx.run_until_parked()` inserted where they didn't before.
Done: `Backend`, `RepoStore`, `RepoListStore`, `LocalReposStore`,
`CheckoutsStore` and `ProfileStore` all defer their bootstrap now, see
§10 for verification notes.
9. ✅ **Consolidate the send paths** (§2): introduce the single
`require_relay_accepted` helper, delete
`Backend::send`/`publish_event`/`send_fire_and_forget`/`broadcast_event`/
`RepoStore::send`, switch `retract_events` to `EventDeletionRequest`
(one deletion event per target, no `k` tag), and make each send site
explicit about bypassing gossip (§4) with `.broadcast()`/`.to(relays)`.
This is the biggest diff and touches every publish call site (`repo.rs`,
`backend.rs`), so do it as its own PR with full test-suite coverage
before/after.
Done: see §2 and §4 for the full list of call sites, the one deliberate
narrow exception (`RepoStore::publish`), and verification notes.
10. ✅ **Split `pushing_repos` (and similar fields) into a child entity** (§11).
Small, isolated change once §9's `PushGuard` rewrite is in flight — do
them together since both touch `PushGuard`.
Done: see §11 — implemented via `AsyncApp::on_drop`, not the plain
`Drop` impl originally sketched, which turned out not to be possible.
11. ✅ **Centralize the notification-pump debounce** (§12). This one is the
most speculative of the batch — land it after §7's `SyncProgress`
decision and re-measure whether each store's own `RefreshGate` window
can shrink, rather than assuming the exact shape up front.
Done: see §12. Landed without waiting on §7 since it doesn't depend on
that decision — downstream `RefreshGate` windows were deliberately left
unresized, so there's nothing here for §7 to invalidate either way.
12. **Optional, product call:** drop `SyncProgress` from
`RepoListStore`'s relevant-event match if progressive reveal during
bootstrap sync isn't a feature you want (§7).
13. **Spike `diffy::patch_set::PatchSet`** to replace `signed_git`'s
hand-rolled `git format-patch` parser (§5). Separate PR, separate
crate, no interaction with the nostr-facing changes above — do this in
parallel if you have a second contributor, otherwise last since it's
the largest and riskiest single change (needs fixture-by-fixture
verification against the existing test suite).
14. ✅ **Move the misplaced `workspace` domain logic to `signed_core`/`signed_state`** (§17):
make `current_commit_of` `pub` in `signed_core` and delete the
`workspace` duplicate; move `merge_base_of`/`clone_urls_of`/`branch_name_of`/
`latest_update` and `fork_candidates`/`fork_namespace` there too, tests
included; give `RepoStore` a refs-in-patch-out method so
`NewPullRequestView::submit` stops calling `format_patch_between`
itself. Low risk, no behavior change, best done as its own small PR per
function cluster rather than one big move.
Done: see §17. `fork_namespace` ended up in `signed_git`, not
`signed_core`, to avoid a circular crate dependency — everything else
landed exactly as planned.
Verification: `cargo check --workspace`, `cargo clippy --workspace
--all-targets` and `cargo test --workspace` pass — 167 tests, 0 failures —
re-run after each item landed rather than once at the end. Test counts moved
between crates as functions moved (`signed_core` 41 → 48, `signed_git`
67 → 68, `workspace` 14 → 7); no coverage was lost.
Everything **not** listed above (per-repo/per-list `Entity` stores, the
`RefreshGate` debounce/coalesce pattern, `Nip34Tag`/`Coordinate`/`Filter`
usage in `signed_core`, the GRASP push-retry state machine in
`push_staged_to_grasps`, the `UniversalSigner` abstraction, and the dense
`.clone()` clusters audited in §16) was checked and already matches "use
the SDK directly, no unnecessary wrapper" — those should be left alone.
`.clone()` clusters audited in §16) was checked and already matches "use the
SDK directly, no unnecessary wrapper" — those were left alone.