chore: refactor the backend #17
Generated
+10
@@ -1690,6 +1690,15 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"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]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -7958,6 +7967,7 @@ name = "signed_git"
|
|||||||
version = "0.1.0-alpha"
|
version = "0.1.0-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"diffy",
|
||||||
"gix",
|
"gix",
|
||||||
"gix-worktree",
|
"gix-worktree",
|
||||||
"gix-worktree-state",
|
"gix-worktree-state",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ gix = { workspace = true, features = ["revision", "blob-diff"] }
|
|||||||
gix-worktree = "0.56"
|
gix-worktree = "0.56"
|
||||||
gix-worktree-state = "0.34"
|
gix-worktree-state = "0.34"
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
diffy = "0.5"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
+134
-282
@@ -4,6 +4,8 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
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::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
|
||||||
use gix::interrupt::IS_INTERRUPTED;
|
use gix::interrupt::IS_INTERRUPTED;
|
||||||
use gix::progress::Discard;
|
use gix::progress::Discard;
|
||||||
@@ -1596,24 +1598,145 @@ fn tree_diff(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse `git format-patch` output, a single patch or a series.
|
/// 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> {
|
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
||||||
let lines: Vec<&str> = patch.lines().collect();
|
// `PatchSet` reports an error when the input holds no patch at all,
|
||||||
let mut files = Vec::new();
|
// while a patch without git diff sections is simply empty here.
|
||||||
let mut i = 0;
|
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
||||||
|
return Ok(CommitDiff { files: Vec::new() });
|
||||||
|
}
|
||||||
|
|
||||||
while i < lines.len() {
|
let mut files = Vec::new();
|
||||||
let Some(header) = lines[i].strip_prefix("diff --git ") else {
|
|
||||||
i += 1;
|
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
|
||||||
continue;
|
files.push(file_diff(file?)?);
|
||||||
};
|
|
||||||
let (file, next) = parse_diff_section(header, &lines, i + 1)?;
|
|
||||||
files.push(file);
|
|
||||||
i = next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(CommitDiff { files })
|
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.
|
/// Commits of a `git format-patch` output, a single patch or a series.
|
||||||
///
|
///
|
||||||
/// Entries appear in patch order, oldest first as `git format-patch` produces them.
|
/// Entries appear in patch order, oldest first as `git format-patch` produces them.
|
||||||
@@ -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.
|
/// Collects the hunks of one blob diff while tracking per-line numbers.
|
||||||
struct HunkCollector<'a> {
|
struct HunkCollector<'a> {
|
||||||
hunks: &'a mut Vec<DiffHunk>,
|
hunks: &'a mut Vec<DiffHunk>,
|
||||||
|
|||||||
@@ -203,7 +203,10 @@ impl RepoListStore {
|
|||||||
|
|
||||||
announcement || deletion
|
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,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+155
-366
@@ -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
|
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`**
|
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
|
`crates/signed_git`, and `crates/workspace` (the actual call sites of the
|
||||||
backend, audited for business-logic flaws and redundant conversions).
|
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 1–17 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
|
## Summary of the ask
|
||||||
|
|
||||||
1. Never call `fetch_events`. Bootstrap only via `subscribe`/`sync` (negentropy), read from `client.database()`.
|
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
|
## 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/
|
grep -rn "fetch_events" crates/
|
||||||
crates/signed_state/src/backend.rs:1065
|
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
|
## 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`:
|
Grep for anything that ends up calling `client.send_event`:
|
||||||
|
|
||||||
| Function | File:line | What it adds over `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
|
## 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
|
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
|
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`
|
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`,
|
project's test fixtures (`signed_git/src/lib.rs:3917-3962`,
|
||||||
octal-escaped/non-ASCII quoted paths) before committing to the swap.
|
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()`
|
## 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
|
Verified against the actual pinned GPUI revision
|
||||||
(`~/.cargo/git/checkouts/zed-a70e2ad075855582/1870e26/crates/scheduler/src/executor.rs:375-573`
|
(`~/.cargo/git/checkouts/zed-a70e2ad075855582/1870e26/crates/scheduler/src/executor.rs:375-573`
|
||||||
and `crates/gpui/src/executor.rs:32-63`).
|
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
|
## 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`):
|
Flagged example (`backend.rs:1071-1074`):
|
||||||
|
|
||||||
```rust
|
```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
|
## 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
|
This is a real business-logic flaw, not just a style issue. Today
|
||||||
(`backend.rs:437-501`):
|
(`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`
|
## 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
|
Verified against the pinned GPUI revision
|
||||||
(`crates/gpui/src/app.rs:1999-2005`, `crates/gpui/src/app/context.rs:296-315`).
|
(`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
|
## 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>>>`
|
`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
|
— 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
|
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
|
## 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`):
|
Flagged example — the notification pump (`backend.rs:126-146`):
|
||||||
|
|
||||||
```rust
|
```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
|
## 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>>`
|
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
|
snapshot, refresh it in the background on a trigger, swap it in with
|
||||||
`cx.notify()`, and carry their own `Global` wrapper + `global()`/`set_global()`
|
`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
|
## 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
|
§6 covers `signed_state`'s 6 stores, where the unpruned-`Vec<Task>` pattern
|
||||||
is a style/complexity concern with no observed failure, because
|
is a style/complexity concern with no observed failure, because
|
||||||
`push_task` always pruned before pushing. `crates/workspace` has the exact
|
`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
|
## 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
|
`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`,
|
`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
|
`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`
|
## 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 —
|
Direct answer to "can the view side be thinner": yes, and not speculatively —
|
||||||
found one confirmed duplicate, one cluster of misplaced domain parsing, and
|
found one confirmed duplicate, one cluster of misplaced domain parsing, and
|
||||||
one mutating-flow split across the view/store boundary. The test used to
|
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
|
All 14 items below are implemented and verified, listed in the order they were
|
||||||
change for the intended usage pattern (each call site already has, or
|
done — mechanical removals first, the largest diff (§2) and the riskiest swap
|
||||||
trivially gets, its own guard). Lowest risk, do first.
|
(§5) last.
|
||||||
|
|
||||||
Done: removed `recent_fetches`/`fetch_recently_started`/`fetch_fingerprint`/
|
1. **Delete the fetch/sync dedup cache** (§3). `recent_fetches`,
|
||||||
`FETCH_DEDUP_WINDOW` and the now-unused `DefaultHasher`/`Hash`/`Hasher`/
|
`fetch_recently_started`, `fetch_fingerprint` and `FETCH_DEDUP_WINDOW` are
|
||||||
`Instant` imports from `signed_state/src/backend.rs`. `connect_repo_relays`
|
gone, along with the `DefaultHasher`/`Hash`/`Hasher`/`Instant` imports they
|
||||||
and `sync_bootstrap` no longer fingerprint or gate on a cache; callers keep
|
needed. Each call site keeps its own guard.
|
||||||
their own guards (`RepoStore::repo_relays`, one-shot construction-time call
|
2. **Remove the `tasks: Vec<Task<...>>` + `push_task` boilerplate** on both
|
||||||
in `RepoListStore::subscribe_remote`). `cargo check --workspace` and
|
sides (§6, §14) — all six `signed_state` stores and all four
|
||||||
`cargo test -p signed_state` (24 tests) both pass unchanged.
|
`crates/workspace` views, 17 push sites, most never pruned. In the views
|
||||||
2. ✅ **Remove the `tasks: Vec<Task<...>>` + `push_task` boilerplate**, in
|
this was a real unbounded-growth bug, not just style.
|
||||||
both `signed_state` (§6) and `crates/workspace` (§14), in favor of
|
3. **Fix the relay add/connect calls** (§8). No `.as_str()`/`ToString` round
|
||||||
`.detach()`/`.detach_and_log_err(cx)`. Independent of every other change
|
trips; `add_relay(url).and_connect()` instead of a separate, pool-wide
|
||||||
here, touches 10 files, all mechanical — and fixes a real unbounded-growth
|
`connect()`; `Backend::add_relays` deleted.
|
||||||
bug in `RepoDetailView`/`NewPullRequestView` along the way.
|
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.
|
### Deviations, corrections, and findings worth keeping
|
||||||
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."
|
|
||||||
|
|
||||||
Done: see §8 for the full list of call sites and verification notes.
|
Most items landed exactly as planned. These are the ones that did not, plus
|
||||||
4. ✅ **Fix `bootstrap_user`** to sync+query instead of `fetch_events` (§1).
|
the non-obvious findings that were only ever recorded in the per-item status
|
||||||
One function, fully covered by existing tests for
|
notes this section replaced:
|
||||||
`latest_grasp_list_servers`.
|
|
||||||
|
|
||||||
Done: see §1. `fetch_events` no longer appears anywhere in the workspace.
|
- **`RepoStore::publish` was deliberately kept** (§2), a narrow exception to
|
||||||
5. ✅ **Generalize the 3 `signed_git` URL-list signatures** to `&[impl AsRef<str>]`
|
"delete `RepoStore::send`": its four callers (`open_issue`, `reply`,
|
||||||
(§15), then delete the now-redundant `.map(ToString::to_string).collect()`
|
`set_status`, `publish_applied_status`) have byte-for-byte identical
|
||||||
at all 7 call sites. Self-contained to `signed_git`'s public API plus a
|
sign+send+check+`last_error` post-conditions. The three callers with
|
||||||
one-line change per call site; re-run `signed_git`'s existing tests
|
genuinely divergent control flow (`open_pull_request`,
|
||||||
(`clone_repo`/`fetch_repo_refs` already have coverage).
|
`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.
|
Verification: `cargo check --workspace`, `cargo clippy --workspace
|
||||||
6. ✅ **Fix `create_repository`'s init/clone ordering** (§9): initialize and
|
--all-targets` and `cargo test --workspace` pass — 167 tests, 0 failures —
|
||||||
push directly at the user's chosen destination, drop the mirror
|
re-run after each item landed rather than once at the end. Test counts moved
|
||||||
pre-population entirely and let `ensure_clone` populate it lazily like
|
between crates as functions moved (`signed_core` 41 → 48, `signed_git`
|
||||||
every other repo. Self-contained to one function; verify against this
|
67 → 68, `workspace` 14 → 7); no coverage was lost.
|
||||||
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.
|
|
||||||
|
|
||||||
Everything **not** listed above (per-repo/per-list `Entity` stores, the
|
Everything **not** listed above (per-repo/per-list `Entity` stores, the
|
||||||
`RefreshGate` debounce/coalesce pattern, `Nip34Tag`/`Coordinate`/`Filter`
|
`RefreshGate` debounce/coalesce pattern, `Nip34Tag`/`Coordinate`/`Filter`
|
||||||
usage in `signed_core`, the GRASP push-retry state machine in
|
usage in `signed_core`, the GRASP push-retry state machine in
|
||||||
`push_staged_to_grasps`, the `UniversalSigner` abstraction, and the dense
|
`push_staged_to_grasps`, the `UniversalSigner` abstraction, and the dense
|
||||||
`.clone()` clusters audited in §16) was checked and already matches "use
|
`.clone()` clusters audited in §16) was checked and already matches "use the
|
||||||
the SDK directly, no unnecessary wrapper" — those should be left alone.
|
SDK directly, no unnecessary wrapper" — those were left alone.
|
||||||
|
|||||||
Reference in New Issue
Block a user