diff --git a/.rules b/.rules new file mode 100644 index 0000000..ee4ef8a --- /dev/null +++ b/.rules @@ -0,0 +1,174 @@ +# Rust coding guidelines + +* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified. +* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious. +* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files. +* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors. +* Be careful with operations like indexing which may panic if the indexes are out of bounds. +* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately: + - Propagate errors with `?` when the calling function should handle them + - Use `.log_err()` or similar when you need to ignore errors but want visibility + - Use explicit error handling with `match` or `if let Err(...)` when you need custom logic + - Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead +* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback. +* Avoid creative additions unless explicitly requested +* Use full words for variable names (no abbreviations like "q" for "queue") +* Use variable shadowing to scope clones in async contexts for clarity, minimizing the lifetime of borrowed references. + Example: + ```rust + executor.spawn({ + let task_ran = task_ran.clone(); + async move { + *task_ran.borrow_mut() = true; + } + }); + ``` + +# Timers in tests + +* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`: + - Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher. + - Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping. + +# GPUI + +GPUI is a UI framework which also provides primitives for state and concurrency management. + +## Context + +Context types allow interaction with global state, windows, entities, and system services. They are typically passed to functions as the argument named `cx`. When a function takes callbacks they come after the `cx` parameter. + +* `App` is the root context type, providing access to global state and read and update of entities. +* `Context` is provided when updating an `Entity`. This context dereferences into `App`, so functions which take `&App` can also take `&Context`. +* `AsyncApp` and `AsyncWindowContext` are provided by `cx.spawn` and `cx.spawn_in`. These can be held across await points. + +## `Window` + +`Window` provides access to the state of an application window. It is passed to functions as an argument named `window` and comes before `cx` when present. It is used for managing focus, dispatching actions, directly drawing, getting user input state, etc. + +## Entities + +An `Entity` is a handle to state of type `T`. With `thing: Entity`: + +* `thing.entity_id()` returns `EntityId` +* `thing.downgrade()` returns `WeakEntity` +* `thing.read(cx: &App)` returns `&T`. +* `thing.read_with(cx, |thing: &T, cx: &App| ...)` returns the closure's return value. +* `thing.update(cx, |thing: &mut T, cx: &mut Context| ...)` allows the closure to mutate the state, and provides a `Context` for interacting with the entity. It returns the closure's return value. +* `thing.update_in(cx, |thing: &mut T, window: &mut Window, cx: &mut Context| ...)` takes a `AsyncWindowContext` or `VisualTestContext`. It's the same as `update` while also providing the `Window`. + +Within the closures, the inner `cx` provided to the closure must be used instead of the outer `cx` to avoid issues with multiple borrows. + +Trying to update an entity while it's already being updated must be avoided as this will cause a panic. + +`WeakEntity` is a weak handle. It has `read_with`, `update`, and `update_in` methods that work the same, but always return an `anyhow::Result` so that they can fail if the entity no longer exists. This can be useful to avoid memory leaks - if entities have mutually recursive handles to each other they will never be dropped. + +## Concurrency + +All use of entities and UI rendering occurs on a single foreground thread. + +`cx.spawn(async move |cx| ...)` runs an async closure on the foreground thread. Within the closure, `cx` is `&mut AsyncApp`. + +When the outer cx is a `Context`, the use of `spawn` instead looks like `cx.spawn(async move |this, cx| ...)`, where `this: WeakEntity` and `cx: &mut AsyncApp`. + +To do work on other threads, `cx.background_spawn(async move { ... })` is used. Often this background task is awaited on by a foreground task which uses the results to update state. + +Both `cx.spawn` and `cx.background_spawn` return a `Task`, which is a future that can be awaited upon. If this task is dropped, then its work is cancelled. To prevent this one of the following must be done: + +* Awaiting the task in some other async context. +* Detaching the task via `task.detach()` or `task.detach_and_log_err(cx)`, allowing it to run indefinitely. +* Storing the task in a field, if the work should be halted when the struct is dropped. + +A task which doesn't do anything but provide a value can be created with `Task::ready(value)`. + +## Elements + +The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity` where `T` implements `Render` is sometimes called a "view". + +Example: + +``` +struct TextWithBorder(SharedString); + +impl Render for TextWithBorder { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().border_1().child(self.0.clone()) + } +} +``` + +Since `impl IntoElement for SharedString` exists, it can be used as an argument to `child`. `SharedString` is used to avoid copying strings, and is either an `&'static str` or `Arc`. + +UI components that are constructed just to be turned into elements can instead implement the `RenderOnce` trait, which is similar to `Render`, but its `render` method takes ownership of `self` and receives `&mut App` instead of `&mut Context`. Types that implement this trait can use `#[derive(IntoElement)]` to use them directly as children. + +The style methods on elements are similar to those used by Tailwind CSS. + +If some attributes or children of an element tree are conditional, `.when(condition, |this| ...)` can be used to run the closure only when `condition` is true. Similarly, `.when_some(option, |this, value| ...)` runs the closure when the `Option` has a value. + +## Input events + +Input event handlers can be registered on an element via methods like `.on_click(|event, window, cx: &mut App| ...)`. + +Often event handlers will want to update the entity that's in the current `Context`. The `cx.listener` method provides this - its use looks like `.on_click(cx.listener(|this: &mut T, event, window, cx: &mut Context| ...)`. + +## Actions + +Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`. + +Actions with no data are defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user. + +Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`. + +## Notify + +When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called. + +## Entity events + +While updating an entity (`cx: Context`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter for EntityType {}`. + +Other entities can then register a callback to handle these events by doing `cx.subscribe(other_entity, |this, other_entity, event, cx| ...)`. This will return a `Subscription` which deregisters the callback when dropped. Typically `cx.subscribe` happens when creating a new entity and the subscriptions are stored in a `_subscriptions: Vec` field. + +# Pull request hygiene + +When an agent opens or updates a pull request, it must: + +- Use a clear, correctly capitalized, imperative PR title (for example, `Fix crash in project panel`). +- Avoid conventional commit prefixes in PR titles (`fix:`, `feat:`, `docs:`, etc.). +- Avoid trailing punctuation in PR titles. +- Optionally prefix the title with a crate name when one crate is the clear scope (for example, `git_ui: Add history view`). +- Include a `Release Notes:` section as the final section in the PR body. +- Use one bullet under `Release Notes:`: + - `- Added ...`, `- Fixed ...`, or `- Improved ...` for user-facing changes, or + - `- N/A` for docs-only and other non-user-facing changes. +- Format release notes exactly with a blank line after the heading, for example: + +``` +Release Notes: + +- N/A +``` + +# Rules Hygiene + +These `.rules` files are read by every agent session. Keep them high-signal. + +## After any agentic session +If you discover a non-obvious pattern that would help future sessions, include a **"Suggested .rules additions"** heading in your PR description with the proposed text. Do **not** edit `.rules` inline during normal feature/fix work. Reviewers decide what gets merged. + +## High bar for new rules +Editing or clarifying existing rules is always welcome. New rules must meet **all three** criteria: +1. **Non-obvious** — someone familiar with the codebase would still get it wrong without the rule. +2. **Repeatedly encountered** — it came up more than once (multiple hits in one session counts). +3. **Specific enough to act on** — a concrete instruction, not a vague principle. + +Rules that apply to a single crate belong in that crate's own `.rules` file, not the repo root. + +## What NOT to put in `.rules` +Avoid architectural descriptions of a crate (module layout, data flow, key types). These go stale fast and the agent can gather them by reading the code. Rules should be **traps to avoid**, not **maps to follow**. + +## No drive-by additions +Rules emerge from validated patterns, not one-off observations. The workflow is: +1. Agent notes a pattern during a session. +2. Team validates the pattern in code review. +3. A dedicated commit adds the rule with context on *why* it exists. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8663ed5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +.rules diff --git a/Cargo.lock b/Cargo.lock index be49088..271f8bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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,9 +7967,11 @@ name = "signed_git" version = "0.1.0-alpha" dependencies = [ "anyhow", + "diffy", "gix", "gix-worktree", "gix-worktree-state", + "ignore", "nostr", "signed_core", "tempfile", diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index c73a298..b909b86 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -11,6 +11,9 @@ pub use addr::{RepoAddr, identifier_from_name, repo_addr}; pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override}; pub use clone_url::{CloneTarget, parse_clone_url}; pub use deletions::Deletions; -pub use model::{Announcement, activity_subject, pull_request_patch, pull_request_patches}; +pub use model::{ + Announcement, activity_subject, branch_name_of, clone_urls_of, current_commit_of, + fork_candidates, latest_update, merge_base_of, pull_request_patch, pull_request_patches, +}; pub use state::{build_state, parse_state}; pub use status::{RepoStatus, references_root, resolve_status}; diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 8fc87c2..e600c52 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -180,7 +180,7 @@ fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> } /// The `c` tag of an event, the tip of the proposed branch, as hex. -fn current_commit_of(event: &Event) -> Option { +pub fn current_commit_of(event: &Event) -> Option { event .tags .iter() @@ -190,6 +190,82 @@ fn current_commit_of(event: &Event) -> Option { }) } +/// The `merge-base` tag of an event, the base commit a pull request diffs against. +pub fn merge_base_of(event: &Event) -> Option { + event + .tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()), + _ => None, + }) +} + +/// The `clone` tag of an event, URLs the tip commit can be fetched from. +pub fn clone_urls_of(event: &Event) -> Option> { + event + .tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Clone(urls)) => Some(urls), + _ => None, + }) +} + +/// The `branch-name` tag of an event, the proposed branch's name. +pub fn branch_name_of(event: &Event) -> Option { + event + .tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::BranchName(name)) => Some(name), + _ => None, + }) +} + +/// The newest `GitPullRequestUpdate` revising `root`, from the root's own author. +/// +/// A pull request's tip is only mutable by its author, per NIP-34; updates +/// from anyone else are ignored even if they are newer. +pub fn latest_update<'a>( + events: impl Iterator, + root: &Event, +) -> Option<&'a Event> { + let root_hex = root.id.to_hex(); + events + .filter(|e| e.kind == Kind::GitPullRequestUpdate) + .filter(|e| e.pubkey == root.pubkey) + .filter(|e| { + e.tags + .iter() + .any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str())) + }) + .max_by_key(|e| e.created_at) +} + +/// The announced forks of `base` a new pull request compare can be built from. +/// +/// The user's own forks are listed first. +pub fn fork_candidates<'a>( + announcements: &'a [Announcement], + base: &RepoAddr, + base_euc: Option<&str>, + user: Option, +) -> Vec<&'a Announcement> { + let (mut own, mut others) = (Vec::new(), Vec::new()); + for announcement in announcements { + if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) { + continue; + } + if Some(announcement.owner) == user { + own.push(announcement); + } else { + others.push(announcement); + } + } + own.into_iter().chain(others).collect() +} + /// Whether `patch` produces `commit`, found via its `commit` or `r` tag. /// /// It lets clients find existing patches for a specific commit. @@ -727,4 +803,221 @@ mod tests { vec!["patch-one", "patch-two"] ); } + + const COMMIT_HEX: &str = "1111111111111111111111111111111111111111"; + const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222"; + + /// Build a signed event of `kind` with the given tags and `created_at`. + fn signed_at(kind: Kind, tags: Vec, created_at: u64) -> Event { + EventBuilder::new(kind, "") + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .finalize(&keys()) + .expect("signed event") + } + + fn pr_root() -> Event { + signed_at( + Kind::GitPullRequest, + vec![ + Tag::parse(["c", COMMIT_HEX]).expect("valid tag"), + Tag::parse(["branch-name", "feature/x"]).expect("valid tag"), + ], + 100, + ) + } + + #[test] + fn reads_current_commit_and_branch_name() { + let pr = pr_root(); + assert_eq!(current_commit_of(&pr).as_deref(), Some(COMMIT_HEX)); + assert_eq!(branch_name_of(&pr).as_deref(), Some("feature/x")); + } + + #[test] + fn returns_none_without_pr_tags() { + let pr = signed_at(Kind::GitPullRequest, vec![], 100); + assert_eq!(current_commit_of(&pr), None); + assert_eq!(branch_name_of(&pr), None); + } + + #[test] + fn latest_update_picks_newest_revision_of_the_root() { + let root = pr_root(); + let root_hex = root.id.to_hex(); + + let revision = |created_at: u64| { + signed_at( + Kind::GitPullRequestUpdate, + vec![Tag::parse(["E", &root_hex]).expect("valid tag")], + created_at, + ) + }; + // An update revising a different PR must be ignored even though it is newer. + let unrelated = signed_at( + Kind::GitPullRequestUpdate, + vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")], + 999, + ); + + let events = [unrelated, revision(200), root.clone(), revision(300)]; + let latest = latest_update(events.iter(), &root).expect("an update"); + + assert_eq!(latest.created_at.as_secs(), 300); + assert_eq!(latest.kind, Kind::GitPullRequestUpdate); + } + + #[test] + fn latest_update_ignores_other_authors() { + let root = pr_root(); + let root_hex = root.id.to_hex(); + let other = Keys::new( + SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002") + .expect("valid secret key"), + ); + let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "") + .tags([Tag::parse(["E", &root_hex]).expect("valid tag")]) + .custom_created_at(Timestamp::from(999)) + .finalize(&other) + .expect("signed event"); + + // The tip of a PR is only mutable by its author. + // A newer update from anyone else must not win. + assert!(latest_update([&stranger, &root].into_iter(), &root).is_none()); + } + + #[test] + fn latest_update_ignores_roots_without_revisions() { + let root = pr_root(); + assert!(latest_update([&root].into_iter(), &root).is_none()); + } + + const OWNER_KEYS: [&str; 3] = [ + "0000000000000000000000000000000000000000000000000000000000000001", + "0000000000000000000000000000000000000000000000000000000000000002", + "0000000000000000000000000000000000000000000000000000000000000003", + ]; + + /// Build a signed kind-30617 event for `owner` with the given tags. + fn owned_announcement_event(owner: &str, tags: &[&[&str]]) -> Event { + let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key")); + let tags: Vec = tags + .iter() + .map(|t| Tag::parse(t.to_vec()).expect("valid tag")) + .collect(); + EventBuilder::new(Kind::GitRepoAnnouncement, "") + .tags(tags) + .finalize(&keys) + .expect("signed event") + } + + fn owned_announcements(owner_ix: usize, tags: &[&[&str]]) -> Vec { + vec![ + Announcement::from_event(&owned_announcement_event(OWNER_KEYS[owner_ix], tags)) + .expect("parses"), + ] + } + + #[test] + fn fork_candidates_orders_own_forks_first() { + let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; + let clone = "https://grasp.example/npub1x/my-fork.git"; + + let base_addr = crate::repo_addr( + PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"), + "upstream", + ); + // Newest first, as RepoListStore keeps them. + // Unrelated repo, the user's fork with the shared EUC, another fork with a `u` tag. + let all = vec![ + owned_announcements( + 2, + &[ + &["d", "other-project"], + &["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"], + ], + ) + .pop() + .unwrap(), + owned_announcements( + 1, + &[&["d", "my-fork"], &["r", euc, "euc"], &["clone", clone]], + ) + .pop() + .unwrap(), + owned_announcements( + 2, + &[ + &["d", "their-fork"], + &["u", &base_addr.to_string()], + &["clone", clone], + ], + ) + .pop() + .unwrap(), + ]; + + let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey"); + let forks = fork_candidates(&all, &base_addr, Some(euc), Some(user)); + + // The user's fork comes first, then the other author's. + let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect(); + assert_eq!(ids, vec!["my-fork", "their-fork"]); + } + + #[test] + fn fork_candidates_excludes_base_unrelated_and_unfetchable() { + let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; + let base_owner = PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"); + let base_addr = crate::repo_addr(base_owner, "upstream"); + + let mut all = vec![ + owned_announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]]) + .pop() + .unwrap(), + owned_announcements(1, &[&["d", "no-clone-fork"], &["r", euc, "euc"]]) + .pop() + .unwrap(), + owned_announcements( + 2, + &[ + &["d", "other"], + &["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"], + ], + ) + .pop() + .unwrap(), + owned_announcements( + 2, + &[ + &["d", "mirror"], + &["r", euc, "euc"], + &["clone", "https://grasp.example/x/mirror.git"], + ], + ) + .pop() + .unwrap(), + ]; + + let forks = fork_candidates(&all, &base_addr, Some(euc), Some(base_owner)); + assert_eq!(forks.len(), 1); + assert_eq!(forks[0].id, "mirror"); + + // Without a base EUC only `u`-tag forks match. + all.push( + owned_announcements( + 2, + &[ + &["d", "u-fork"], + &["u", &base_addr.to_string()], + &["clone", "https://grasp.example/x/u-fork.git"], + ], + ) + .pop() + .unwrap(), + ); + let forks = fork_candidates(&all, &base_addr, None, Some(base_owner)); + let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect(); + assert_eq!(ids, vec!["u-fork"]); + } } diff --git a/crates/signed_git/Cargo.toml b/crates/signed_git/Cargo.toml index ef29ece..a4cd750 100644 --- a/crates/signed_git/Cargo.toml +++ b/crates/signed_git/Cargo.toml @@ -12,6 +12,8 @@ gix = { workspace = true, features = ["revision", "blob-diff"] } gix-worktree = "0.56" gix-worktree-state = "0.34" anyhow.workspace = true +diffy = "0.5" +ignore = "0.4" [dev-dependencies] tempfile = "3" diff --git a/crates/signed_git/src/cache.rs b/crates/signed_git/src/cache.rs new file mode 100644 index 0000000..bdfdde1 --- /dev/null +++ b/crates/signed_git/src/cache.rs @@ -0,0 +1,96 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use signed_core::{Announcement, RepoAddr}; + +use crate::remote::{clone_repo, fetch_all}; + +/// On-disk cache of cloned repositories, keyed by owner pubkey / repo id. +#[derive(Debug, Clone)] +pub struct GitCache { + root: PathBuf, +} + +impl GitCache { + pub fn new(root: PathBuf) -> Self { + Self { root } + } + + /// The root directory holding the mirror clones. + pub fn root(&self) -> &Path { + &self.root + } + + /// Local path of the clone for a repository. + pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf { + self.root + .join(addr.public_key.to_hex()) + .join(sanitize_path_component(&addr.identifier)) + } + + /// Open an existing clone. + pub fn open(&self, addr: &RepoAddr) -> Result> { + let path = self.repo_path(addr); + match gix::open(&path) { + Ok(repo) => Ok(Some(repo)), + Err(gix::open::Error::NotARepository { .. }) => Ok(None), + Err(gix::open::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// Open the existing clone, fetching it first. + pub fn ensure_clone>( + &self, + addr: &RepoAddr, + clone_urls: &[U], + ) -> Result { + let path = self.repo_path(addr); + + if let Some(repo) = self.open(addr)? { + fetch_all(&repo).ok(); + return Ok(repo); + } + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + + clone_repo(clone_urls, &path)?; + self.open(addr)? + .ok_or_else(|| anyhow::anyhow!("clone finished but the repository cannot be opened")) + } +} + +/// Map an untrusted repository id or display name to a safe single path component. +/// +/// Everything outside `[A-Za-z0-9._-]` becomes `_`. +/// An id that maps to exactly `.` or `..` becomes `_`. +pub fn sanitize_path_component(id: &str) -> String { + let sanitized: String = id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + }) + .collect(); + + if sanitized == "." || sanitized == ".." { + return "_".to_owned(); + } + + sanitized +} + +/// The refs namespace of a fork's import in the target mirror. +pub fn fork_namespace(announcement: &Announcement) -> String { + format!( + "{}/{}", + announcement.owner.to_hex(), + sanitize_path_component(&announcement.id) + ) +} diff --git a/crates/signed_git/src/diff.rs b/crates/signed_git/src/diff.rs new file mode 100644 index 0000000..cffc382 --- /dev/null +++ b/crates/signed_git/src/diff.rs @@ -0,0 +1,304 @@ +use std::path::Path; + +use anyhow::Result; +use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader}; + +/// The kind of a [`DiffLine`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiffLineKind { + /// An unchanged context line, present on both sides. + Context, + /// A line added by the commit. + Addition, + /// A line removed by the commit. + Deletion, +} + +/// One line of a file diff. +#[derive(Debug, Clone)] +pub struct DiffLine { + pub kind: DiffLineKind, + /// 1-based line number in the old version, if the line exists there. + pub old: Option, + /// 1-based line number in the new version, if the line exists there. + pub new: Option, + /// Line content without the trailing newline. + pub text: String, +} + +/// A hunk of a file diff, like `@@ -a,b +c,d @@`. +#[derive(Debug, Clone)] +pub struct DiffHunk { + /// 1-based start line in the old version. + pub old_start: u32, + /// Number of old lines covered by the hunk. + pub old_lines: u32, + /// 1-based start line in the new version. + pub new_start: u32, + /// Number of new lines covered by the hunk. + pub new_lines: u32, + pub lines: Vec, +} + +/// How a file changed in a commit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiffStatus { + Added, + Modified, + Deleted, + Renamed, + Copied, +} + +/// The diff of one file in a commit. +#[derive(Debug, Clone)] +pub struct FileDiff { + /// Path of the file relative to the repo root. + /// + /// For renames and copies, this is the destination path. + pub path: String, + /// Previous path, for renames and copies. + pub old_path: Option, + pub status: DiffStatus, + /// Number of added lines, 0 for binary files. + pub insertions: usize, + /// Number of removed lines, 0 for binary files. + pub deletions: usize, + /// True if either version is binary, then `hunks` is empty. + pub binary: bool, + pub hunks: Vec, +} + +/// The changes of one commit. +#[derive(Debug, Clone)] +pub struct CommitDiff { + pub files: Vec, +} + +/// The changes of the commit `id`, short or full, in the repository at `workdir`. +/// +/// Compared against its first parent, the empty tree for the root commit. +pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result { + commit_diff(&gix::open(workdir)?, id) +} + +fn commit_diff(repo: &gix::Repository, id: &str) -> Result { + let commit_id = repo.rev_parse_single(id.as_bytes())?; + let commit = commit_id.object()?.into_commit(); + let new_tree = commit.tree()?; + let old_tree = match commit.parent_ids().next() { + Some(parent) => Some(parent.object()?.into_commit().tree()?), + None => None, + }; + tree_diff(repo, old_tree.as_ref(), &new_tree) +} + +/// The changes between two commits, `base`..`tip`, like `git diff base tip`. +/// +/// Directories and submodules are skipped, files are sorted by path. +pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result { + let repo = gix::open(workdir)?; + let base_tree = repo + .rev_parse_single(base.as_bytes())? + .object()? + .into_commit() + .tree()?; + let tip_tree = repo + .rev_parse_single(tip.as_bytes())? + .object()? + .into_commit() + .tree()?; + tree_diff(&repo, Some(&base_tree), &tip_tree) +} +/// The changes between two trees. Used by both [`commit_diff`] and [`worktree_commit_range_diff`]. +fn tree_diff( + repo: &gix::Repository, + old_tree: Option<&gix::Tree<'_>>, + new_tree: &gix::Tree<'_>, +) -> Result { + use gix::diff::blob::platform::prepare_diff::Operation; + use gix::object::tree::diff::Change; + use gix::objs::tree::EntryKind; + + let changes = repo.diff_tree_to_tree(old_tree, Some(new_tree), None)?; + + let mut cache = repo.diff_resource_cache_for_tree_diff()?; + let mut files = Vec::new(); + + for change in changes { + let attached = Change::from_change_ref(change.to_ref(), repo, repo); + + // Skip directory trees and submodule gitlinks, only files are listed. + let (path, old_path, status) = match attached { + Change::Addition { + location, + entry_mode, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { + (location.to_owned(), None, DiffStatus::Added) + } + Change::Deletion { + location, + entry_mode, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { + (location.to_owned(), None, DiffStatus::Deleted) + } + Change::Modification { + location, + previous_entry_mode, + entry_mode, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) + && !matches!( + previous_entry_mode.kind(), + EntryKind::Tree | EntryKind::Commit + ) => + { + (location.to_owned(), None, DiffStatus::Modified) + } + Change::Rewrite { + location, + source_location, + source_entry_mode, + entry_mode, + copy, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) + && !matches!( + source_entry_mode.kind(), + EntryKind::Tree | EntryKind::Commit + ) => + { + let status = if copy { + DiffStatus::Copied + } else { + DiffStatus::Renamed + }; + ( + location.to_owned(), + Some(source_location.to_owned()), + status, + ) + } + _ => continue, + }; + + // Always diff with the built-in algorithm. + // External diff drivers would shell out, out of scope for a read-only viewer. + let platform = attached.diff(&mut cache)?; + platform + .resource_cache + .options + .skip_internal_diff_if_external_is_configured = true; + let outcome = platform.resource_cache.prepare_diff()?; + + let (binary, hunks, insertions, deletions) = match outcome.operation { + Operation::InternalDiff { algorithm } => { + let input = outcome.interned_input(); + let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input); + + let mut hunks = Vec::new(); + let mut insertions = 0usize; + let mut deletions = 0usize; + let collector = HunkCollector { + hunks: &mut hunks, + insertions: &mut insertions, + deletions: &mut deletions, + }; + gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default()) + .consume()?; + (false, hunks, insertions, deletions) + } + Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0), + Operation::ExternalCommand { .. } => unreachable!("external diff drivers are disabled"), + }; + + files.push(FileDiff { + path: String::from_utf8_lossy(&path).into_owned(), + old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()), + status, + insertions, + deletions, + binary, + hunks, + }); + } + + files.sort_by(|a, b| a.path.cmp(&b.path)); + + Ok(CommitDiff { files }) +} + +/// Collects the hunks of one blob diff while tracking per-line numbers. +struct HunkCollector<'a> { + hunks: &'a mut Vec, + insertions: &'a mut usize, + deletions: &'a mut usize, +} + +impl ConsumeHunk for HunkCollector<'_> { + type Out = (); + + fn consume_hunk( + &mut self, + header: HunkHeader, + lines: &[(GixLineKind, &[u8])], + ) -> std::io::Result<()> { + let mut old_ln = header.before_hunk_start; + let mut new_ln = header.after_hunk_start; + let mut out = Vec::with_capacity(lines.len()); + + for (kind, content) in lines { + let text = String::from_utf8_lossy(content).into_owned(); + let line = match kind { + GixLineKind::Context => { + let line = DiffLine { + kind: DiffLineKind::Context, + old: Some(old_ln), + new: Some(new_ln), + text, + }; + old_ln += 1; + new_ln += 1; + line + } + GixLineKind::Remove => { + *self.deletions += 1; + let line = DiffLine { + kind: DiffLineKind::Deletion, + old: Some(old_ln), + new: None, + text, + }; + old_ln += 1; + line + } + GixLineKind::Add => { + *self.insertions += 1; + let line = DiffLine { + kind: DiffLineKind::Addition, + old: None, + new: Some(new_ln), + text, + }; + new_ln += 1; + line + } + }; + out.push(line); + } + + self.hunks.push(DiffHunk { + old_start: header.before_hunk_start, + old_lines: header.before_hunk_len, + new_start: header.after_hunk_start, + new_lines: header.after_hunk_len, + lines: out, + }); + + Ok(()) + } + + fn finish(self) {} +} diff --git a/crates/signed_git/src/history.rs b/crates/signed_git/src/history.rs new file mode 100644 index 0000000..731ce32 --- /dev/null +++ b/crates/signed_git/src/history.rs @@ -0,0 +1,267 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +/// In-memory object cache for history walks, see [`open_with_cache`]. +/// +/// Without one, a walk re-decodes the same commit objects from the object database. +/// Sized generously: a walk can cover a large portion of the repository's history. +const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024; + +/// Metadata of a commit, as shown in the repository browser's file header. +#[derive(Debug, Clone)] +pub struct FileCommit { + /// Shortened commit id, 7+ hex chars, disambiguated if needed. + pub id: String, + /// First line of the commit message. + pub summary: String, + /// Rest of the commit message after the title. + /// + /// `None` for single-line commit messages. + pub description: Option, + /// Author name. + pub author: String, + /// Author time, seconds since the Unix epoch. + pub time: i64, +} + +/// Open the repository at `workdir` with an in-memory object cache. +/// +/// Only history walks use it, they re-decode the same commit objects repeatedly. +/// Single-object reads open the repository plain. +pub(crate) fn open_with_cache(workdir: &Path) -> Result { + let mut repo = gix::open(workdir)?; + repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES); + Ok(repo) +} + +/// A [`FileCommit`] from a commit, with author, message title, body and shortened id. +/// +/// The diff panel fetches the full commit on demand. +fn file_commit(commit: &gix::Commit<'_>) -> Result { + file_commit_with_description(commit, true) +} + +/// A [`FileCommit`] without the message body, for history lists that never display it. +/// +/// Skipping the body saves an allocation per listed commit. +fn file_commit_summary(commit: &gix::Commit<'_>) -> Result { + file_commit_with_description(commit, false) +} + +/// [`file_commit`] and [`file_commit_summary`], `include_description` picks the body. +fn file_commit_with_description( + commit: &gix::Commit<'_>, + include_description: bool, +) -> Result { + let author = commit.author()?; + let message = commit.message()?; + + Ok(FileCommit { + id: commit.id().shorten_or_id().to_string(), + summary: String::from_utf8_lossy(message.title).trim().to_string(), + description: if include_description { + message + .body + .map(|body| String::from_utf8_lossy(body).trim().to_string()) + .filter(|body| !body.is_empty()) + } else { + None + }, + author: String::from_utf8_lossy(author.name).trim().to_string(), + time: author.time()?.seconds, + }) +} + +/// Find the most recent commit that changed `rel`, a path relative to the worktree. +/// +/// `Ok(None)` when no commit touched the file, e.g. an untracked file. +pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result> { + let rel = rel.to_path_buf(); + Ok(last_commits(repo, std::slice::from_ref(&rel))? + .into_iter() + .next() + .map(|(_, commit)| commit)) +} + +/// Newest commit touching each of `rels`, like `git log -1 -- ` per path. +/// `rels` are paths relative to the worktree. +/// +/// Paths without any commit, like untracked files, are absent from the result. +pub fn worktree_last_commits( + workdir: &Path, + rels: &[PathBuf], +) -> Result> { + last_commits(&open_with_cache(workdir)?, rels) +} + +/// The walk behind [`last_commit`] and [`worktree_last_commits`]. +/// +/// Stops as soon as every pending path has its commit. +fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result> { + use gix::traverse::commit::simple::CommitTimeOrder; + + let Some(head) = repo.head_id().ok() else { + return Ok(Vec::new()); + }; + + // De-duplicate while preserving order. + let mut pending: Vec = Vec::with_capacity(rels.len()); + let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len()); + + for rel in rels { + if seen.insert(rel.as_path()) { + pending.push(rel.clone()); + } + } + + let walk = repo + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + CommitTimeOrder::NewestFirst, + )); + + let mut found = Vec::new(); + for info in walk.all()? { + if pending.is_empty() { + break; + } + let info = info?; + let commit = info.object()?; + let tree = commit.tree()?; + let parent_tree = match info.parent_ids().next() { + Some(parent) => Some(parent.object()?.into_commit().tree()?), + None => None, + }; + + // Compare each unresolved path against this commit and its first parent. + // Resolved paths leave the pending set. + let mut ix = 0; + while ix < pending.len() { + let rel = &pending[ix]; + let blob = tree.lookup_entry_by_path(rel)?; + let parent_blob = match &parent_tree { + Some(tree) => tree.lookup_entry_by_path(rel)?, + None => None, + }; + + if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach()) + { + found.push((rel.clone(), file_commit(&commit)?)); + pending.swap_remove(ix); + } else { + ix += 1; + } + } + } + + Ok(found) +} + +/// Cap on [`CommitList::commits`]. The virtual list renders a window at a time, +/// the tab badge shows the real count. +/// +/// A huge history is never fully materialized in memory. +pub const MAX_LISTED_COMMITS: usize = 20_000; + +/// Commits reachable from `HEAD`, newest first, possibly capped. +pub struct CommitList { + /// Number of commits reachable from HEAD. + pub total: usize, + /// Newest commits, capped at [`MAX_LISTED_COMMITS`]. + pub commits: Vec, +} + +/// All commits reachable from `HEAD`, newest first, with author and summary. +/// +/// Returns an empty list for a repository without any commits yet. +pub fn all_commits(repo: &gix::Repository) -> Result { + use gix::traverse::commit::simple::CommitTimeOrder; + + let Some(head) = repo.head_id().ok() else { + return Ok(CommitList { + total: 0, + commits: Vec::new(), + }); + }; + + let walk = repo + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + CommitTimeOrder::NewestFirst, + )); + + let mut commits = Vec::new(); + let mut total = 0; + + for info in walk.all()? { + let info = info?; + total += 1; + if commits.len() < MAX_LISTED_COMMITS { + commits.push(file_commit_summary(&info.object()?)?); + } + } + + Ok(CommitList { total, commits }) +} + +/// Like [`all_commits`], but opens the repository at `workdir` first. +/// +/// For non-bare clones the clone root is the worktree. +pub fn worktree_all_commits(workdir: &Path) -> Result { + all_commits(&open_with_cache(workdir)?) +} + +/// Commits in the range `base`..`tip`, newest first, like `git log base..tip`. +pub fn worktree_commit_range_commits( + workdir: &Path, + base: &str, + tip: &str, +) -> Result> { + use gix::traverse::commit::simple::CommitTimeOrder; + + let repo = open_with_cache(workdir)?; + let base_id = repo.rev_parse_single(base.as_bytes())?; + let tip_id = repo.rev_parse_single(tip.as_bytes())?; + let walk = repo + .rev_walk([tip_id]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + CommitTimeOrder::NewestFirst, + )) + .with_hidden([base_id]); + + let mut commits = Vec::new(); + + for info in walk.all()? { + let info = info?; + commits.push(file_commit_summary(&info.object()?)?); + } + + Ok(commits) +} +/// The commit HEAD points to, like `git log -1`. +/// +/// `Ok(None)` for a repository without commits yet, an unborn HEAD. +pub fn head_commit(repo: &gix::Repository) -> Result> { + let Some(head) = repo.head_id().ok() else { + return Ok(None); + }; + let commit = head.object()?.into_commit(); + Ok(Some(file_commit(&commit)?)) +} + +/// Full metadata of the commit `id`, short or full, in the repository at `workdir`. +/// Like [`head_commit`] for an arbitrary commit. +/// +/// `Ok(None)` when the id cannot be resolved. +pub fn worktree_commit(workdir: &Path, id: &str) -> Result> { + let repo = gix::open(workdir)?; + match repo.rev_parse_single(id.as_bytes()) { + Ok(commit_id) => { + let commit = commit_id.object()?.into_commit(); + Ok(Some(file_commit(&commit)?)) + } + Err(_) => Ok(None), + } +} diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 86974df..bb92b84 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -1,1030 +1,52 @@ -use std::collections::{HashMap, HashSet}; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; - -use anyhow::{Context, Result, bail}; -use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader}; -use gix::interrupt::IS_INTERRUPTED; -use gix::progress::Discard; -use signed_core::RepoAddr; - -/// On-disk cache of cloned repositories, keyed by owner pubkey / repo id. -#[derive(Debug, Clone)] -pub struct GitCache { - root: PathBuf, -} - -impl GitCache { - pub fn new(root: PathBuf) -> Self { - Self { root } - } - - /// The root directory holding the mirror clones. - pub fn root(&self) -> &Path { - &self.root - } - - /// Local path of the clone for a repository. - pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf { - self.root - .join(addr.public_key.to_hex()) - .join(sanitize_path_component(&addr.identifier)) - } - - /// Open an existing clone. - pub fn open(&self, addr: &RepoAddr) -> Result> { - let path = self.repo_path(addr); - match gix::open(&path) { - Ok(repo) => Ok(Some(repo)), - Err(gix::open::Error::NotARepository { .. }) => Ok(None), - Err(gix::open::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e.into()), - } - } - - /// Open the existing clone, fetching it first. - pub fn ensure_clone(&self, addr: &RepoAddr, clone_urls: &[String]) -> Result { - let path = self.repo_path(addr); - - if let Some(repo) = self.open(addr)? { - fetch_all(&repo).ok(); - return Ok(repo); - } - - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - - clone_repo(clone_urls, &path)?; - self.open(addr)? - .ok_or_else(|| anyhow::anyhow!("clone finished but the repository cannot be opened")) - } -} - -/// Maximum directory nesting depth when scanning for local repositories. -/// -/// Pathological trees can't stall the scan. -const SCAN_MAX_DEPTH: usize = 12; - -/// Directories never descended into during a scan. -/// -/// Dependency caches can be enormous without ever containing user repositories. -const SCAN_SKIPPED_DIR: &str = "node_modules"; - -/// Walk `root` recursively and collect the paths of git repositories below it. -pub fn find_git_repos(root: &Path) -> Vec { - let mut repos = Vec::new(); - if !root.is_dir() { - return repos; - } - - let mut stack = vec![(root.to_path_buf(), 0usize)]; - while let Some((dir, depth)) = stack.pop() { - if depth > SCAN_MAX_DEPTH { - continue; - } - // A directory containing a `.git` entry is a repository. - // A linked worktree has a `.git` file instead of a directory. - // Don't descend into repositories. - if dir.join(".git").exists() { - if let Ok(path) = dir.canonicalize() { - repos.push(path); - } - continue; - } - - let Ok(entries) = std::fs::read_dir(&dir) else { - continue; - }; - for entry in entries.flatten() { - let Ok(file_type) = entry.file_type() else { - continue; - }; - if !file_type.is_dir() || file_type.is_symlink() { - continue; - } - let file_name = entry.file_name(); - let name = file_name.to_string_lossy(); - if name.starts_with('.') || name == SCAN_SKIPPED_DIR { - continue; - } - stack.push((entry.path(), depth + 1)); - } - } - - repos.sort(); - repos.dedup(); - repos -} - -/// Clone into `path` from the first working URL in `clone_urls`. -/// -/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache. -pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> { - if path.exists() { - bail!("destination {} already exists", path.display()); - } - - try_each_url(clone_urls, "clone", |url| { - let repo = clone(url, path)?; - // The initial clone uses the default refspecs. - // Also fetch the `refs/nostr/*` PR refs. - fetch_all(&repo).ok(); - Ok(()) - }) -} - -/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace. -pub fn fetch_all(repo: &gix::Repository) -> Result<()> { - let options = gix::remote::ref_map::Options { - extra_refspecs: vec![ - gix::refspec::parse( - gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"), - gix::refspec::parse::Operation::Fetch, - )? - .to_owned(), - ], - ..Default::default() - }; - repo.find_remote("origin")? - .connect(gix::remote::Direction::Fetch)? - .prepare_fetch(Discard, options)? - .receive(Discard, &IS_INTERRUPTED)?; - Ok(()) -} - -/// Apply a `git format-patch` patch or series with `git am`, -/// uses the git CLI because it handles the mbox format natively. -/// -/// TODO: Replaced with a pure-Rust implementation later without changing callers. -pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> { - let mut child = Command::new("git") - .arg("am") - .current_dir(repo_path) - .stdin(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .context("failed to spawn `git am`")?; - - child - .stdin - .as_mut() - .expect("stdin piped") - .write_all(patch.as_bytes())?; - - let output = child.wait_with_output()?; - if !output.status.success() { - bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr)); - } - Ok(()) -} - -/// The merge base of two revisions in the repository at `repo_path`, -/// revisions may be branch names, remote-tracking refs or commit ids. -/// -/// `Ok(None)` when the revisions share no common ancestor. -/// -/// Unresolvable revisions are errors. -pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result> { - let repo = open_with_cache(repo_path)?; - let a = repo.rev_parse_single(a.as_bytes())?; - let b = repo.rev_parse_single(b.as_bytes())?; - match repo.merge_base(a, b) { - Ok(id) => Ok(Some(id.to_string())), - // No common ancestor, a valid outcome for a proposal. - Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None), - Err(e) => Err(e.into()), - } -} - -/// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`. -/// Fails when the range has no commits. -/// -/// The mbox is returned untrimmed. Trailing newlines are part of the format. -pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result { - let output = Command::new("git") - .arg("-C") - .arg(repo_path) - .args(["format-patch", "--stdout", &format!("{base}..{tip}")]) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .context("failed to spawn `git format-patch`")?; - - if !output.status.success() { - bail!( - "git format-patch failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - let patch = String::from_utf8_lossy(&output.stdout).into_owned(); - if patch.trim().is_empty() { - bail!("no commits between {base} and {tip}"); - } - Ok(patch) -} - -/// Push `commit` to `reference` on the server at `url`, from `repo_path`. -pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> { - let output = Command::new("git") - .arg("-C") - .arg(repo_path) - .args(["push"]) - .arg(url) - .arg(format!("{commit}:{reference}")) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .context("failed to spawn `git push`")?; - - if !output.status.success() { - bail!( - "git push failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(()) -} - -/// Split a `git format-patch` series into its individual patches, mbox messages. -/// -/// A single patch yields one element. -/// A malformed input yields one element covering it. -pub fn split_patch_series(patch: &str) -> Vec<&str> { - let mut starts = vec![0usize]; - let mut search_from = 1; - while let Some(rel) = patch[search_from..].find("\nFrom ") { - let ix = search_from + rel + 1; - let hex = patch[ix + 5..] - .split(|c: char| !c.is_ascii_hexdigit()) - .next() - .unwrap_or(""); - if hex.len() == 40 { - starts.push(ix); - } - search_from = ix + 1; - } - - starts - .iter() - .enumerate() - .map(|(i, &start)| { - let end = starts.get(i + 1).copied().unwrap_or(patch.len()); - &patch[start..end] - }) - .collect() -} - -/// The commit HEAD points to in the repository at `repo_path`. -/// -/// `None` when the repository has no commits yet, an unborn HEAD. -pub fn head_commit_id(repo_path: &Path) -> Result> { - let Ok(repo) = gix::open(repo_path) else { - return Ok(None); - }; - - match repo.head_id() { - Ok(id) => Ok(Some(id.to_string())), - Err(_) => Ok(None), - } -} - -/// The commits in `base..HEAD` of the repository at `repo_path`, oldest first. -/// This is the order `git am` creates them. -/// -/// `HEAD` alone when `base` is `None`. -pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result> { - let repo = match gix::open(repo_path) { - Ok(repo) => repo, - Err(_) if base.is_none() => return Ok(Vec::new()), - Err(e) => return Err(e.into()), - }; - - let head = match repo.head_id() { - Ok(head) => head, - Err(_) if base.is_none() => return Ok(Vec::new()), - Err(e) => return Err(e).context("repository has no commits"), - }; - - let Some(base) = base else { - // `HEAD` alone when no base is given. - return Ok(vec![head.to_string()]); - }; - - let base = repo.rev_parse_single(base.as_bytes())?; - let mut commits = Vec::new(); - - for info in repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, - )) - .with_hidden([base]) - .all()? - { - commits.push(info?.id().to_string()); - } - - // Oldest first, like `git rev-list --reverse`, the order `git am` creates them. - commits.reverse(); - - Ok(commits) -} - -/// Rewrite a grasp server URL to the https URL the git transport actually uses. -/// -/// GRASP servers announce `grasp:////` clone URLs. -/// The transport is git smart HTTP, so the scheme is rewritten for gix. -fn transport_url(url: &str) -> String { - url.strip_prefix("grasp://") - .map(|rest| format!("https://{rest}")) - .unwrap_or_else(|| url.to_owned()) -} - -/// Run `attempt` against each URL in `urls` until one succeeds. -/// -/// Returns the last error wrapped in `failed to {verb} from any mirror`, -/// or `no clone URLs provided` when the list is empty. -fn try_each_url(urls: &[String], verb: &str, mut attempt: F) -> Result<()> -where - F: FnMut(&str) -> Result<()>, -{ - let mut last_err: Option = None; - - for url in urls { - match attempt(url) { - Ok(()) => return Ok(()), - Err(e) => last_err = Some(e), - } - } - - match last_err { - Some(e) => Err(e).context(format!("failed to {verb} from any mirror")), - None => bail!("no clone URLs provided"), - } -} - -fn clone(url: &str, path: &Path) -> Result { - let url = transport_url(url); - let url = gix::url::parse(url).context("invalid clone URL")?; - - let mut prepare = gix::prepare_clone(url, path)?; - let (mut checkout, _fetch) = prepare.fetch_then_checkout(Discard, &IS_INTERRUPTED)?; - let (repo, _checkout) = checkout.main_worktree(Discard, &IS_INTERRUPTED)?; - - Ok(repo) -} - -/// The identity written to reflogs and commits created by this crate itself. -/// -/// Like `git -c user.name=… -c user.email=…` per invocation: the repository works -/// without a global git identity, and `gix` runs no hooks and never signs. -fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) { - let seconds = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_secs() as i64) - .unwrap_or_default(); - let signature = gix::actor::Signature { - name: gix::bstr::BString::from("Signed"), - email: gix::bstr::BString::from("signed@localhost"), - time: gix::date::Time { seconds, offset: 0 }, - }; - (signature, gix::date::parse::TimeBuf::default()) -} - -/// Create a repository at `path` with an initial `main` branch. -/// Write a `README.md` from `name` and `description`, then create the initial commit. -/// -/// Returns the initial commit id. -pub fn init_repository(path: &Path, name: &str, description: &str) -> Result { - use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; - - std::fs::create_dir_all(path) - .with_context(|| format!("failed to create {}", path.display()))?; - - let repo = gix::init(path)?; - - let (signature, mut time_buf) = repository_signature(); - let signature = signature.to_ref(&mut time_buf); - - // The initial branch is `main`, regardless of `init.defaultBranch` in - // the user's git configuration: point the unborn HEAD there. - let head = gix::refs::FullName::try_from("HEAD") - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - - repo.edit_references_as( - [RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: "checkout: moving to main".into(), - }, - expected: PreviousValue::Any, - new: gix::refs::Target::Symbolic( - gix::refs::FullName::try_from("refs/heads/main") - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?, - ), - }, - name: head, - deref: false, - }], - Some(signature), - )?; - - let readme = if description.trim().is_empty() { - format!("# {name}\n") - } else { - format!("# {name}\n\n{description}\n") - }; - - std::fs::write(path.join("README.md"), &readme).context("failed to write README.md")?; - - let blob = repo.write_object(gix::objs::Blob { - data: readme.into_bytes(), - })?; - - let tree = repo.write_object(gix::objs::Tree { - entries: vec![gix::objs::tree::Entry { - mode: gix::objs::tree::EntryKind::Blob.into(), - filename: gix::bstr::BString::from("README.md"), - oid: blob.into(), - }], - })?; - - let commit = repo.commit_as( - signature, - signature, - "HEAD", - "Initial commit", - tree, - Vec::::new(), - )?; - - // Populate the index so the fresh repository is clean, - // as `git add` and`git commit` would leave it. - let mut index = repo.index_from_tree(&tree)?; - index.write(gix::index::write::Options::default())?; - - let commit = commit.to_string(); - if commit.len() != 40 { - bail!("unexpected initial commit id: {commit}"); - } - - Ok(commit) -} - -/// Push the `main` branch of the repository at `repo_path` to a grasp server. -pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { - push_refspecs( - repo_path, - base_url, - owner, - repo_id, - &["refs/heads/main:refs/heads/main"], - ) -} - -/// Push every local branch and tag of the repository at `repo_path` to a grasp server. -/// -/// This mirrors an initialized repository's whole history. -pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { - push_refspecs( - repo_path, - base_url, - owner, - repo_id, - &["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], - ) -} - -/// Push `refspecs` to the grasp server URL derived from `base_url`, `owner` and `repo_id`. -fn push_refspecs( - repo_path: &Path, - base_url: &str, - owner: &str, - repo_id: &str, - refspecs: &[&str], -) -> Result<()> { - let url = format!("{base_url}/{owner}/{repo_id}.git"); - - let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2); - args.push("push"); - args.push(&url); - args.extend_from_slice(refspecs); - - let output = git_output(repo_path, &args, "git push")?; - - if !output.status.success() { - bail!( - "git push to {base_url} failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(()) -} - -/// Whether `url` advertises every ref in `expected` at the given commit. -/// -/// Extra advertised refs are ignored: the question is whether the data this -/// push wanted to land is already there, not whether the remote is an exact mirror. -/// This is the convergence probe for a push that lost the compare-and-swap race -/// to the grasp server's own background ref alignment. -pub fn remote_has_refs(repo_path: &Path, url: &str, expected: &[(String, String)]) -> Result { - if expected.is_empty() { - return Ok(true); - } - - let repo = gix::open(repo_path)?; - let url = transport_url(url); - - // A URL-created remote has no configured fetch refspecs, and `ref_map` only - // keeps refs that match one. Match each expected ref by its exact name, like - // `git ls-remote ` would; ref maps never write to the repository. - let refspecs = expected - .iter() - .map(|(name, _)| { - gix::refspec::parse( - gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()), - gix::refspec::parse::Operation::Fetch, - ) - .map(|spec| spec.to_owned()) - }) - .collect::, _>>() - .context("invalid refspec")?; - - let options = gix::remote::ref_map::Options { - extra_refspecs: refspecs, - ..Default::default() - }; - - let (refs, _) = repo - .remote_at(url.as_str()) - .with_context(|| format!("cannot use remote {url}"))? - .connect(gix::remote::Direction::Fetch) - .with_context(|| format!("cannot connect to {url}"))? - .ref_map(Discard, options) - .with_context(|| format!("listing refs of {url} failed"))?; - - // Peeled tag entries carry the tag object in their direct oid, so mapping - // each advertised ref to its direct oid matches `git ls-remote` while - // skipping the duplicated `^{}` lines. - let advertised: HashMap = refs - .remote_refs - .iter() - .filter_map(|reference| { - let (name, object, _peeled) = reference.unpack(); - object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string())) - }) - .collect(); - - Ok(expected - .iter() - .all(|(name, oid)| advertised.get(name.as_str()) == Some(oid))) -} - -/// The earliest unique commit of the repository at `repo_path`. -/// Used as the NIP-34 announcement's `euc` marker. -/// -/// `None` for a repository without commits. -pub fn root_commit(repo_path: &Path) -> Result> { - let Ok(repo) = gix::open(repo_path) else { - return Ok(None); - }; - - let Ok(head) = repo.head_id() else { - // An unborn HEAD with no commits yet has no root commit. - return Ok(None); - }; - - for info in repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, - )) - .all()? - { - let info = info?; - if info.parent_ids().next().is_none() { - let id = info.id().to_string(); - return Ok((id.len() == 40).then_some(id)); - } - } - - Ok(None) -} - -/// Add `origin` pointing at `url` when the repository has no remote yet. -/// -/// No-op if `origin` already exists. -pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> { - let repo = gix::open(repo_path)?; - if repo.find_remote("origin").is_ok() { - return Ok(()); - } - - // `git remote add` also configures the default fetch refspec. - edit_local_config(&repo, |config| { - config.set_raw_value("remote.origin.url", url)?; - config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; - Ok(()) - }) -} - -/// Point `origin` at `url`, replacing an existing remote, -/// used after a clone whose `origin` points at the cloned-from path. -/// -/// A working copy cloned from a local mirror is re-targeted at the grasp server. -pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> { - let repo = gix::open(repo_path)?; - let had_origin = repo.find_remote("origin").is_ok(); - - edit_local_config(&repo, |config| { - // Replaces the existing url, like `git remote set-url origin `. - // A pre-existing fetch refspec is left untouched. - config.set_raw_value("remote.origin.url", url)?; - - if !had_origin { - config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; - } - - Ok(()) - }) -} - -/// Apply `edit` to the repository-local configuration and persist it. -/// -/// The config file is locked while it is read, edited and written back, -/// like git would when running `git config` or `git remote`. -fn edit_local_config( - repo: &gix::Repository, - edit: impl FnOnce(&mut gix::config::File) -> Result<()>, -) -> Result<()> { - let config_path = repo.common_dir().join("config"); - - let mut lock = gix::lock::File::acquire_to_update_resource( - &config_path, - gix::lock::acquire::Fail::Immediately, - None, - ) - .context("failed to lock repository config")?; - - let mut config = - match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local) { - Ok(config) => config, - // A repository without a config file yet starts from scratch. - Err(gix::config::file::init::from_paths::Error::Io { source, .. }) - if source.kind() == std::io::ErrorKind::NotFound => - { - gix::config::File::default() - } - Err(error) => return Err(error).context("failed to read repository config"), - }; - - edit(&mut config)?; - - config - .write_to(&mut lock) - .context("failed to write repository config")?; - - lock.commit().context("failed to save repository config")?; - - Ok(()) -} - -/// Fetch `refspec` into `repo_path` from the first working URL in `urls`. -/// When no URL works, the last error is returned. -/// -/// Never touches the checked-out refs or the worktree. -pub fn fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) -> Result<()> { - let repo = gix::open(repo_path)?; - let refspec = gix::refspec::parse( - gix::bstr::BStr::new(refspec), - gix::refspec::parse::Operation::Fetch, - ) - .context("invalid fetch refspec")? - .to_owned(); - - try_each_url(urls, "fetch", |url| { - let url = transport_url(url); - let options = gix::remote::ref_map::Options { - extra_refspecs: vec![refspec.clone()], - ..Default::default() - }; - repo.remote_at(url.as_str()) - .with_context(|| format!("fetch from {url} failed"))? - .connect(gix::remote::Direction::Fetch) - .with_context(|| format!("fetch from {url} failed"))? - .prepare_fetch(Discard, options) - .with_context(|| format!("fetch from {url} failed"))? - .receive(Discard, &IS_INTERRUPTED) - .with_context(|| format!("fetch from {url} failed"))?; - Ok(()) - }) -} - -/// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`. -/// `prefix` is a ref namespace like `refs/fork//`. -/// -/// Returns an empty list when nothing matches. -pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result> { - let pattern = prefix.trim_end_matches('/'); - let repo = gix::open(repo_path)?; - let mut names = Vec::new(); - - for reference in repo.references()?.all()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - let name = String::from_utf8_lossy(reference.name().as_bstr()).into_owned(); - - // Match the pattern itself and everything beneath it, like `git for-each-ref`. - let under_pattern = name - .strip_prefix(pattern) - .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')); - - if under_pattern { - names.push(name); - } - } - - // Sort lexicographically, like `git for-each-ref`. - names.sort(); - - Ok(names) -} - -/// Delete every ref under `prefix` of the repository at `repo_path`. -/// `prefix` is a ref namespace like `refs/fork//`. -pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> { - use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog}; - - let refs = refs_with_prefix(repo_path, prefix)?; - if refs.is_empty() { - return Ok(()); - } - - let repo = gix::open(repo_path)?; - let edits: Vec = refs - .iter() - .map(|name| { - let full = gix::refs::FullName::try_from(name.as_str()) - .map_err(|e| anyhow::anyhow!("invalid ref name {name}: {e}"))?; - Ok(RefEdit { - change: Change::Delete { - expected: PreviousValue::Any, - log: RefLog::AndReference, - }, - name: full, - deref: false, - }) - }) - .collect::>>()?; - - // Delete all refs with the given prefix. - repo.edit_references(edits)?; - - Ok(()) -} - -/// The URL of the `origin` remote of the repository at `workdir`. -/// -/// `None` when it has no `origin` yet. -pub fn origin_url(workdir: &Path) -> Result> { - let Ok(repo) = gix::open(workdir) else { - return Ok(None); - }; - - let Ok(remote) = repo.find_remote("origin") else { - return Ok(None); - }; - - Ok(remote - .url(gix::remote::Direction::Fetch) - .map(|url| url.to_string())) -} - -/// Whether the worktree of `workdir` has uncommitted changes. -/// -/// Best-effort: any read failure is reported as clean. -pub fn worktree_dirty(workdir: &Path) -> bool { - let Ok(repo) = gix::open(workdir) else { - return false; - }; - - // Changes to tracked files, staged or not; untracked files are excluded. - match repo.is_dirty() { - Ok(true) => return true, - Ok(false) => {} - Err(_) => return false, - } - - // Untracked files surface as `DirectoryContents` items of the index-vs-worktree walk, - // tracked files only appear there when modified. - let Ok(platform) = repo.status(Discard) else { - return false; - }; - - let Ok(mut changes) = platform.into_index_worktree_iter(Vec::::new()) - else { - return false; - }; - - for change in changes.by_ref() { - match change { - Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true, - Ok(_) => {} - Err(_) => return false, - } - } - - false -} - -/// Commits in `base..branch` of the checkout at `workdir`. -/// -/// Best-effort: 0 when the range cannot be computed. -pub fn worktree_commits_ahead(workdir: &Path, base: &str, branch: &str) -> u32 { - let Ok(repo) = gix::open(workdir) else { - return 0; - }; - - let (Some(base), Some(branch)) = (resolve_commit(&repo, base), resolve_commit(&repo, branch)) - else { - return 0; - }; - - let Ok(walk) = repo.rev_walk([branch]).with_hidden([base]).all() else { - return 0; - }; - - walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32 -} - -/// Resolve `rev` to a commit id, accepting full refs, -/// symbolic refs and the bare branch names callers pass, like git's DWIM. -fn resolve_commit<'a>(repo: &'a gix::Repository, rev: &str) -> Option> { - if let Ok(id) = repo.rev_parse_single(rev.as_bytes()) { - return Some(id); - } - - // Branch names arrive bare, like git resolving `main`. - if rev.contains('/') { - return None; - } - - repo.rev_parse_single(format!("refs/heads/{rev}").as_bytes()) - .ok() -} - -/// Short name of the branch HEAD points to at `workdir`, -/// `None` when detached or unreadable, like `git branch --show-current`. -pub fn worktree_current_branch(workdir: &Path) -> Option { - let repo = gix::open(workdir).ok()?; - let head = repo.head().ok()?; - let name = head.referent_name()?; - Some(String::from_utf8_lossy(name.shorten()).into_owned()) -} - -/// Whether the reference `name` exists in the repository at `workdir`. -pub fn worktree_ref_exists(workdir: &Path, name: &str) -> bool { - let Ok(repo) = gix::open(workdir) else { - return false; - }; - repo.find_reference(name).is_ok() -} - -/// Fast-forward local branches that trail their remote-tracking counterpart. -/// -/// Returns whether any branch moved. -pub fn fast_forward_branches(workdir: &Path) -> Result { - let repo = gix::open(workdir)?; - let current = worktree_current_branch(workdir); - let heads = refs_with_prefix(workdir, "refs/heads")?; - - let (signature, mut time_buf) = repository_signature(); - let signature = signature.to_ref(&mut time_buf); - - let mut moved = false; - - for head in heads { - let Some(branch) = head.strip_prefix("refs/heads/") else { - continue; - }; - - let remote = format!("refs/remotes/origin/{branch}"); - // No remote-tracking counterpart means the remote lacks this branch. - let Ok(mut remote_reference) = repo.find_reference(&remote) else { - continue; - }; - - let Ok(mut local_reference) = repo.find_reference(&head) else { - continue; - }; - - let Ok(remote_oid) = remote_reference.peel_to_id() else { - continue; - }; - - let Ok(local_oid) = local_reference.peel_to_id() else { - continue; - }; - - let remote_oid = remote_oid.detach(); - let local_oid = local_oid.detach(); - - if local_oid == remote_oid { - continue; - } - - // Only fast-forward. - // Local-only commits or diverged history must never be rewritten by a refresh. - let Ok(base) = repo.merge_base(local_oid, remote_oid) else { - continue; - }; - - if base != local_oid { - continue; - } - - let full = gix::refs::FullName::try_from(head.as_str()) - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - - let edit = |new: gix::refs::Target| { - use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; - RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: format!("merge {remote}: Fast-forward").into(), - }, - expected: PreviousValue::ExistingMustMatch(gix::refs::Target::Object( - local_oid, - )), - new, - }, - name: full.clone(), - deref: false, - } - }; - - if current.as_deref() == Some(branch) { - // Merge so the checked-out worktree follows the branch. - // Only proceed on a clean worktree, like `git merge --ff-only`. - if worktree_dirty(workdir) { - continue; - } - - let tree = repo.find_object(remote_oid)?.peel_to_tree()?.id; - - // Check out the remote tree, discarding local changes. - force_checkout(&repo, &tree)?; - - // Update the branch reference to point to the remote tree. - repo.edit_references_as( - [edit(gix::refs::Target::Object(remote_oid))], - Some(signature), - )?; - - moved = true; - } else { - // Update the branch reference to point to the remote tree. - repo.edit_references_as( - [edit(gix::refs::Target::Object(remote_oid))], - Some(signature), - )?; - - moved = true; - } - } - - Ok(moved) -} - -/// Run `git -C dir args`, disabling the terminal prompt and capturing stderr. -/// -/// `what` names the command in the spawn error. -fn git_output(dir: &Path, args: &[&str], what: &str) -> Result { - Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .with_context(|| format!("failed to spawn `{what}`")) -} +mod cache; +mod diff; +mod history; +mod patch; +mod remote; +mod repo; +mod scan; +mod worktree; + +#[cfg(test)] +mod tests; + +pub use cache::{GitCache, fork_namespace, sanitize_path_component}; +pub use diff::{ + CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff, worktree_commit_diff, + worktree_commit_range_diff, +}; +pub use history::{ + CommitList, FileCommit, MAX_LISTED_COMMITS, all_commits, head_commit, last_commit, + worktree_all_commits, worktree_commit, worktree_commit_range_commits, worktree_last_commits, +}; +pub use patch::{ + apply_patch, format_patch_between, patch_commits, patch_diffs, split_patch_series, +}; +pub use remote::{ + clone_repo, ensure_origin, fetch_all, fetch_repo_refs, origin_url, push_all, push_commit_ref, + push_main, remote_has_refs, set_origin, +}; +pub use repo::{ + RepoRefState, commits_since, current_branch, delete_refs_with_prefix, fast_forward_branches, + head_commit_id, init_repository, merge_base, refs_with_prefix, repo_branches, repo_ref_state, + repo_tags, root_commit, worktree_branches, worktree_current_branch, worktree_ref_exists, + worktree_ref_state, +}; +pub use scan::find_git_repos; +pub use worktree::{ + WorktreeSnapshot, find_readme, worktree_checkout_branch, worktree_checkout_tag, + worktree_commits_ahead, worktree_dirty, worktree_entries, worktree_read, worktree_snapshot, +}; /// Run a git command in `dir`, returning trimmed stdout. /// /// The terminal prompt is disabled so a credential request fails instead of hanging. #[cfg(test)] -fn git_in(dir: &Path, args: &[&str]) -> Result { - let output = git_output(dir, args, "git")?; +fn git_in(dir: &std::path::Path, args: &[&str]) -> anyhow::Result { + let output = remote::git_output(dir, args, "git")?; if !output.status.success() { - bail!( + anyhow::bail!( "git {} failed: {}", args.join(" "), String::from_utf8_lossy(&output.stderr).trim() @@ -1033,3086 +55,3 @@ fn git_in(dir: &Path, args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) } - -/// Map an untrusted repository id or display name to a safe single path component. -/// -/// Everything outside `[A-Za-z0-9._-]` becomes `_`. -/// An id that maps to exactly `.` or `..` becomes `_`. -pub fn sanitize_path_component(id: &str) -> String { - let sanitized: String = id - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { - c - } else { - '_' - } - }) - .collect(); - - if sanitized == "." || sanitized == ".." { - return "_".to_owned(); - } - - sanitized -} - -/// In-memory object cache for history walks, see [`open_with_cache`]. -/// -/// Without one, a walk re-decodes the same commit objects from the object database. -/// Sized generously: a walk can cover a large portion of the repository's history. -const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024; - -/// Metadata of a commit, as shown in the repository browser's file header. -#[derive(Debug, Clone)] -pub struct FileCommit { - /// Shortened commit id, 7+ hex chars, disambiguated if needed. - pub id: String, - /// First line of the commit message. - pub summary: String, - /// Rest of the commit message after the title. - /// `None` for single-line commit messages. - pub description: Option, - /// Author name. - pub author: String, - /// Author time, seconds since the Unix epoch. - pub time: i64, -} - -/// Relative paths of all entries in the worktree, files and directories. -/// -/// The `.git` directory is skipped. -pub fn worktree_entries(repo: &gix::Repository) -> Result> { - let workdir = repo.workdir().context("repository has no worktree")?; - - let mut entries: Vec<(PathBuf, bool)> = Vec::new(); - collect_entries(workdir, workdir, &mut entries)?; - - entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| { - b_is_dir - .cmp(a_is_dir) - .then_with(|| a.as_os_str().cmp(b.as_os_str())) - }); - Ok(entries.into_iter().map(|(path, _)| path).collect()) -} - -/// Read a file from the worktree. -/// -/// Returns `Ok(None)` if the path is missing or not a regular file. -pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result>> { - let workdir = repo.workdir().context("repository has no worktree")?; - let path = workdir.join(rel); - - match std::fs::read(&path) { - Ok(bytes) => Ok(Some(bytes)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None), - Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), - } -} - -/// Find the README file in the repository root. -/// -/// Falls back to any other file whose name starts with `readme`. -pub fn find_readme(repo: &gix::Repository) -> Result> { - let Some(workdir) = repo.workdir() else { - return Ok(None); - }; - - let mut candidates: Vec = Vec::new(); - for entry in std::fs::read_dir(workdir)? { - let entry = entry?; - let name = entry.file_name(); - let Some(name) = name.to_str() else { continue }; - if name.to_ascii_lowercase().starts_with("readme") { - candidates.push(entry.path()); - } - } - - candidates.sort_by_key(|path| { - let ext = path - .extension() - .map(|e| e.to_string_lossy().to_ascii_lowercase()); - match ext.as_deref() { - Some("md") => 0, - Some("markdown") => 1, - Some("mdown") => 2, - Some("mkdn") => 3, - Some(_) => 5, - None => 4, - } - }); - - Ok(candidates - .into_iter() - .next() - .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) -} - -/// Open the repository at `workdir` with an in-memory object cache. -/// -/// Only history walks use it, they re-decode the same commit objects repeatedly. -/// Single-object reads open the repository plain. -fn open_with_cache(workdir: &Path) -> Result { - let mut repo = gix::open(workdir)?; - repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES); - Ok(repo) -} - -/// A [`FileCommit`] from a commit, with author, message title, body and shortened id. -/// -/// The diff panel fetches the full commit on demand. -fn file_commit(commit: &gix::Commit<'_>) -> Result { - file_commit_with_description(commit, true) -} - -/// A [`FileCommit`] without the message body, for history lists that never display it. -/// -/// Skipping the body saves an allocation per listed commit. -fn file_commit_summary(commit: &gix::Commit<'_>) -> Result { - file_commit_with_description(commit, false) -} - -/// [`file_commit`] and [`file_commit_summary`], `include_description` picks the body. -fn file_commit_with_description( - commit: &gix::Commit<'_>, - include_description: bool, -) -> Result { - let author = commit.author()?; - let message = commit.message()?; - Ok(FileCommit { - id: commit.id().shorten_or_id().to_string(), - summary: String::from_utf8_lossy(message.title).trim().to_string(), - description: if include_description { - message - .body - .map(|body| String::from_utf8_lossy(body).trim().to_string()) - .filter(|body| !body.is_empty()) - } else { - None - }, - author: String::from_utf8_lossy(author.name).trim().to_string(), - time: author.time()?.seconds, - }) -} - -/// Find the most recent commit that changed `rel`, a path relative to the worktree. -/// -/// `Ok(None)` when no commit touched the file, e.g. an untracked file. -pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result> { - let rel = rel.to_path_buf(); - Ok(last_commits(repo, std::slice::from_ref(&rel))? - .into_iter() - .next() - .map(|(_, commit)| commit)) -} - -/// Newest commit touching each of `rels`, like `git log -1 -- ` per path. -/// `rels` are paths relative to the worktree. -/// -/// Paths without any commit, like untracked files, are absent from the result. -pub fn worktree_last_commits( - workdir: &Path, - rels: &[PathBuf], -) -> Result> { - last_commits(&open_with_cache(workdir)?, rels) -} - -/// The walk behind [`last_commit`] and [`worktree_last_commits`]. -/// -/// Stops as soon as every pending path has its commit. -fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result> { - use gix::traverse::commit::simple::CommitTimeOrder; - - let Some(head) = repo.head_id().ok() else { - return Ok(Vec::new()); - }; - - // De-duplicate while preserving order. - let mut pending: Vec = Vec::with_capacity(rels.len()); - let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len()); - for rel in rels { - if seen.insert(rel.as_path()) { - pending.push(rel.clone()); - } - } - - let walk = repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - CommitTimeOrder::NewestFirst, - )); - - let mut found = Vec::new(); - for info in walk.all()? { - if pending.is_empty() { - break; - } - let info = info?; - let commit = info.object()?; - let tree = commit.tree()?; - let parent_tree = match info.parent_ids().next() { - Some(parent) => Some(parent.object()?.into_commit().tree()?), - None => None, - }; - - // Compare each unresolved path against this commit and its first parent. - // Resolved paths leave the pending set. - let mut ix = 0; - while ix < pending.len() { - let rel = &pending[ix]; - let blob = tree.lookup_entry_by_path(rel)?; - let parent_blob = match &parent_tree { - Some(tree) => tree.lookup_entry_by_path(rel)?, - None => None, - }; - - if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach()) - { - found.push((rel.clone(), file_commit(&commit)?)); - pending.swap_remove(ix); - } else { - ix += 1; - } - } - } - - Ok(found) -} - -/// Cap on [`CommitList::commits`]. The virtual list renders a window at a time, -/// the tab badge shows the real count. -/// -/// A huge history is never fully materialized in memory. -pub const MAX_LISTED_COMMITS: usize = 20_000; - -/// Commits reachable from `HEAD`, newest first, possibly capped. -pub struct CommitList { - /// Number of commits reachable from HEAD. - pub total: usize, - /// Newest commits, capped at [`MAX_LISTED_COMMITS`]. - pub commits: Vec, -} - -/// All commits reachable from `HEAD`, newest first, with author and summary. -/// -/// Returns an empty list for a repository without any commits yet. -pub fn all_commits(repo: &gix::Repository) -> Result { - use gix::traverse::commit::simple::CommitTimeOrder; - - let Some(head) = repo.head_id().ok() else { - return Ok(CommitList { - total: 0, - commits: Vec::new(), - }); - }; - let walk = repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - CommitTimeOrder::NewestFirst, - )); - - let mut commits = Vec::new(); - let mut total = 0; - for info in walk.all()? { - let info = info?; - total += 1; - if commits.len() < MAX_LISTED_COMMITS { - commits.push(file_commit_summary(&info.object()?)?); - } - } - Ok(CommitList { total, commits }) -} - -/// Like [`all_commits`], but opens the repository at `workdir` first. -/// -/// For non-bare clones the clone root is the worktree. -pub fn worktree_all_commits(workdir: &Path) -> Result { - all_commits(&open_with_cache(workdir)?) -} - -/// The kind of a [`DiffLine`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DiffLineKind { - /// An unchanged context line, present on both sides. - Context, - /// A line added by the commit. - Addition, - /// A line removed by the commit. - Deletion, -} - -/// One line of a file diff. -#[derive(Debug, Clone)] -pub struct DiffLine { - pub kind: DiffLineKind, - /// 1-based line number in the old version, if the line exists there. - pub old: Option, - /// 1-based line number in the new version, if the line exists there. - pub new: Option, - /// Line content without the trailing newline. - pub text: String, -} - -/// A hunk of a file diff, like `@@ -a,b +c,d @@`. -#[derive(Debug, Clone)] -pub struct DiffHunk { - /// 1-based start line in the old version. - pub old_start: u32, - /// Number of old lines covered by the hunk. - pub old_lines: u32, - /// 1-based start line in the new version. - pub new_start: u32, - /// Number of new lines covered by the hunk. - pub new_lines: u32, - pub lines: Vec, -} - -/// How a file changed in a commit. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DiffStatus { - Added, - Modified, - Deleted, - Renamed, - Copied, -} - -/// The diff of one file in a commit. -#[derive(Debug, Clone)] -pub struct FileDiff { - /// Path of the file relative to the repo root. - /// For renames and copies, this is the destination path. - pub path: String, - /// Previous path, for renames and copies. - pub old_path: Option, - pub status: DiffStatus, - /// Number of added lines, 0 for binary files. - pub insertions: usize, - /// Number of removed lines, 0 for binary files. - pub deletions: usize, - /// True if either version is binary, then `hunks` is empty. - pub binary: bool, - pub hunks: Vec, -} - -/// The changes of one commit. -#[derive(Debug, Clone)] -pub struct CommitDiff { - pub files: Vec, -} - -/// The changes of the commit `id`, short or full, in the repository at `workdir`. -/// -/// Compared against its first parent, the empty tree for the root commit. -pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result { - commit_diff(&gix::open(workdir)?, id) -} - -fn commit_diff(repo: &gix::Repository, id: &str) -> Result { - let commit_id = repo.rev_parse_single(id.as_bytes())?; - let commit = commit_id.object()?.into_commit(); - let new_tree = commit.tree()?; - let old_tree = match commit.parent_ids().next() { - Some(parent) => Some(parent.object()?.into_commit().tree()?), - None => None, - }; - tree_diff(repo, old_tree.as_ref(), &new_tree) -} - -/// The changes between two commits, `base`..`tip`, like `git diff base tip`. -/// -/// Directories and submodules are skipped, files are sorted by path. -pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result { - let repo = gix::open(workdir)?; - let base_tree = repo - .rev_parse_single(base.as_bytes())? - .object()? - .into_commit() - .tree()?; - let tip_tree = repo - .rev_parse_single(tip.as_bytes())? - .object()? - .into_commit() - .tree()?; - tree_diff(&repo, Some(&base_tree), &tip_tree) -} - -/// Commits in the range `base`..`tip`, newest first, like `git log base..tip`. -pub fn worktree_commit_range_commits( - workdir: &Path, - base: &str, - tip: &str, -) -> Result> { - use gix::traverse::commit::simple::CommitTimeOrder; - - let repo = open_with_cache(workdir)?; - let base_id = repo.rev_parse_single(base.as_bytes())?; - let tip_id = repo.rev_parse_single(tip.as_bytes())?; - let walk = repo - .rev_walk([tip_id]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - CommitTimeOrder::NewestFirst, - )) - .with_hidden([base_id]); - - let mut commits = Vec::new(); - for info in walk.all()? { - let info = info?; - commits.push(file_commit_summary(&info.object()?)?); - } - Ok(commits) -} - -/// The changes between two trees. Used by both [`commit_diff`] and [`worktree_commit_range_diff`]. -fn tree_diff( - repo: &gix::Repository, - old_tree: Option<&gix::Tree<'_>>, - new_tree: &gix::Tree<'_>, -) -> Result { - use gix::diff::blob::platform::prepare_diff::Operation; - use gix::object::tree::diff::Change; - use gix::objs::tree::EntryKind; - - let changes = repo.diff_tree_to_tree(old_tree, Some(new_tree), None)?; - let mut cache = repo.diff_resource_cache_for_tree_diff()?; - - let mut files = Vec::new(); - for change in changes { - let attached = Change::from_change_ref(change.to_ref(), repo, repo); - - // Skip directory trees and submodule gitlinks, only files are listed. - let (path, old_path, status) = match attached { - Change::Addition { - location, - entry_mode, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { - (location.to_owned(), None, DiffStatus::Added) - } - Change::Deletion { - location, - entry_mode, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { - (location.to_owned(), None, DiffStatus::Deleted) - } - Change::Modification { - location, - previous_entry_mode, - entry_mode, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) - && !matches!( - previous_entry_mode.kind(), - EntryKind::Tree | EntryKind::Commit - ) => - { - (location.to_owned(), None, DiffStatus::Modified) - } - Change::Rewrite { - location, - source_location, - source_entry_mode, - entry_mode, - copy, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) - && !matches!( - source_entry_mode.kind(), - EntryKind::Tree | EntryKind::Commit - ) => - { - let status = if copy { - DiffStatus::Copied - } else { - DiffStatus::Renamed - }; - ( - location.to_owned(), - Some(source_location.to_owned()), - status, - ) - } - _ => continue, - }; - - // Always diff with the built-in algorithm. - // External diff drivers would shell out, out of scope for a read-only viewer. - let platform = attached.diff(&mut cache)?; - platform - .resource_cache - .options - .skip_internal_diff_if_external_is_configured = true; - let outcome = platform.resource_cache.prepare_diff()?; - - let (binary, hunks, insertions, deletions) = match outcome.operation { - Operation::InternalDiff { algorithm } => { - let input = outcome.interned_input(); - let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input); - - let mut hunks = Vec::new(); - let mut insertions = 0usize; - let mut deletions = 0usize; - let collector = HunkCollector { - hunks: &mut hunks, - insertions: &mut insertions, - deletions: &mut deletions, - }; - gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default()) - .consume()?; - (false, hunks, insertions, deletions) - } - Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0), - Operation::ExternalCommand { .. } => unreachable!("external diff drivers are disabled"), - }; - - files.push(FileDiff { - path: String::from_utf8_lossy(&path).into_owned(), - old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()), - status, - insertions, - deletions, - binary, - hunks, - }); - } - - files.sort_by(|a, b| a.path.cmp(&b.path)); - Ok(CommitDiff { files }) -} - -/// Parse `git format-patch` output, a single patch or a series. -pub fn patch_diffs(patch: &str) -> Result { - let lines: Vec<&str> = patch.lines().collect(); - let mut files = Vec::new(); - let mut i = 0; - - 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; - } - - Ok(CommitDiff { files }) -} - -/// 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. -pub fn patch_commits(patch: &str) -> Vec { - let lines: Vec<&str> = patch.lines().collect(); - let mut commits = Vec::new(); - let mut i = 0; - - while i < lines.len() { - // A patch starts with its `From ` envelope line. - let Some(rest) = lines[i].strip_prefix("From ") else { - i += 1; - continue; - }; - let Some(id) = rest.split_whitespace().next() else { - i += 1; - continue; - }; - if id.len() != 40 { - i += 1; - continue; - } - - let mut author = String::new(); - let mut summary = String::new(); - let mut time = 0i64; - - // Envelope headers run up to the blank line before the commit message. - i += 1; - while i < lines.len() && !lines[i].is_empty() { - let header = lines[i]; - if let Some(value) = header.strip_prefix("From: ") { - author = name_from_address(value); - } else if let Some(value) = header.strip_prefix("Subject: ") { - summary = strip_patch_prefix(value); - } else if let Some(value) = header.strip_prefix("Date: ") { - time = gix::date::parse(value.trim(), None) - .map(|t| t.seconds) - .unwrap_or(0); - } - i += 1; - } - - commits.push(FileCommit { - id: id.to_string(), - summary, - description: None, - author, - time, - }); - } - - commits -} - -/// The name part of a `From: Name ` header value. -fn name_from_address(from: &str) -> String { - match from.trim().find('<') { - Some(ix) => from[..ix].trim().to_string(), - None => from.trim().to_string(), - } -} - -/// Strip the patch prefix from a `Subject:` header. -/// -/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`. -fn strip_patch_prefix(subject: &str) -> String { - let trimmed = subject.trim(); - let Some(rest) = trimmed.strip_prefix('[') else { - return trimmed.to_string(); - }; - let Some(end) = rest.find(']') else { - return trimmed.to_string(); - }; - if rest[..end].to_ascii_lowercase().contains("patch") { - rest[end + 1..].trim().to_string() - } else { - trimmed.to_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 { - 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::()?), - None => (spec, 1), - }; - Ok((start.parse::()?, 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 { - 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 { - 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, - insertions: &'a mut usize, - deletions: &'a mut usize, -} - -impl ConsumeHunk for HunkCollector<'_> { - type Out = (); - - fn consume_hunk( - &mut self, - header: HunkHeader, - lines: &[(GixLineKind, &[u8])], - ) -> std::io::Result<()> { - let mut old_ln = header.before_hunk_start; - let mut new_ln = header.after_hunk_start; - let mut out = Vec::with_capacity(lines.len()); - - for (kind, content) in lines { - let text = String::from_utf8_lossy(content).into_owned(); - let line = match kind { - GixLineKind::Context => { - let line = DiffLine { - kind: DiffLineKind::Context, - old: Some(old_ln), - new: Some(new_ln), - text, - }; - old_ln += 1; - new_ln += 1; - line - } - GixLineKind::Remove => { - *self.deletions += 1; - let line = DiffLine { - kind: DiffLineKind::Deletion, - old: Some(old_ln), - new: None, - text, - }; - old_ln += 1; - line - } - GixLineKind::Add => { - *self.insertions += 1; - let line = DiffLine { - kind: DiffLineKind::Addition, - old: None, - new: Some(new_ln), - text, - }; - new_ln += 1; - line - } - }; - out.push(line); - } - - self.hunks.push(DiffHunk { - old_start: header.before_hunk_start, - old_lines: header.before_hunk_len, - new_start: header.after_hunk_start, - new_lines: header.after_hunk_len, - lines: out, - }); - - Ok(()) - } - - fn finish(self) {} -} - -/// The commit HEAD points to, like `git log -1`. -/// -/// `Ok(None)` for a repository without commits yet, an unborn HEAD. -pub fn head_commit(repo: &gix::Repository) -> Result> { - let Some(head) = repo.head_id().ok() else { - return Ok(None); - }; - let commit = head.object()?.into_commit(); - Ok(Some(file_commit(&commit)?)) -} - -/// Full metadata of the commit `id`, short or full, in the repository at `workdir`. -/// Like [`head_commit`] for an arbitrary commit. -/// -/// `Ok(None)` when the id cannot be resolved. -pub fn worktree_commit(workdir: &Path, id: &str) -> Result> { - let repo = gix::open(workdir)?; - match repo.rev_parse_single(id.as_bytes()) { - Ok(commit_id) => { - let commit = commit_id.object()?.into_commit(); - Ok(Some(file_commit(&commit)?)) - } - Err(_) => Ok(None), - } -} - -/// Short names of local branches, `refs/heads/*`, of `repo`, sorted alphabetically. -pub fn repo_branches(repo: &gix::Repository) -> Result> { - let mut names = Vec::new(); - for reference in repo.references()?.local_branches()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); - } - names.sort(); - Ok(names) -} - -/// Short names of tags, `refs/tags/*`, of `repo`, sorted alphabetically. -pub fn repo_tags(repo: &gix::Repository) -> Result> { - let mut names = Vec::new(); - for reference in repo.references()?.tags()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); - } - names.sort(); - Ok(names) -} - -/// Short names of local branches, `refs/heads/*`, sorted alphabetically. -pub fn worktree_branches(workdir: &Path) -> Result> { - repo_branches(&gix::open(workdir)?) -} - -/// Short name of the branch HEAD points to, or `None` when detached. -/// -/// Detached after checking out a tag or a commit directly. -pub fn current_branch(repo: &gix::Repository) -> Result> { - let head = repo.head()?; - let Some(name) = head.referent_name() else { - return Ok(None); - }; - Ok(Some(String::from_utf8_lossy(name.shorten()).into_owned())) -} - -/// Branch, tag and HEAD refs of a repository. -/// -/// Ready for a NIP-34 kind-30618 repository state announcement. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RepoRefState { - /// `(full refname, commit id)` pairs for heads and tags, sorted. - pub refs: Vec<(String, String)>, - /// Short branch name HEAD points to, or `None` when detached. - pub head: Option, -} - -/// Collect the refs of `repo`. -/// -/// Local branches and tags become `(refname, commit-id)` pairs. -/// Also reports the branch HEAD points to. -pub fn repo_ref_state(repo: &gix::Repository) -> Result { - let mut refs = Vec::new(); - - for reference in repo.references()?.local_branches()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - refs.push(( - String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), - reference.id().to_string(), - )); - } - for reference in repo.references()?.tags()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - refs.push(( - String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), - reference.id().to_string(), - )); - } - refs.sort(); - - let head = match repo.head() { - Ok(head) => head - .referent_name() - .filter(|name| name.as_bstr().starts_with(b"refs/heads/")) - .map(|name| String::from_utf8_lossy(name.shorten()).into_owned()), - Err(_) => None, - }; - - Ok(RepoRefState { refs, head }) -} - -/// [`repo_ref_state`] for the repository at `workdir`. -pub fn worktree_ref_state(workdir: &Path) -> Result { - repo_ref_state(&gix::open(workdir)?) -} - -/// Everything the browser needs to refresh after a branch or tag switch. -pub struct WorktreeSnapshot { - /// Relative paths of all worktree entries, directories first. - pub entries: Vec, - /// README path relative to the worktree, if any. - pub readme_path: Option, - /// Contents of the README, if any. - pub readme: Option>, - /// Branch HEAD points to, `None` when detached, for example on a tag. - pub current_branch: Option, - /// Commit HEAD points to, if any, see [`head_commit`]. - pub head_commit: Option, -} - -/// Snapshot the worktree after a branch or tag switch. -/// -/// Collects entries, the README, the branch HEAD points to and its commit. -pub fn worktree_snapshot(workdir: &Path) -> Result { - let repo = gix::open(workdir)?; - let readme_path = find_readme(&repo)?; - let readme = match &readme_path { - Some(path) => worktree_read(&repo, path)?, - None => None, - }; - Ok(WorktreeSnapshot { - entries: worktree_entries(&repo)?, - readme_path, - readme, - current_branch: current_branch(&repo)?, - head_commit: head_commit(&repo)?, - }) -} - -/// Check out `tree` into the worktree of `repo` -fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> Result<()> { - let workdir = repo - .workdir() - .context("repository has no worktree")? - .to_path_buf(); - - let mut index = repo.index_from_tree(tree)?; - - // Files the previous index tracked but `tree` no longer contains are removed, - // like git deleting files that vanish between branches. - if let Ok(previous) = repo.index_or_empty() { - let keep: HashSet = index - .entries() - .iter() - .map(|entry| PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned())) - .collect(); - for entry in previous.entries() { - let rel = entry.path(&previous); - let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned()); - - if keep.contains(&rel) { - continue; - } - - let path = workdir.join(&rel); - - match std::fs::remove_file(&path) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(error) - .with_context(|| format!("failed to remove {}", path.display())); - } - } - } - } - - let mut options = - repo.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?; - options.overwrite_existing = true; - - let objects = repo.objects.clone().into_arc()?; - let files = gix::progress::Discard; - let bytes = gix::progress::Discard; - - // Check out the index into the worktree. - gix_worktree_state::checkout( - &mut index, - workdir, - objects, - &files, - &bytes, - &gix::interrupt::IS_INTERRUPTED, - options, - )?; - - // Write the index to disk. - index.write(gix::index::write::Options::default())?; - - Ok(()) -} - -/// Point `HEAD` at `target` and record the switch in the reflog. -fn move_head( - repo: &gix::Repository, - signature: gix::actor::SignatureRef<'_>, - target: gix::refs::Target, - message: &str, -) -> Result<()> { - use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; - - let head = gix::refs::FullName::try_from("HEAD") - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - - // Update the reference, creating a reflog entry. - repo.edit_references_as( - [RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: message.into(), - }, - expected: PreviousValue::Any, - new: target, - }, - name: head, - deref: false, - }], - Some(signature), - )?; - - Ok(()) -} - -/// Check out the local branch `name`, HEAD stays attached to it. -pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> { - let repo = gix::open(workdir)?; - let full = format!("refs/heads/{name}"); - - let branch = gix::refs::FullName::try_from(full.as_str()) - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - - let mut reference = repo.find_reference(&full)?; - let tree = reference.peel_to_tree()?.id; - - let (signature, mut time_buf) = repository_signature(); - let signature = signature.to_ref(&mut time_buf); - - // Move HEAD to the branch, creating a reflog entry. - move_head( - &repo, - signature, - gix::refs::Target::Symbolic(branch), - &format!("checkout: moving to {name}"), - )?; - - // Check out the branch's tree, replacing index + worktree. - force_checkout(&repo, &tree)?; - - Ok(()) -} - -/// Check out the tag `name`, HEAD becomes detached at the tagged commit. -pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> { - let repo = gix::open(workdir)?; - let full = format!("refs/tags/{name}"); - - let mut reference = repo.find_reference(&full)?; - - let commit = reference.peel_to_id()?; - let tree = reference.peel_to_tree()?.id; - - let (signature, mut time_buf) = repository_signature(); - let signature = signature.to_ref(&mut time_buf); - - // Move HEAD to the tag, creating a reflog entry. - move_head( - &repo, - signature, - gix::refs::Target::Object(commit.detach()), - &format!("checkout: moving to {name}"), - )?; - - // Check out the tag's tree, replacing index + worktree. - force_checkout(&repo, &tree)?; - - Ok(()) -} - -fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - if entry.file_name() == ".git" { - continue; - } - - let is_dir = entry.file_type()?.is_dir(); - let path = entry.path(); - let rel = path.strip_prefix(root)?.to_path_buf(); - out.push((rel, is_dir)); - - if is_dir { - collect_entries(root, &path, out)?; - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use nostr::prelude::*; - use signed_core::repo_addr; - - use super::*; - - #[test] - fn keeps_plain_ids() { - assert_eq!(sanitize_path_component("my-repo"), "my-repo"); - assert_eq!(sanitize_path_component("repo.v2"), "repo.v2"); - assert_eq!(sanitize_path_component("a_b-c"), "a_b-c"); - } - - #[test] - fn replaces_unsafe_characters() { - assert_eq!(sanitize_path_component("a/b\\c:d"), "a_b_c_d"); - assert_eq!(sanitize_path_component(""), ""); - } - - #[test] - fn blocks_parent_components() { - assert_eq!(sanitize_path_component(".."), "_"); - assert_eq!(sanitize_path_component("."), "_"); - // Separators are neutralized before the check, so these stay safe. - assert_eq!(sanitize_path_component("../.."), ".._.."); - assert_eq!(sanitize_path_component("a/../b"), "a_.._b"); - } - - #[test] - fn repo_path_stays_inside_root() { - let cache = GitCache::new("/cache".into()); - let owner = Keys::generate().public_key(); - - let path = cache.repo_path(&repo_addr(owner, "..")); - assert!(path.starts_with("/cache")); - assert_eq!( - path.file_name().map(|n| n.to_string_lossy().into_owned()), - Some("_".into()) - ); - } - - #[test] - fn find_git_repos_discovers_repositories_recursively() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path(); - - // Repositories are found at any depth. - // A linked worktree, with a `.git` file instead of a directory, counts too. - let nested = root.join("a/b/project"); - std::fs::create_dir_all(nested.join(".git")).unwrap(); - let worktree = root.join("wt"); - std::fs::create_dir_all(&worktree).unwrap(); - std::fs::write( - worktree.join(".git"), - "gitdir: ../a/b/project/.git/worktrees/wt", - ) - .unwrap(); - - // Plain directories are not repositories. - std::fs::create_dir_all(root.join("plain")).unwrap(); - - // Hidden entries and dependency caches are skipped. - std::fs::create_dir_all(root.join(".hidden/repo/.git")).unwrap(); - std::fs::create_dir_all(root.join("node_modules/pkg/.git")).unwrap(); - - // A repository is not descended into. - // Repositories inside it, like submodule worktrees, are not reported. - let outer = root.join("outer"); - std::fs::create_dir_all(outer.join(".git")).unwrap(); - std::fs::create_dir_all(outer.join("sub/other/.git")).unwrap(); - - let mut found = find_git_repos(root); - found.sort(); - - let mut expected = vec![ - nested.canonicalize().unwrap(), - worktree.canonicalize().unwrap(), - outer.canonicalize().unwrap(), - ]; - expected.sort(); - assert_eq!(found, expected); - } - - #[test] - fn root_commit_reports_the_first_ancestor() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - - let dir = dir.path(); - let root = root_commit(dir).expect("root").expect("commit"); - assert_eq!(root.len(), 40); - - // The root commit does not change when history grows. - std::fs::write(dir.join("b.txt"), b"two").expect("write"); - commit_all(&repo, "second"); - assert_eq!( - root_commit(dir).expect("root").as_deref(), - Some(root.as_str()) - ); - } - - #[test] - fn root_commit_is_none_without_commits() { - let (_dir, repo) = fixture(&[("a.txt", b"one")]); - let workdir = repo.workdir().expect("workdir"); - assert_eq!(root_commit(workdir).expect("root"), None); - } - - #[test] - fn push_all_mirrors_branches_and_tags() { - // A bare server repository reachable via a `file://` URL. - // Mirrors a grasp server's `{base}/{owner}/{repo-id}.git` layout. - let server = tempfile::tempdir().unwrap(); - let server_repo = server.path().join("npub1test").join("my-repo.git"); - std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&server_repo) - .status() - .expect("spawn git init --bare"); - assert!(init_status.success()); - - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - let dir = dir.path(); - - // Two branches plus a tag are all mirrored. - git_run(dir, &["checkout", "-b", "feature"]); - std::fs::write(dir.join("b.txt"), b"two").expect("write"); - commit_all(&repo, "feature work"); - git_run(dir, &["checkout", "-"]); - git_run(dir, &["tag", "v1.0"]); - - let base_url = format!("file://{}", server.path().display()); - push_all(dir, &base_url, "npub1test", "my-repo").expect("push"); - - let refs = git_in(&server_repo, &["show-ref"]).expect("server refs"); - assert!(refs.contains("refs/heads/main")); - assert!(refs.contains("refs/heads/feature")); - assert!(refs.contains("refs/tags/v1.0")); - } - - #[test] - fn push_all_tolerates_a_missing_ref_kind() { - // A repository with only tags and no branches still pushes. - // Wildcard refspecs without a local match are ignored. - let server = tempfile::tempdir().unwrap(); - let server_repo = server.path().join("npub1test").join("my-repo.git"); - std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&server_repo) - .status() - .expect("spawn git init --bare"); - assert!(init_status.success()); - - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - let dir = dir.path(); - git_run(dir, &["tag", "v1.0"]); - git_run(dir, &["update-ref", "-d", "refs/heads/main"]); - - let base_url = format!("file://{}", server.path().display()); - push_all(dir, &base_url, "npub1test", "my-repo").expect("push"); - - let refs = git_in(&server_repo, &["show-ref"]).expect("server refs"); - assert!(refs.contains("refs/tags/v1.0")); - assert!(!refs.contains("refs/heads/")); - } - - #[test] - fn remote_has_refs_reports_whether_pushed_refs_landed() { - let server = tempfile::tempdir().unwrap(); - let server_repo = server.path().join("npub1test").join("my-repo.git"); - std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&server_repo) - .status() - .expect("spawn git init --bare"); - assert!(init_status.success()); - - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - let dir = dir.path(); - let main = git_in(dir, &["rev-parse", "refs/heads/main"]).expect("main oid"); - let url = format!("file://{}/npub1test/my-repo.git", server.path().display()); - let expected = vec![("refs/heads/main".to_owned(), main.clone())]; - - // Nothing pushed yet: the ref is absent. - assert!(!remote_has_refs(dir, &url, &expected).expect("probe")); - - push_all( - dir, - &format!("file://{}", server.path().display()), - "npub1test", - "my-repo", - ) - .expect("push"); - - // The pushed ref is advertised at the expected commit. - assert!(remote_has_refs(dir, &url, &expected).expect("probe")); - - // A stale expectation - the exact race a retry resolves - is false. - let stale = vec![("refs/heads/main".to_owned(), "0".repeat(40))]; - assert!(!remote_has_refs(dir, &url, &stale).expect("probe")); - - // Extra remote refs (e.g. a tag pushed later) do not invalidate the - // refs this push wanted to land. - git_run(dir, &["tag", "v1.0"]); - push_all( - dir, - &format!("file://{}", server.path().display()), - "npub1test", - "my-repo", - ) - .expect("push"); - assert!(remote_has_refs(dir, &url, &expected).expect("probe")); - } - - #[test] - fn repo_ref_state_lists_branches_tags_and_head() { - let (_dir, repo) = fixture(&[("a.txt", b"hello")]); - commit_all(&repo, "initial"); - let workdir = repo.workdir().expect("workdir").to_path_buf(); - - let state = repo_ref_state(&repo).expect("refs"); - - let branch = current_branch(&repo).expect("branch").expect("on a branch"); - assert_eq!(state.head.as_deref(), Some(branch.as_str())); - assert_eq!(state.refs.len(), 1); - assert_eq!(state.refs[0].0, format!("refs/heads/{branch}")); - assert_eq!(state.refs[0].1.len(), 40); - - // Additional branches and tags are listed alongside. - git_run(&workdir, &["branch", "feature"]); - git_run(&workdir, &["tag", "v1.0"]); - - let state = repo_ref_state(&repo).expect("refs"); - let mut expected: Vec = vec![ - format!("refs/heads/{branch}"), - "refs/heads/feature".to_owned(), - "refs/tags/v1.0".to_owned(), - ]; - expected.sort(); - assert_eq!( - state - .refs - .iter() - .map(|(name, _)| name.clone()) - .collect::>(), - expected - ); - - // A detached HEAD yields no head branch. - git_run(&workdir, &["checkout", "--detach"]); - let state = repo_ref_state(&repo).expect("refs"); - assert!(state.head.is_none()); - assert_eq!(state.refs.len(), 3); - } - - /// Build a throwaway non-bare repository from `(rel, bytes)` file pairs. - fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) { - let dir = tempfile::tempdir().expect("tempdir"); - let repo = gix::init(&dir).expect("init"); - - for (rel, bytes) in files { - let path = dir.path().join(rel); - std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); - std::fs::write(&path, bytes).expect("write"); - } - - (dir, repo) - } - - #[test] - fn worktree_entries_lists_all_files_and_dirs() { - let (_dir, repo) = fixture(&[ - ("README.md", b"# Hi"), - ("src/main.rs", b"fn main() {}"), - ("src/lib.rs", b""), - ("docs/guide.md", b"guide"), - ]); - - let entries = worktree_entries(&repo).expect("entries"); - let entries: Vec = entries - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - - assert_eq!( - entries, - vec![ - "docs", - "src", - "README.md", - "docs/guide.md", - "src/lib.rs", - "src/main.rs" - ] - ); - } - - #[test] - fn worktree_read_returns_bytes_or_none() { - let (_dir, repo) = fixture(&[("a.txt", b"hello"), ("sub/b.bin", b"\x00\x01")]); - - assert_eq!( - worktree_read(&repo, Path::new("a.txt")).expect("read"), - Some(b"hello".to_vec()) - ); - assert_eq!( - worktree_read(&repo, Path::new("sub/b.bin")).expect("read"), - Some(vec![0x00, 0x01]) - ); - assert_eq!( - worktree_read(&repo, Path::new("missing.txt")).expect("read"), - None - ); - } - - /// Stage everything and create a commit with the git CLI. - /// Like [`apply_patch`], the crate already shells out to the CLI. - fn commit_all(repo: &gix::Repository, message: &str) { - git_run(repo.workdir().expect("workdir"), &["add", "-A"]); - git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]); - } - - #[test] - fn merge_base_finds_the_fork_point_and_reports_unrelated_history() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("repo"); - let initial = init_repository(&path, "My Repo", "desc").expect("init"); - - // A feature branch and a mainline commit diverge from the initial commit. - // The initial commit is their merge base. - git_run(&path, &["checkout", "-b", "feature"]); - std::fs::write(path.join("feature.txt"), "feature\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "feature commit"); - git_run(&path, &["checkout", "main"]); - std::fs::write(path.join("main.txt"), "main\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "mainline commit"); - - assert_eq!( - merge_base(&path, "feature", "main") - .expect("merge base") - .as_deref(), - Some(initial.as_str()) - ); - - // An orphan branch shares no history with main, so `Ok(None)`. - git_run(&path, &["checkout", "--orphan", "orphan"]); - std::fs::write(path.join("orphan.txt"), "orphan\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "orphan commit"); - assert_eq!(merge_base(&path, "orphan", "main").expect("ok"), None); - - // An unresolvable revision is an error, not a missing ancestor. - assert!(merge_base(&path, "orphan", "no-such-ref").is_err()); - } - - #[test] - fn format_patch_between_produces_the_series_and_rejects_empty_ranges() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("repo"); - let initial = init_repository(&path, "My Repo", "desc").expect("init"); - - git_run(&path, &["checkout", "-b", "feature"]); - std::fs::write(path.join("feature.txt"), "feature\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "feature commit"); - - let patch = format_patch_between(&path, &initial, "feature").expect("patch"); - assert!(patch.contains("Subject: [PATCH] feature commit")); - assert!(patch.contains("feature.txt")); - - // An empty range has no commits to send. - assert!(format_patch_between(&path, "feature", "feature").is_err()); - } - - #[test] - fn push_commit_ref_pushes_to_the_event_namespace() { - // A bare server repository reachable via a `file://` URL. - // Mirrors a grasp server's `{base}/{owner}/{repo-id}.git` layout. - let server = tempfile::tempdir().unwrap(); - let server_repo = server.path().join("npub1test").join("my-repo.git"); - std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&server_repo) - .status() - .expect("spawn git init --bare"); - assert!(init_status.success()); - - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - let dir = dir.path(); - let tip = git_in(dir, &["rev-parse", "HEAD"]).expect("tip"); - - let url = format!("file://{}/npub1test/my-repo.git", server.path().display()); - push_commit_ref(dir, &url, &tip, "refs/nostr/abcd1234").expect("push"); - - let refs = git_in(&server_repo, &["show-ref"]).expect("server refs"); - assert!(refs.contains("refs/nostr/abcd1234")); - } - - #[test] - fn split_patch_series_splits_real_multi_commit_mboxes() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("repo"); - let initial = init_repository(&path, "My Repo", "desc").expect("init"); - - git_run(&path, &["checkout", "-b", "feature"]); - std::fs::write(path.join("one.txt"), "one\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "first commit"); - std::fs::write(path.join("two.txt"), "two\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "second commit"); - - let series = format_patch_between(&path, &initial, "feature").expect("series"); - let parts = split_patch_series(&series); - - assert_eq!(parts.len(), 2); - assert!(parts[0].contains("Subject: [PATCH 1/2] first commit")); - assert!(parts[1].contains("Subject: [PATCH 2/2] second commit")); - // Each part starts its own mbox message with its own commit id. - let first = parts[0].lines().next().expect("first header"); - let second = parts[1].lines().next().expect("second header"); - assert!(first.starts_with("From ") && first.len() >= 45); - assert_ne!(first, second); - } - - #[test] - fn split_patch_series_keeps_single_patches_whole() { - let patch = "From abcdefabcdefabcdefabcdefabcdefabcdefab Mon Sep 17 00:00:00 2001\nFrom: A \nSubject: [PATCH] fix\n\n---\n"; - let parts = split_patch_series(patch); - assert_eq!(parts.len(), 1); - assert_eq!(parts[0], patch); - } - - #[test] - fn head_commit_and_commits_since_track_applied_commits() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("repo"); - let initial = init_repository(&path, "My Repo", "desc").expect("init"); - - assert_eq!( - head_commit_id(&path).expect("head").as_deref(), - Some(initial.as_str()) - ); - // No commits yet, `HEAD` alone. - assert_eq!( - commits_since(&path, None).expect("commits"), - vec![initial.clone()] - ); - - std::fs::write(path.join("one.txt"), "one\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "first commit"); - let first = head_commit_id(&path).expect("head").expect("on a branch"); - - std::fs::write(path.join("two.txt"), "two\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "second commit"); - let second = head_commit_id(&path).expect("head").expect("on a branch"); - - // Oldest first, like the order `git am` creates them. - assert_eq!( - commits_since(&path, Some(&initial)).expect("commits"), - vec![first.clone(), second.clone()] - ); - assert_eq!( - commits_since(&path, Some(&first)).expect("commits"), - vec![second] - ); - } - - #[test] - fn head_commit_reports_unborn_repositories() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("repo"); - let status = Command::new("git") - .args(["init", "-q"]) - .arg(&path) - .status() - .expect("spawn git init"); - assert!(status.success()); - - assert_eq!(head_commit_id(&path).expect("head"), None); - assert_eq!( - commits_since(&path, None).expect("commits"), - Vec::::new() - ); - } - - #[test] - fn init_repository_creates_main_branch_and_readme() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("my-repo"); - - let commit = init_repository(&path, "My Repo", "Does things.\n\nCool.").expect("init"); - assert_eq!(commit.len(), 40); - - let repo = gix::open(&path).expect("open"); - let workdir = repo.workdir().expect("workdir"); - - assert_eq!( - std::fs::read_to_string(workdir.join("README.md")).expect("read"), - "# My Repo\n\nDoes things.\n\nCool.\n" - ); - - let branch = current_branch(&repo).expect("branch").expect("on a branch"); - assert_eq!(branch, "main"); - // [`FileCommit`] carries the short id, the full id is 40 chars. - assert_eq!( - head_commit(&repo).expect("head").expect("commit").id, - &commit[..7] - ); - - let state = repo_ref_state(&repo).expect("refs"); - assert_eq!(state.head.as_deref(), Some("main")); - assert_eq!(state.refs, vec![("refs/heads/main".to_owned(), commit)]); - - // The index matches the committed tree, so the fresh repo is clean. - assert!(!worktree_dirty(workdir)); - } - - #[test] - fn init_repository_omits_description_when_empty() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("my-repo"); - - init_repository(&path, "My Repo", " ").expect("init"); - let repo = gix::open(&path).expect("open"); - let workdir = repo.workdir().expect("workdir"); - - assert_eq!( - std::fs::read_to_string(workdir.join("README.md")).expect("read"), - "# My Repo\n" - ); - } - - #[test] - fn ensure_origin_adds_remote_only_once() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("my-repo"); - init_repository(&path, "My Repo", "").expect("init"); - - ensure_origin(&path, "https://gitnostr.com/npub1test/repo.git").expect("add"); - assert_eq!( - git_in(&path, &["remote", "get-url", "origin"]).expect("url"), - "https://gitnostr.com/npub1test/repo.git" - ); - // The standard fetch mapping is configured with the remote. - // Later `git fetch origin` updates `refs/remotes/origin/*`. - assert_eq!( - git_in(&path, &["config", "remote.origin.fetch"]).expect("refspec"), - "+refs/heads/*:refs/remotes/origin/*" - ); - - // A second call must not override the existing remote. - ensure_origin(&path, "https://other.example/repo.git").expect("keep"); - assert_eq!( - git_in(&path, &["remote", "get-url", "origin"]).expect("url"), - "https://gitnostr.com/npub1test/repo.git" - ); - } - - #[test] - fn origin_url_reads_the_remote_or_reports_none() { - let (dir, _repo) = fixture(&[("a.txt", b"one")]); - commit_all(&_repo, "initial"); - let dir = dir.path(); - - // No remote configured yet. - assert_eq!(origin_url(dir).expect("read"), None); - - ensure_origin(dir, "https://gitnostr.com/npub1test/repo.git").expect("add"); - assert_eq!( - origin_url(dir).expect("read").as_deref(), - Some("https://gitnostr.com/npub1test/repo.git") - ); - } - - #[test] - fn set_origin_creates_or_replaces_the_remote() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("my-repo"); - init_repository(&path, "My Repo", "").expect("init"); - - // No origin yet, so one is added. - set_origin(&path, "https://gitnostr.com/npub1test/repo.git").expect("add"); - assert_eq!( - origin_url(&path).expect("url").as_deref(), - Some("https://gitnostr.com/npub1test/repo.git") - ); - - // An existing origin is replaced, not duplicated. - // A clone's origin points at the cloned-from path. - // It is re-targeted at the grasp server. - set_origin(&path, "https://grasp.example/npub1test/repo.git").expect("replace"); - assert_eq!( - origin_url(&path).expect("url").as_deref(), - Some("https://grasp.example/npub1test/repo.git") - ); - } - - #[test] - fn working_copy_cloned_from_the_mirror_matches_head_and_origin() { - // The mirror is a freshly initialized repository. - // Its `origin` points at the grasp server. - // `Backend::create_repository` leaves it in the GitCache. - let dir = tempfile::tempdir().expect("tempdir"); - let mirror = dir.path().join("mirror"); - let commit = init_repository(&mirror, "My Repo", "Does things.").expect("init"); - ensure_origin(&mirror, "https://gitnostr.com/npub1test/my-repo.git").expect("origin"); - - // The working copy is cloned from the mirror. - // It then shares the announced history exactly. - // `origin` is re-pointed at the grasp server instead of the mirror path. - let destination = dir.path().join("folder").join("My_Repo"); - std::fs::create_dir_all(destination.parent().unwrap()).expect("parent"); - clone_repo(&[format!("file://{}", mirror.display())], &destination).expect("clone"); - set_origin(&destination, "https://gitnostr.com/npub1test/my-repo.git").expect("set origin"); - - assert_eq!( - origin_url(&destination).expect("url").as_deref(), - Some("https://gitnostr.com/npub1test/my-repo.git") - ); - assert_eq!( - head_commit_id(&destination).expect("head").as_deref(), - Some(commit.as_str()) - ); - assert!(destination.join("README.md").is_file()); - } - - #[test] - fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { - // A bare server, like a grasp server's `{base}/{owner}/{repo}.git` layout. - let dir = tempfile::tempdir().expect("tempdir"); - let base_server = dir.path().join("npub1test").join("repo.git"); - std::fs::create_dir_all(base_server.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&base_server) - .status() - .expect("spawn git init --bare"); - assert!(init_status.success()); - - // The owner's working repo pushes the initial commit. - let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]); - commit_all(&work_repo, "initial"); - let work = work_dir.path(); - let base_url = format!("file://{}", dir.path().display()); - push_all(work, &base_url, "npub1test", "repo").expect("push"); - - // A mirror clone, like the app's GitCache clones. - let mirror = dir.path().join("mirror"); - git_run( - dir.path(), - &[ - "clone", - "-q", - &format!("{base_url}/npub1test/repo.git"), - mirror.to_str().unwrap(), - ], - ); - let initial = git_in(&mirror, &["rev-parse", "HEAD"]).expect("initial"); - - // The owner pushes a new commit. - // The mirror fetches it, but its local `main` and worktree stay behind. - std::fs::write(work.join("new.txt"), b"new\n").expect("write"); - commit_all(&gix::open(work).expect("open"), "new commit"); - push_all(work, &base_url, "npub1test", "repo").expect("push"); - git_run(&mirror, &["fetch", "origin"]); - let remote = git_in(&mirror, &["rev-parse", "refs/remotes/origin/main"]).expect("remote"); - assert_eq!( - git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"), - initial - ); - assert_ne!(remote, initial); - - // Fast-forwarding catches the branch and its worktree up. - // The second call has nothing left to move. - assert!(fast_forward_branches(&mirror).expect("ff")); - assert_eq!( - git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"), - remote - ); - assert!(mirror.join("new.txt").is_file()); - assert!(!fast_forward_branches(&mirror).expect("idle")); - - // A branch with local commits of its own is never touched. - git_run(&mirror, &["checkout", "-b", "wip"]); - std::fs::write(mirror.join("wip.txt"), b"wip\n").expect("write"); - commit_all(&gix::open(&mirror).expect("open"), "local wip"); - let wip = git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip"); - assert!(!fast_forward_branches(&mirror).expect("wip skipped")); - assert_eq!( - git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip kept"), - wip - ); - } - - #[test] - fn fetch_repo_refs_imports_heads_under_a_prefix() { - let dir = tempfile::tempdir().expect("tempdir"); - - // A bare base server holding the initial commit. - // Like a grasp server's `{base}/{owner}/{repo-id}.git` layout. - let base_server = dir.path().join("npub1base").join("base.git"); - std::fs::create_dir_all(base_server.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&base_server) - .status() - .expect("spawn git init --bare"); - assert!(init_status.success()); - - let (upstream_dir, upstream_repo) = fixture(&[("a.txt", b"one")]); - commit_all(&upstream_repo, "initial"); - let upstream_path = upstream_dir.path(); - let initial = git_in(upstream_path, &["rev-parse", "HEAD"]).expect("initial"); - push_all( - upstream_path, - &format!("file://{}", dir.path().display()), - "npub1base", - "base", - ) - .expect("push"); - - // The base mirror is a plain clone of the base server. - let base_url = format!("file://{}", base_server.display()); - let mirror = dir.path().join("mirror"); - git_run( - dir.path(), - &["clone", "-q", &base_url, mirror.to_str().unwrap()], - ); - - // The fork server has the same initial commit. - // It also carries a feature commit on its own `feature` branch. - let fork_work = dir.path().join("fork-work"); - git_run( - dir.path(), - &["clone", "-q", &base_url, fork_work.to_str().unwrap()], - ); - git_run(&fork_work, &["checkout", "-b", "feature"]); - std::fs::write(fork_work.join("feature.txt"), "feature\n").expect("write"); - commit_all(&gix::open(&fork_work).expect("open"), "feature commit"); - let tip = git_in(&fork_work, &["rev-parse", "HEAD"]).expect("tip"); - - let fork_server = dir.path().join("npub1fork").join("fork.git"); - std::fs::create_dir_all(fork_server.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&fork_server) - .status() - .expect("spawn git init --bare"); - assert!(init_status.success()); - push_commit_ref( - &fork_work, - &format!("file://{}", fork_server.display()), - &tip, - "refs/heads/feature", - ) - .expect("push"); - - // Import the fork's heads into the mirror under a private prefix. - // The first dead URL is skipped, the second works. - let dead = format!("file://{}/missing.git", dir.path().display()); - fetch_repo_refs( - &mirror, - &[dead, format!("file://{}", fork_server.display())], - "+refs/heads/*:refs/fork/npub1fork/fork/*", - ) - .expect("fetch"); - - // The imported refs are listed under the prefix only. - assert_eq!( - refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"), - vec!["refs/fork/npub1fork/fork/feature"] - ); - // Nothing leaked into the normal ref namespaces. - assert_eq!( - refs_with_prefix(&mirror, "refs/heads/fork").expect("refs"), - Vec::::new() - ); - - // The mirror can now range across both histories. - // The fork point is the shared initial commit, the proposal covers the fork commit. - assert_eq!( - merge_base( - &mirror, - "refs/remotes/origin/main", - "refs/fork/npub1fork/fork/feature", - ) - .expect("merge base") - .as_deref(), - Some(initial.as_str()) - ); - let patch = format_patch_between(&mirror, &initial, "refs/fork/npub1fork/fork/feature") - .expect("patch"); - assert!(patch.contains("Subject: [PATCH] feature commit")); - assert!(patch.contains("feature.txt")); - - // Pruning the prefix removes the import again. - delete_refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("delete"); - assert_eq!( - refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"), - Vec::::new() - ); - } - - #[test] - fn fetch_repo_refs_fails_when_every_url_fails() { - let (_dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - let dir = _dir.path(); - - let dead = format!("file://{}/missing.git", dir.display()); - let err = fetch_repo_refs(dir, &[dead], "+refs/heads/*:refs/fork/x/*") - .expect_err("all URLs fail"); - assert!(err.to_string().contains("failed to fetch")); - - // Without any URL there is nothing to try. - let err = fetch_repo_refs(dir, &[], "+refs/heads/*:refs/fork/x/*").expect_err("no URLs"); - assert!(err.to_string().contains("no clone URLs")); - } - - #[test] - fn delete_refs_with_prefix_is_a_noop_without_matches() { - let (_dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - delete_refs_with_prefix(_dir.path(), "refs/fork/nothing").expect("noop"); - } - - /// Run a git command in `dir`, asserting success. - fn git_run(dir: &Path, args: &[&str]) { - let status = Command::new("git") - .current_dir(dir) - .env("GIT_AUTHOR_NAME", "Test Author") - .env("GIT_AUTHOR_EMAIL", "test@example.com") - .env("GIT_COMMITTER_NAME", "Test Author") - .env("GIT_COMMITTER_EMAIL", "test@example.com") - .env("GIT_EDITOR", "true") - .args(args) - .status() - .expect("spawn git"); - assert!(status.success(), "git {args:?} failed"); - } - - #[test] - fn last_commit_returns_most_recent_change() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - - std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); - commit_all(&repo, "change a"); - - // A commit touching another file must not be reported for a.txt. - std::fs::write(dir.path().join("b.txt"), b"other").expect("write"); - commit_all(&repo, "add b"); - - let commit = last_commit(&repo, Path::new("a.txt")) - .expect("lookup") - .expect("found"); - assert_eq!(commit.summary, "change a"); - assert_eq!(commit.author, "Test Author"); - assert!(!commit.id.is_empty()); - assert!(commit.time > 0); - } - - #[test] - fn all_commits_lists_every_commit() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - - std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); - commit_all(&repo, "second"); - std::fs::write(dir.path().join("b.txt"), b"b").expect("write"); - commit_all(&repo, "third"); - - let list = all_commits(&repo).expect("commits"); - assert_eq!(list.total, 3); - let mut summaries: Vec<&str> = list.commits.iter().map(|c| c.summary.as_str()).collect(); - summaries.sort(); - assert_eq!(summaries, vec!["initial", "second", "third"]); - assert!( - list.commits - .iter() - .all(|c| c.author == "Test Author" && !c.id.is_empty() && c.time > 0) - ); - } - - #[test] - fn all_commits_returns_empty_without_head() { - let (_dir, repo) = fixture(&[("a.txt", b"one")]); - - let list = all_commits(&repo).expect("commits"); - assert!(list.commits.is_empty()); - assert_eq!(list.total, 0); - } - - #[test] - fn last_commit_returns_none_for_untracked_files() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - std::fs::write(dir.path().join("untracked.txt"), b"x").expect("write"); - - let commit = last_commit(&repo, Path::new("untracked.txt")).expect("lookup"); - assert!(commit.is_none()); - } - - #[test] - fn last_commit_reports_merge_commits() { - let (dir, repo) = fixture(&[("a.txt", b"base")]); - commit_all(&repo, "initial"); - - let run = |args: &[&str]| { - let status = Command::new("git") - .current_dir(dir.path()) - .env("GIT_AUTHOR_NAME", "Test Author") - .env("GIT_AUTHOR_EMAIL", "test@example.com") - .env("GIT_COMMITTER_NAME", "Test Author") - .env("GIT_COMMITTER_EMAIL", "test@example.com") - .env("GIT_EDITOR", "true") - .args(args) - .status() - .expect("spawn git"); - assert!(status.success(), "git {args:?} failed"); - }; - run(&["checkout", "-b", "feature"]); - std::fs::write(dir.path().join("a.txt"), b"feature").expect("write"); - commit_all(&repo, "feature change"); - run(&["checkout", "-"]); - // `--no-ff` forces a merge commit, it is the latest commit changing a.txt. - run(&["merge", "--no-ff", "--no-edit", "feature"]); - - let commit = last_commit(&repo, Path::new("a.txt")) - .expect("lookup") - .expect("found"); - assert_eq!( - commit.id, - repo.head_id().expect("head").shorten_or_id().to_string() - ); - assert!(commit.summary.starts_with("Merge branch")); - } - - #[test] - fn last_commits_batches_multiple_paths() { - let (dir, repo) = fixture(&[("a.txt", b"one"), ("b.txt", b"b")]); - commit_all(&repo, "initial"); - - std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); - commit_all(&repo, "change a"); - std::fs::write(dir.path().join("b.txt"), b"bb").expect("write"); - commit_all(&repo, "change b"); - - let found = worktree_last_commits( - dir.path(), - &[ - PathBuf::from("a.txt"), - PathBuf::from("b.txt"), - // Untracked paths are absent from the result. - PathBuf::from("missing.txt"), - ], - ) - .expect("commits"); - let by_path: HashMap<&Path, &FileCommit> = found - .iter() - .map(|(path, commit)| (path.as_path(), commit)) - .collect(); - assert_eq!(by_path.len(), 2); - assert_eq!(by_path[Path::new("a.txt")].summary, "change a"); - assert_eq!(by_path[Path::new("b.txt")].summary, "change b"); - } - - #[test] - fn find_readme_prefers_markdown() { - let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]); - - let readme = find_readme(&repo).expect("find"); - assert_eq!( - readme.map(|p| p.to_string_lossy().into_owned()), - Some("README.md".into()) - ); - } - - #[test] - fn find_readme_falls_back_to_any_readme() { - let (_dir, repo) = fixture(&[("README.rst", b"rst")]); - - let readme = find_readme(&repo).expect("find"); - assert_eq!( - readme.map(|p| p.to_string_lossy().into_owned()), - Some("README.rst".into()) - ); - } - - #[test] - fn find_readme_returns_none_without_one() { - let (_dir, repo) = fixture(&[("main.rs", b"")]); - assert!(find_readme(&repo).expect("find").is_none()); - } - - #[test] - fn head_commit_reports_head() { - let (_dir, repo) = fixture(&[("a.txt", b"one")]); - - // Unborn HEAD means no commit yet. - assert!(head_commit(&repo).expect("head").is_none()); - - commit_all(&repo, "initial"); - let head = head_commit(&repo).expect("head").expect("commit"); - assert_eq!( - head.id, - repo.head_id().expect("head id").shorten_or_id().to_string() - ); - assert_eq!(head.summary, "initial"); - assert_eq!(head.author, "Test Author"); - } - - #[test] - fn worktree_branches_and_tags_list_short_names() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - let dir = dir.path(); - - git_run(dir, &["checkout", "-b", "feature"]); - git_run(dir, &["tag", "v0.9"]); - git_run(dir, &["tag", "v1.0"]); - - // The initial branch name depends on git configuration. - // Only the branch we created is fixed. - let branches = worktree_branches(dir).expect("branches"); - assert_eq!(branches.len(), 2); - assert!(branches.contains(&"feature".to_string())); - assert!(branches.windows(2).all(|pair| pair[0] <= pair[1]), "sorted"); - - assert_eq!( - repo_tags(&repo).expect("tags"), - vec!["v0.9".to_string(), "v1.0".to_string()] - ); - } - - #[test] - fn current_branch_tracks_checkout() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - let dir = dir.path(); - - let default = worktree_branches(dir) - .expect("branches") - .into_iter() - .next() - .expect("default branch"); - assert_eq!( - current_branch(&repo).expect("branch").as_deref(), - Some(default.as_str()) - ); - - git_run(dir, &["checkout", "-b", "feature"]); - assert_eq!( - current_branch(&repo).expect("branch").as_deref(), - Some("feature") - ); - - // Tags detach HEAD. - git_run(dir, &["tag", "v1.0"]); - worktree_checkout_tag(dir, "v1.0").expect("checkout tag"); - assert_eq!(current_branch(&repo).expect("branch"), None); - - // Branches re-attach HEAD. - worktree_checkout_branch(dir, &default).expect("checkout branch"); - assert_eq!( - current_branch(&repo).expect("branch").as_deref(), - Some(default.as_str()) - ); - } - - #[test] - fn worktree_snapshot_reflects_checked_out_ref() { - let (dir, repo) = fixture(&[("README.md", b"# main"), ("a.txt", b"one")]); - commit_all(&repo, "initial"); - let dir = dir.path(); - - git_run(dir, &["checkout", "-b", "feature"]); - std::fs::write(dir.join("README.md"), b"# feature").expect("write"); - std::fs::write(dir.join("b.txt"), b"b").expect("write"); - commit_all(&repo, "feature work"); - - let snapshot = worktree_snapshot(dir).expect("snapshot"); - assert_eq!(snapshot.current_branch.as_deref(), Some("feature")); - assert_eq!( - snapshot.head_commit.as_ref().expect("head commit").summary, - "feature work" - ); - assert_eq!( - String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"), - "# feature" - ); - let entries: Vec = snapshot - .entries - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - assert!(entries.contains(&"b.txt".to_string())); - - let default = worktree_branches(dir) - .expect("branches") - .into_iter() - .find(|name| name != "feature") - .expect("default branch"); - worktree_checkout_branch(dir, &default).expect("checkout"); - - let snapshot = worktree_snapshot(dir).expect("snapshot"); - assert_eq!(snapshot.current_branch.as_deref(), Some(default.as_str())); - assert_eq!( - snapshot.head_commit.as_ref().expect("head commit").summary, - "initial" - ); - assert_eq!( - String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"), - "# main" - ); - assert!( - !snapshot - .entries - .iter() - .any(|p| p.to_string_lossy() == "b.txt") - ); - } - - #[test] - fn commit_diff_lists_added_modified_and_deleted_files() { - let (dir, repo) = fixture(&[("keep.txt", b"keep"), ("mod.txt", b"one\ntwo\nthree\n")]); - commit_all(&repo, "initial"); - - std::fs::write(dir.path().join("mod.txt"), b"one\ntwo!\nthree\n").expect("write"); - std::fs::write(dir.path().join("new.txt"), b"hello\n").expect("write"); - std::fs::remove_file(dir.path().join("keep.txt")).expect("remove"); - commit_all(&repo, "changes"); - - let head = repo.head_id().expect("head").shorten_or_id().to_string(); - let diff = worktree_commit_diff(dir.path(), &head).expect("diff"); - - let by_path: HashMap<&str, &FileDiff> = diff - .files - .iter() - .map(|file| (file.path.as_str(), file)) - .collect(); - assert_eq!(by_path.len(), 3); - - let added = by_path["new.txt"]; - assert_eq!(added.status, DiffStatus::Added); - assert_eq!(added.insertions, 1); - assert_eq!(added.deletions, 0); - assert_eq!(added.hunks.len(), 1); - assert_eq!(added.hunks[0].lines.len(), 1); - assert_eq!(added.hunks[0].lines[0].kind, DiffLineKind::Addition); - assert_eq!(added.hunks[0].lines[0].old, None); - assert_eq!(added.hunks[0].lines[0].new, Some(1)); - assert_eq!(added.hunks[0].lines[0].text, "hello"); - - let modified = by_path["mod.txt"]; - assert_eq!(modified.status, DiffStatus::Modified); - assert_eq!(modified.insertions, 1); - assert_eq!(modified.deletions, 1); - assert!(!modified.binary); - let lines = &modified.hunks[0].lines; - // One hunk with context around the single-line change. - // The removed line is old 2, the added line is new 2. - assert!(lines.iter().any(|line| { - line.kind == DiffLineKind::Deletion - && line.old == Some(2) - && line.new.is_none() - && line.text == "two" - })); - assert!(lines.iter().any(|line| { - line.kind == DiffLineKind::Addition - && line.old.is_none() - && line.new == Some(2) - && line.text == "two!" - })); - assert!(lines.iter().any(|line| { - line.kind == DiffLineKind::Context && line.old == Some(1) && line.new == Some(1) - })); - - let deleted = by_path["keep.txt"]; - assert_eq!(deleted.status, DiffStatus::Deleted); - assert_eq!(deleted.deletions, 1); - assert_eq!(deleted.hunks[0].lines[0].kind, DiffLineKind::Deletion); - assert_eq!(deleted.hunks[0].lines[0].old, Some(1)); - assert_eq!(deleted.hunks[0].lines[0].new, None); - } - - #[test] - fn commit_range_diff_lists_changes_between_two_commits() { - let (dir, repo) = fixture(&[("a.txt", b"a\n"), ("b.txt", b"b\n")]); - commit_all(&repo, "first"); - let base = repo.head_id().expect("head").to_string(); - - std::fs::write(dir.path().join("a.txt"), b"changed\n").expect("write"); - std::fs::write(dir.path().join("c.txt"), b"new\n").expect("write"); - commit_all(&repo, "second"); - let tip = repo.head_id().expect("head").to_string(); - - let diff = worktree_commit_range_diff(dir.path(), &base, &tip).expect("diff"); - - let by_path: HashMap<&str, &FileDiff> = diff - .files - .iter() - .map(|file| (file.path.as_str(), file)) - .collect(); - assert_eq!(by_path.len(), 2); - assert_eq!(by_path["a.txt"].status, DiffStatus::Modified); - assert_eq!(by_path["a.txt"].insertions, 1); - assert_eq!(by_path["a.txt"].deletions, 1); - assert_eq!(by_path["c.txt"].status, DiffStatus::Added); - // b.txt is unchanged between the two commits. - assert!(diff.files.iter().all(|file| file.path != "b.txt")); - } - - #[test] - fn commit_range_commits_lists_only_new_commits_newest_first() { - let (dir, repo) = fixture(&[("a.txt", b"one\n")]); - commit_all(&repo, "one"); - let base = repo.head_id().expect("head").to_string(); - - std::fs::write(dir.path().join("a.txt"), b"two\n").expect("write"); - commit_all(&repo, "two"); - std::fs::write(dir.path().join("a.txt"), b"three\n").expect("write"); - commit_all(&repo, "three"); - let tip = repo.head_id().expect("head").to_string(); - - let commits = worktree_commit_range_commits(dir.path(), &base, &tip).expect("commits"); - - assert_eq!(commits.len(), 2); - assert_eq!(commits[0].summary, "three"); - assert_eq!(commits[1].summary, "two"); - } - - #[test] - fn commit_diff_reports_binary_files_without_hunks() { - let (_dir, repo) = fixture(&[("blob.bin", b"\x00\x01\x02")]); - commit_all(&repo, "initial"); - - std::fs::write(_dir.path().join("blob.bin"), b"\x00\x03").expect("write"); - commit_all(&repo, "binary change"); - - let head = repo.head_id().expect("head").shorten_or_id().to_string(); - let diff = worktree_commit_diff(_dir.path(), &head).expect("diff"); - let file = diff - .files - .iter() - .find(|f| f.path == "blob.bin") - .expect("file"); - assert!(file.binary); - assert!(file.hunks.is_empty()); - assert_eq!(file.insertions, 0); - assert_eq!(file.deletions, 0); - } - - #[test] - fn commit_diff_resolves_short_ids_and_root_commit() { - let (dir, repo) = fixture(&[("a.txt", b"one\n")]); - commit_all(&repo, "initial"); - - // The root commit diffs against the empty tree, everything is added. - let head = repo.head_id().expect("head").shorten_or_id().to_string(); - let diff = worktree_commit_diff(dir.path(), &head).expect("diff"); - assert_eq!(diff.files.len(), 1); - assert_eq!(diff.files[0].path, "a.txt"); - assert_eq!(diff.files[0].status, DiffStatus::Added); - assert_eq!(diff.files[0].insertions, 1); - } - - #[test] - fn file_commit_includes_message_body() { - let (_dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "title"); - - // A single-line message has no body. - let head = head_commit(&repo).expect("head").expect("commit"); - assert_eq!(head.summary, "title"); - assert_eq!(head.description, None); - - // A message with a body exposes it, trimmed. - let dir = _dir.path(); - let status = Command::new("git") - .current_dir(dir) - .env("GIT_AUTHOR_NAME", "Test Author") - .env("GIT_AUTHOR_EMAIL", "test@example.com") - .env("GIT_COMMITTER_NAME", "Test Author") - .env("GIT_COMMITTER_EMAIL", "test@example.com") - .env("GIT_EDITOR", "true") - .args([ - "commit", - "--allow-empty", - "-m", - "title two", - "-m", - "line one\n\nline two", - ]) - .status() - .expect("spawn git"); - assert!(status.success(), "git commit failed"); - - let head = head_commit(&repo).expect("head").expect("commit"); - assert_eq!(head.summary, "title two"); - assert_eq!(head.description.as_deref(), Some("line one\n\nline two")); - } - - #[test] - fn commit_diff_reports_renames() { - let (_dir, repo) = fixture(&[("old.txt", b"same content\n")]); - commit_all(&repo, "initial"); - - std::fs::rename(_dir.path().join("old.txt"), _dir.path().join("new.txt")).expect("rename"); - commit_all(&repo, "rename"); - - let head = repo.head_id().expect("head").shorten_or_id().to_string(); - let diff = worktree_commit_diff(_dir.path(), &head).expect("diff"); - let file = diff - .files - .iter() - .find(|f| f.path == "new.txt") - .expect("file"); - assert_eq!(file.status, DiffStatus::Renamed); - assert_eq!(file.old_path.as_deref(), Some("old.txt")); - // A pure rename has no content change, the file is still listed. - assert!(file.hunks.is_empty()); - assert_eq!(file.insertions, 0); - assert_eq!(file.deletions, 0); - } - - #[test] - fn parses_format_patch_output() { - let patch = r#"From 1f6c0c5f3f1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a Mon Sep 17 00:00:00 2001 -From: A -Subject: [PATCH] fix - -fix the thing - ---- - src/lib.rs | 2 +- - 1 file changed, 1 insertion(+), 1 deletion(-) - -diff --git a/src/lib.rs b/src/lib.rs -index 1234567..89abcde 100644 ---- a/src/lib.rs -+++ b/src/lib.rs -@@ -1,3 +1,3 @@ - fn main() { -- println!("old"); -+ println!("new"); - } -"#; - let diff = patch_diffs(patch).expect("parse"); - - assert_eq!(diff.files.len(), 1); - let file = &diff.files[0]; - assert_eq!(file.path, "src/lib.rs"); - assert_eq!(file.old_path, None); - assert_eq!(file.status, DiffStatus::Modified); - assert_eq!(file.insertions, 1); - assert_eq!(file.deletions, 1); - - let hunk = &file.hunks[0]; - assert_eq!(hunk.old_start, 1); - assert_eq!(hunk.old_lines, 3); - assert_eq!(hunk.new_start, 1); - assert_eq!(hunk.new_lines, 3); - assert_eq!(hunk.lines.len(), 4); - assert_eq!(hunk.lines[0].kind, DiffLineKind::Context); - assert_eq!(hunk.lines[0].old, Some(1)); - assert_eq!(hunk.lines[0].new, Some(1)); - assert_eq!(hunk.lines[1].kind, DiffLineKind::Deletion); - assert_eq!(hunk.lines[1].old, Some(2)); - assert_eq!(hunk.lines[1].new, None); - assert_eq!(hunk.lines[2].kind, DiffLineKind::Addition); - assert_eq!(hunk.lines[2].old, None); - assert_eq!(hunk.lines[2].new, Some(2)); - assert_eq!(hunk.lines[3].kind, DiffLineKind::Context); - assert_eq!(hunk.lines[3].old, Some(3)); - assert_eq!(hunk.lines[3].new, Some(3)); - } - - #[test] - fn parses_new_file_as_added() { - let patch = r#"diff --git a/README.md b/README.md -new file mode 100644 -index 0000000..1234567 ---- /dev/null -+++ b/README.md -@@ -0,0 +1 @@ -+# hello -"#; - let diff = patch_diffs(patch).expect("parse"); - - let file = &diff.files[0]; - assert_eq!(file.path, "README.md"); - assert_eq!(file.status, DiffStatus::Added); - assert_eq!(file.old_path, None); - assert_eq!(file.insertions, 1); - assert_eq!(file.deletions, 0); - assert_eq!(file.hunks[0].old_start, 0); - assert_eq!(file.hunks[0].old_lines, 0); - assert_eq!(file.hunks[0].new_start, 1); - } - - #[test] - fn parses_renames_with_old_path() { - let patch = r#"diff --git a/old.rs b/new.rs -similarity index 85% -rename from old.rs -rename to new.rs -index 123..456 100644 ---- a/old.rs -+++ b/new.rs -@@ -1 +1 @@ --fn main() {} -+fn main() { println!("hi"); } -"#; - let diff = patch_diffs(patch).expect("parse"); - - let file = &diff.files[0]; - assert_eq!(file.path, "new.rs"); - assert_eq!(file.old_path.as_deref(), Some("old.rs")); - assert_eq!(file.status, DiffStatus::Renamed); - assert_eq!(file.insertions, 1); - assert_eq!(file.deletions, 1); - } - - #[test] - fn parses_patch_series_and_skips_envelope() { - let patch = r#"From aaaa Mon Sep 17 00:00:00 2001 -From: A -Subject: [PATCH 1/2] one - ---- - a.txt | 1 + - 1 file changed, 1 insertion(+) - -diff --git a/a.txt b/a.txt -index 1..2 100644 ---- a/a.txt -+++ b/a.txt -@@ -1 +1,2 @@ - a -+b - -From bbbb Mon Sep 17 00:00:00 2001 -From: A -Subject: [PATCH 2/2] two - -diff --git a/b.txt b/b.txt -index 3..4 100644 ---- a/b.txt -+++ b/b.txt -@@ -1 +1 @@ --x -+y -"#; - let diff = patch_diffs(patch).expect("parse"); - - assert_eq!(diff.files.len(), 2); - assert_eq!(diff.files[0].path, "a.txt"); - assert_eq!(diff.files[0].insertions, 1); - assert_eq!(diff.files[1].path, "b.txt"); - assert_eq!(diff.files[1].deletions, 1); - } - - #[test] - fn patch_commits_lists_every_patch_in_order() { - let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001 -From: Alice -Date: Tue, 1 Aug 2023 10:00:00 +0200 -Subject: [PATCH 1/2] first - -body one ---- - a.txt | 1 + - 1 file changed, 1 insertion(+) - -diff --git a/a.txt b/a.txt -@@ -1 +1,2 @@ - a -+b - -From 2222222222222222222222222222222222222222 Mon Sep 17 00:00:00 2001 -From: Bob -Date: Wed, 2 Aug 2023 11:30:00 +0000 -Subject: [PATCH 2/2] second - -body two ---- - b.txt | 1 + - 1 file changed, 1 insertion(+) - -diff --git a/b.txt b/b.txt -@@ -1 +1,2 @@ - x -+y -"#; - - let commits = patch_commits(patch); - assert_eq!(commits.len(), 2); - - assert_eq!(commits[0].id, "1111111111111111111111111111111111111111"); - assert_eq!(commits[0].summary, "first"); - assert_eq!(commits[0].author, "Alice"); - assert_eq!(commits[0].time, 1690876800); - - assert_eq!(commits[1].id, "2222222222222222222222222222222222222222"); - assert_eq!(commits[1].summary, "second"); - assert_eq!(commits[1].author, "Bob"); - assert_eq!(commits[1].time, 1690975800); - } - - #[test] - fn patch_commits_strips_patch_subject_prefixes() { - let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001 -From: A -Subject: [RFC PATCH v3 4/7] the real title - ---- -"#; - - let commits = patch_commits(patch); - assert_eq!(commits.len(), 1); - assert_eq!(commits[0].summary, "the real title"); - } - - #[test] - fn patch_commits_handles_missing_headers() { - // A hand-written patch without author or date headers still lists a commit. - // Time stays 0 and the author stays empty. - let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001 -Subject: [PATCH] plain - ---- -"#; - - let commits = patch_commits(patch); - assert_eq!(commits.len(), 1); - assert_eq!(commits[0].summary, "plain"); - assert_eq!(commits[0].author, ""); - assert_eq!(commits[0].time, 0); - } - - #[test] - fn patch_commits_ignores_non_patch_lines() { - assert!(patch_commits("").is_empty()); - assert!(patch_commits("just some text\nFrom 123\n").is_empty()); - // A diff-only body without an mbox envelope has no commits. - let patch = "diff --git a/x b/x\n--- a/x\n+++ b/x\n"; - assert!(patch_commits(patch).is_empty()); - } - - #[test] - fn marks_binary_sections() { - let patch = r#"diff --git a/img.png b/img.png -index 123..456 100644 -Binary files a/img.png and b/img.png differ -"#; - let diff = patch_diffs(patch).expect("parse"); - - assert!(diff.files[0].binary); - assert!(diff.files[0].hunks.is_empty()); - } - - #[test] - fn unquotes_quoted_paths() { - let patch = r#"diff --git "a/weird file.rs" "b/weird file.rs" -index 123..456 100644 ---- "a/weird file.rs" -+++ "b/weird file.rs" -@@ -1 +1 @@ --x -+y -"#; - let diff = patch_diffs(patch).expect("parse"); - - assert_eq!(diff.files[0].path, "weird file.rs"); - assert_eq!(diff.files[0].status, DiffStatus::Modified); - } - - #[test] - fn unquotes_non_ascii_quoted_paths() { - let patch = r#"diff --git "a/说明.md" "b/说明.md" -index 123..456 100644 ---- "a/说明.md" -+++ "b/说明.md" -@@ -1 +1 @@ --x -+y -"#; - let diff = patch_diffs(patch).expect("parse"); - - assert_eq!(diff.files[0].path, "说明.md"); - assert_eq!(diff.files[0].status, DiffStatus::Modified); - } - - #[test] - fn unquotes_octal_escaped_paths() { - let patch = r#"diff --git "a/\345\270\226.md" "b/\345\270\226.md" -index 123..456 100644 ---- "a/\345\270\226.md" -+++ "b/\345\270\226.md" -@@ -1 +1 @@ --x -+y -"#; - let diff = patch_diffs(patch).expect("parse"); - - assert_eq!(diff.files[0].path, "帖.md"); - assert_eq!(diff.files[0].status, DiffStatus::Modified); - } - - #[test] - fn empty_or_unparseable_patch_yields_no_files() { - assert_eq!(patch_diffs("").expect("parse").files.len(), 0); - assert_eq!(patch_diffs("just some text").expect("parse").files.len(), 0); - assert_eq!( - patch_diffs("---\nnot a patch\n") - .expect("parse") - .files - .len(), - 0 - ); - } - - #[test] - fn parses_real_format_patch_output() { - // Build a commit touching a mix of file kinds. - // Feed genuine `git format-patch` output through the parser. - // It covers quoted and octal-escaped paths. - // There are also a rename-free modification, an addition and a binary deletion. - let (dir, repo) = fixture(&[ - ("src/main.rs", b"fn main() {\n println!(\"one\");\n}\n"), - ("my file.txt", b"hello\n"), - ("\u{8bf4}\u{660e}.md", "# \u{8bf4}\u{660e}\n".as_bytes()), - ("img.png", b"\x89PNG\r\n\x1a\n\x00binary"), - ]); - commit_all(&repo, "initial"); - - std::fs::write( - dir.path().join("src/main.rs"), - b"fn main() {\n println!(\"two\");\n println!(\"three\");\n}\n", - ) - .expect("write"); - std::fs::write(dir.path().join("my file.txt"), b"hello world\n").expect("write"); - std::fs::write( - dir.path().join("\u{8bf4}\u{660e}.md"), - "# \u{8bf4}\u{660e}\nupdated\n", - ) - .expect("write"); - std::fs::remove_file(dir.path().join("img.png")).expect("remove"); - std::fs::write(dir.path().join("new file.md"), b"# new\n").expect("write"); - commit_all(&repo, "changes"); - - let output = Command::new("git") - .current_dir(dir.path()) - .env("GIT_AUTHOR_NAME", "Test Author") - .env("GIT_AUTHOR_EMAIL", "test@example.com") - .env("GIT_COMMITTER_NAME", "Test Author") - .env("GIT_COMMITTER_EMAIL", "test@example.com") - .args(["format-patch", "-1", "--stdout"]) - .output() - .expect("spawn git format-patch"); - assert!(output.status.success(), "git format-patch failed"); - let patch = String::from_utf8(output.stdout).expect("patch is utf-8"); - - let diff = patch_diffs(&patch).expect("parse real format-patch output"); - - let by_path = |path: &str| { - diff.files - .iter() - .find(|file| file.path == path) - .unwrap_or_else(|| panic!("missing file {path:?}")) - }; - - // Space in the name makes git quote the path in the header. - let file = by_path("my file.txt"); - assert_eq!(file.status, DiffStatus::Modified); - assert_eq!(file.insertions, 1); - - // UTF-8 names are emitted as octal escapes. - let file = by_path("\u{8bf4}\u{660e}.md"); - assert_eq!(file.status, DiffStatus::Modified); - assert_eq!(file.insertions, 1); - - let file = by_path("src/main.rs"); - assert_eq!(file.status, DiffStatus::Modified); - assert_eq!(file.insertions, 2); - assert_eq!(file.deletions, 1); - assert!(!file.hunks.is_empty()); - - let file = by_path("new file.md"); - assert_eq!(file.status, DiffStatus::Added); - assert_eq!(file.insertions, 1); - - // A binary deletion emits no `---` or `+++` lines. - // Only the mode line and the `Binary files` marker remain. - let file = by_path("img.png"); - assert_eq!(file.status, DiffStatus::Deleted); - assert!(file.binary); - assert!(file.hunks.is_empty()); - } - - #[test] - fn worktree_dirty_tracks_changes_and_untracked_files() { - let (dir, repo) = fixture(&[("tracked.txt", b"one")]); - commit_all(&repo, "initial"); - let workdir = dir.path(); - - assert!(!worktree_dirty(workdir)); - - // A modified tracked file is dirty. - std::fs::write(workdir.join("tracked.txt"), b"two").expect("write"); - assert!(worktree_dirty(workdir)); - - // After restoring, an untracked file alone is dirty as well. - git_run(workdir, &["checkout", "--", "tracked.txt"]); - assert!(!worktree_dirty(workdir)); - std::fs::write(workdir.join("untracked.txt"), b"new").expect("write"); - assert!(worktree_dirty(workdir)); - - // A staged change counts too. - git_run(workdir, &["rm", "--cached", "tracked.txt"]); - assert!(worktree_dirty(workdir)); - - // A missing directory is clean, not an error. - assert!(!worktree_dirty(&dir.path().join("missing"))); - } - - #[test] - fn worktree_dirty_reports_unborn_worktrees_with_files() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("repo"); - let status = Command::new("git") - .args(["init", "-q"]) - .arg(&path) - .status() - .expect("spawn git init"); - assert!(status.success()); - - // No commits and no files: porcelain is empty. - assert!(!worktree_dirty(&path)); - // An unborn repository holding files is dirty. - std::fs::write(path.join("README.md"), "# hello\n").expect("write"); - assert!(worktree_dirty(&path)); - } - - #[test] - fn worktree_commits_ahead_counts_branch_only_commits() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - let path = dir.path(); - - git_run(path, &["checkout", "-q", "-b", "feature"]); - std::fs::write(path.join("f.txt"), b"f\n").expect("write"); - commit_all(&gix::open(path).expect("open"), "feature work"); - - assert_eq!(worktree_commits_ahead(path, "main", "feature"), 1); - assert_eq!(worktree_commits_ahead(path, "feature", "main"), 0); - - git_run(path, &["checkout", "-q", "main"]); - assert_eq!(worktree_current_branch(path).as_deref(), Some("main")); - assert!(worktree_ref_exists(path, "refs/heads/feature")); - assert!(!worktree_ref_exists(path, "refs/heads/nope")); - assert_eq!(worktree_commits_ahead(path, "main", "feature"), 1); - } -} diff --git a/crates/signed_git/src/patch.rs b/crates/signed_git/src/patch.rs new file mode 100644 index 0000000..873c23a --- /dev/null +++ b/crates/signed_git/src/patch.rs @@ -0,0 +1,323 @@ +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result, bail}; +use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet}; +use diffy::{Hunk, Line}; + +use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff}; +use crate::history::FileCommit; + +/// Apply a `git format-patch` patch or series with `git am`, +/// uses the git CLI because it handles the mbox format natively. +/// +/// TODO: Replaced with a pure-Rust implementation later without changing callers. +pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> { + let mut child = Command::new("git") + .arg("am") + .current_dir(repo_path) + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("failed to spawn `git am`")?; + + child + .stdin + .as_mut() + .expect("stdin piped") + .write_all(patch.as_bytes())?; + + let output = child.wait_with_output()?; + + if !output.status.success() { + bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr)); + } + + Ok(()) +} + +/// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`. +/// Fails when the range has no commits. +/// +/// The mbox is returned untrimmed. Trailing newlines are part of the format. +pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(repo_path) + .args(["format-patch", "--stdout", &format!("{base}..{tip}")]) + .env("GIT_TERMINAL_PROMPT", "0") + .stderr(Stdio::piped()) + .output() + .context("failed to spawn `git format-patch`")?; + + if !output.status.success() { + bail!( + "git format-patch failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let patch = String::from_utf8_lossy(&output.stdout).into_owned(); + + if patch.trim().is_empty() { + bail!("no commits between {base} and {tip}"); + } + + Ok(patch) +} + +/// Split a `git format-patch` series into its individual patches, mbox messages. +/// +/// A single patch yields one element. +/// A malformed input yields one element covering it. +pub fn split_patch_series(patch: &str) -> Vec<&str> { + let mut starts = vec![0usize]; + let mut search_from = 1; + + while let Some(rel) = patch[search_from..].find("\nFrom ") { + let ix = search_from + rel + 1; + let hex = patch[ix + 5..] + .split(|c: char| !c.is_ascii_hexdigit()) + .next() + .unwrap_or(""); + if hex.len() == 40 { + starts.push(ix); + } + search_from = ix + 1; + } + + starts + .iter() + .enumerate() + .map(|(i, &start)| { + let end = starts.get(i + 1).copied().unwrap_or(patch.len()); + &patch[start..end] + }) + .collect() +} + +/// 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 { + if !patch.lines().any(|line| line.starts_with("diff --git ")) { + return Ok(CommitDiff { files: Vec::new() }); + } + + 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 { + // 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. +pub fn patch_commits(patch: &str) -> Vec { + let lines: Vec<&str> = patch.lines().collect(); + + let mut commits = Vec::new(); + let mut i = 0; + + while i < lines.len() { + // A patch starts with its `From ` envelope line. + let Some(rest) = lines[i].strip_prefix("From ") else { + i += 1; + continue; + }; + + let Some(id) = rest.split_whitespace().next() else { + i += 1; + continue; + }; + + if id.len() != 40 { + i += 1; + continue; + } + + let mut author = String::new(); + let mut summary = String::new(); + let mut time = 0i64; + + // Envelope headers run up to the blank line before the commit message. + i += 1; + while i < lines.len() && !lines[i].is_empty() { + let header = lines[i]; + if let Some(value) = header.strip_prefix("From: ") { + author = name_from_address(value); + } else if let Some(value) = header.strip_prefix("Subject: ") { + summary = strip_patch_prefix(value); + } else if let Some(value) = header.strip_prefix("Date: ") { + time = gix::date::parse(value.trim(), None) + .map(|t| t.seconds) + .unwrap_or(0); + } + i += 1; + } + + commits.push(FileCommit { + id: id.to_string(), + summary, + description: None, + author, + time, + }); + } + + commits +} + +/// The name part of a `From: Name ` header value. +fn name_from_address(from: &str) -> String { + match from.trim().find('<') { + Some(ix) => from[..ix].trim().to_string(), + None => from.trim().to_string(), + } +} + +/// Strip the patch prefix from a `Subject:` header. +/// +/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`. +fn strip_patch_prefix(subject: &str) -> String { + let trimmed = subject.trim(); + let Some(rest) = trimmed.strip_prefix('[') else { + return trimmed.to_string(); + }; + let Some(end) = rest.find(']') else { + return trimmed.to_string(); + }; + if rest[..end].to_ascii_lowercase().contains("patch") { + rest[end + 1..].trim().to_string() + } else { + trimmed.to_string() + } +} diff --git a/crates/signed_git/src/remote.rs b/crates/signed_git/src/remote.rs new file mode 100644 index 0000000..432eef0 --- /dev/null +++ b/crates/signed_git/src/remote.rs @@ -0,0 +1,357 @@ +use std::collections::HashMap; +use std::path::Path; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result, bail}; +use gix::interrupt::IS_INTERRUPTED; +use gix::progress::Discard; + +/// Clone into `path` from the first working URL in `clone_urls`. +/// +/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache. +pub fn clone_repo>(clone_urls: &[U], path: &Path) -> Result<()> { + if path.exists() { + bail!("destination {} already exists", path.display()); + } + + try_each_url(clone_urls, "clone", |url| { + let repo = clone(url, path)?; + // The initial clone uses the default refspecs. Also fetch the `refs/nostr/*` PR refs. + fetch_all(&repo).ok(); + Ok(()) + }) +} + +/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace. +pub fn fetch_all(repo: &gix::Repository) -> Result<()> { + let options = gix::remote::ref_map::Options { + extra_refspecs: vec![ + gix::refspec::parse( + gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"), + gix::refspec::parse::Operation::Fetch, + )? + .to_owned(), + ], + ..Default::default() + }; + repo.find_remote("origin")? + .connect(gix::remote::Direction::Fetch)? + .prepare_fetch(Discard, options)? + .receive(Discard, &IS_INTERRUPTED)?; + Ok(()) +} + +/// Push `commit` to `reference` on the server at `url`, from `repo_path`. +pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> { + let output = Command::new("git") + .arg("-C") + .arg(repo_path) + .args(["push"]) + .arg(url) + .arg(format!("{commit}:{reference}")) + .env("GIT_TERMINAL_PROMPT", "0") + .stderr(Stdio::piped()) + .output() + .context("failed to spawn `git push`")?; + + if !output.status.success() { + bail!( + "git push failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +/// Rewrite a grasp server URL to the https URL the git transport actually uses. +/// +/// GRASP servers announce `grasp:////` clone URLs. +/// The transport is git smart HTTP, so the scheme is rewritten for gix. +fn transport_url(url: &str) -> String { + url.strip_prefix("grasp://") + .map(|rest| format!("https://{rest}")) + .unwrap_or_else(|| url.to_owned()) +} + +/// Run `attempt` against each URL in `urls` until one succeeds. +/// +/// Returns the last error wrapped in `failed to {verb} from any mirror`, +/// or `no clone URLs provided` when the list is empty. +fn try_each_url, F>(urls: &[U], verb: &str, mut attempt: F) -> Result<()> +where + F: FnMut(&str) -> Result<()>, +{ + let mut last_err: Option = None; + + for url in urls { + match attempt(url.as_ref()) { + Ok(()) => return Ok(()), + Err(e) => last_err = Some(e), + } + } + + match last_err { + Some(e) => Err(e).context(format!("failed to {verb} from any mirror")), + None => bail!("no clone URLs provided"), + } +} + +fn clone(url: &str, path: &Path) -> Result { + let url = transport_url(url); + let url = gix::url::parse(url).context("invalid clone URL")?; + + let mut prepare = gix::prepare_clone(url, path)?; + let (mut checkout, _fetch) = prepare.fetch_then_checkout(Discard, &IS_INTERRUPTED)?; + let (repo, _checkout) = checkout.main_worktree(Discard, &IS_INTERRUPTED)?; + + Ok(repo) +} + +/// Push the `main` branch of the repository at `repo_path` to a grasp server. +pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { + push_refspecs( + repo_path, + base_url, + owner, + repo_id, + &["refs/heads/main:refs/heads/main"], + ) +} + +/// Push every local branch and tag of the repository at `repo_path` to a grasp server. +/// +/// This mirrors an initialized repository's whole history. +pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { + push_refspecs( + repo_path, + base_url, + owner, + repo_id, + &["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], + ) +} + +/// Push `refspecs` to the grasp server URL derived from `base_url`, `owner` and `repo_id`. +fn push_refspecs( + repo_path: &Path, + base_url: &str, + owner: &str, + repo_id: &str, + refspecs: &[&str], +) -> Result<()> { + let url = format!("{base_url}/{owner}/{repo_id}.git"); + + let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2); + args.push("push"); + args.push(&url); + args.extend_from_slice(refspecs); + + let output = git_output(repo_path, &args, "git push")?; + + if !output.status.success() { + bail!( + "git push to {base_url} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +/// Whether `url` advertises every ref in `expected` at the given commit. +/// +/// Extra advertised refs are ignored: the question is whether the data this +/// push wanted to land is already there, not whether the remote is an exact mirror. +/// This is the convergence probe for a push that lost the compare-and-swap race +/// to the grasp server's own background ref alignment. +pub fn remote_has_refs(repo_path: &Path, url: &str, expected: &[(String, String)]) -> Result { + if expected.is_empty() { + return Ok(true); + } + + let repo = gix::open(repo_path)?; + let url = transport_url(url); + + // A URL-created remote has no configured fetch refspecs, and `ref_map` only + // keeps refs that match one. Match each expected ref by its exact name, + // like `git ls-remote ` would; ref maps never write to the repository. + let refspecs = expected + .iter() + .map(|(name, _)| { + gix::refspec::parse( + gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()), + gix::refspec::parse::Operation::Fetch, + ) + .map(|spec| spec.to_owned()) + }) + .collect::, _>>() + .context("invalid refspec")?; + + let options = gix::remote::ref_map::Options { + extra_refspecs: refspecs, + ..Default::default() + }; + + let (refs, _) = repo + .remote_at(url.as_str()) + .with_context(|| format!("cannot use remote {url}"))? + .connect(gix::remote::Direction::Fetch) + .with_context(|| format!("cannot connect to {url}"))? + .ref_map(Discard, options) + .with_context(|| format!("listing refs of {url} failed"))?; + + // Peeled tag entries carry the tag object in their direct oid, so mapping + // each advertised ref to its direct oid matches `git ls-remote` while + // skipping the duplicated `^{}` lines. + let advertised: HashMap = refs + .remote_refs + .iter() + .filter_map(|reference| { + let (name, object, _peeled) = reference.unpack(); + object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string())) + }) + .collect(); + + Ok(expected + .iter() + .all(|(name, oid)| advertised.get(name.as_str()) == Some(oid))) +} + +/// Add `origin` pointing at `url` when the repository has no remote yet. +/// +/// No-op if `origin` already exists. +pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> { + let repo = gix::open(repo_path)?; + if repo.find_remote("origin").is_ok() { + return Ok(()); + } + + // `git remote add` also configures the default fetch refspec. + edit_local_config(&repo, |config| { + config.set_raw_value("remote.origin.url", url)?; + config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; + Ok(()) + }) +} + +/// Point `origin` at `url`, replacing an existing remote, +/// used after a clone whose `origin` points at the cloned-from path. +/// +/// A working copy cloned from a local mirror is re-targeted at the grasp server. +pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> { + let repo = gix::open(repo_path)?; + let had_origin = repo.find_remote("origin").is_ok(); + + edit_local_config(&repo, |config| { + // Replaces the existing url, like `git remote set-url origin `. + // A pre-existing fetch refspec is left untouched. + config.set_raw_value("remote.origin.url", url)?; + + if !had_origin { + config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; + } + + Ok(()) + }) +} + +/// Apply `edit` to the repository-local configuration and persist it. +/// +/// The config file is locked while it is read, edited and written back, +/// like git would when running `git config` or `git remote`. +fn edit_local_config( + repo: &gix::Repository, + edit: impl FnOnce(&mut gix::config::File) -> Result<()>, +) -> Result<()> { + let config_path = repo.common_dir().join("config"); + + let mut lock = gix::lock::File::acquire_to_update_resource( + &config_path, + gix::lock::acquire::Fail::Immediately, + None, + ) + .context("failed to lock repository config")?; + + let mut config = + match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local) { + Ok(config) => config, + // A repository without a config file yet starts from scratch. + Err(gix::config::file::init::from_paths::Error::Io { source, .. }) + if source.kind() == std::io::ErrorKind::NotFound => + { + gix::config::File::default() + } + Err(error) => return Err(error).context("failed to read repository config"), + }; + + edit(&mut config)?; + + config + .write_to(&mut lock) + .context("failed to write repository config")?; + + lock.commit().context("failed to save repository config")?; + + Ok(()) +} + +/// Fetch `refspec` into `repo_path` from the first working URL in `urls`. +/// When no URL works, the last error is returned. +/// +/// Never touches the checked-out refs or the worktree. +pub fn fetch_repo_refs>(repo_path: &Path, urls: &[U], refspec: &str) -> Result<()> { + let repo = gix::open(repo_path)?; + let refspec = gix::refspec::parse( + gix::bstr::BStr::new(refspec), + gix::refspec::parse::Operation::Fetch, + ) + .context("invalid fetch refspec")? + .to_owned(); + + try_each_url(urls, "fetch", |url| { + let url = transport_url(url); + let options = gix::remote::ref_map::Options { + extra_refspecs: vec![refspec.clone()], + ..Default::default() + }; + repo.remote_at(url.as_str()) + .with_context(|| format!("fetch from {url} failed"))? + .connect(gix::remote::Direction::Fetch) + .with_context(|| format!("fetch from {url} failed"))? + .prepare_fetch(Discard, options) + .with_context(|| format!("fetch from {url} failed"))? + .receive(Discard, &IS_INTERRUPTED) + .with_context(|| format!("fetch from {url} failed"))?; + Ok(()) + }) +} + +/// The URL of the `origin` remote of the repository at `workdir`. +/// +/// `None` when it has no `origin` yet. +pub fn origin_url(workdir: &Path) -> Result> { + let Ok(repo) = gix::open(workdir) else { + return Ok(None); + }; + + let Ok(remote) = repo.find_remote("origin") else { + return Ok(None); + }; + + Ok(remote + .url(gix::remote::Direction::Fetch) + .map(|url| url.to_string())) +} + +/// Run `git -C dir args`, disabling the terminal prompt and capturing stderr. +/// +/// `what` names the command in the spawn error. +pub(crate) fn git_output(dir: &Path, args: &[&str], what: &str) -> Result { + Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .env("GIT_TERMINAL_PROMPT", "0") + .stderr(Stdio::piped()) + .output() + .with_context(|| format!("failed to spawn `{what}`")) +} diff --git a/crates/signed_git/src/repo.rs b/crates/signed_git/src/repo.rs new file mode 100644 index 0000000..4b86afb --- /dev/null +++ b/crates/signed_git/src/repo.rs @@ -0,0 +1,488 @@ +use std::path::Path; + +use anyhow::{Context, Result, bail}; + +use crate::history::open_with_cache; +use crate::worktree::{force_checkout, worktree_dirty}; + +/// The merge base of two revisions in the repository at `repo_path`, +/// revisions may be branch names, remote-tracking refs or commit ids. +/// +/// `Ok(None)` when the revisions share no common ancestor. +/// +/// Unresolvable revisions are errors. +pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result> { + let repo = open_with_cache(repo_path)?; + let a = repo.rev_parse_single(a.as_bytes())?; + let b = repo.rev_parse_single(b.as_bytes())?; + match repo.merge_base(a, b) { + Ok(id) => Ok(Some(id.to_string())), + // No common ancestor, a valid outcome for a proposal. + Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None), + Err(e) => Err(e.into()), + } +} + +/// The commit HEAD points to in the repository at `repo_path`. +/// +/// `None` when the repository has no commits yet, an unborn HEAD. +pub fn head_commit_id(repo_path: &Path) -> Result> { + let Ok(repo) = gix::open(repo_path) else { + return Ok(None); + }; + + match repo.head_id() { + Ok(id) => Ok(Some(id.to_string())), + Err(_) => Ok(None), + } +} + +/// The commits in `base..HEAD` of the repository at `repo_path`, oldest first. +/// This is the order `git am` creates them. +/// +/// `HEAD` alone when `base` is `None`. +pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result> { + let repo = match gix::open(repo_path) { + Ok(repo) => repo, + Err(_) if base.is_none() => return Ok(Vec::new()), + Err(e) => return Err(e.into()), + }; + + let head = match repo.head_id() { + Ok(head) => head, + Err(_) if base.is_none() => return Ok(Vec::new()), + Err(e) => return Err(e).context("repository has no commits"), + }; + + let Some(base) = base else { + // `HEAD` alone when no base is given. + return Ok(vec![head.to_string()]); + }; + + let base = repo.rev_parse_single(base.as_bytes())?; + let mut commits = Vec::new(); + + for info in repo + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, + )) + .with_hidden([base]) + .all()? + { + commits.push(info?.id().to_string()); + } + + // Oldest first, like `git rev-list --reverse`, the order `git am` creates them. + commits.reverse(); + + Ok(commits) +} + +/// The identity written to reflogs and commits created by this crate itself. +/// +/// Like `git -c user.name=… -c user.email=…` per invocation: the repository works +/// without a global git identity, and `gix` runs no hooks and never signs. +pub(crate) fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) { + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or_default(); + + let signature = gix::actor::Signature { + name: gix::bstr::BString::from("Signed"), + email: gix::bstr::BString::from("signed@localhost"), + time: gix::date::Time { seconds, offset: 0 }, + }; + + (signature, gix::date::parse::TimeBuf::default()) +} + +/// Create a repository at `path` with an initial `main` branch. +/// Write a `README.md` from `name` and `description`, then create the initial commit. +/// +/// Returns the initial commit id. +pub fn init_repository(path: &Path, name: &str, description: &str) -> Result { + use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; + + std::fs::create_dir_all(path) + .with_context(|| format!("failed to create {}", path.display()))?; + + let repo = gix::init(path)?; + + let (signature, mut time_buf) = repository_signature(); + let signature = signature.to_ref(&mut time_buf); + + // The initial branch is `main`, regardless of `init.defaultBranch` in + // the user's git configuration: point the unborn HEAD there. + let head = gix::refs::FullName::try_from("HEAD") + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; + + repo.edit_references_as( + [RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: "checkout: moving to main".into(), + }, + expected: PreviousValue::Any, + new: gix::refs::Target::Symbolic( + gix::refs::FullName::try_from("refs/heads/main") + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?, + ), + }, + name: head, + deref: false, + }], + Some(signature), + )?; + + let readme = if description.trim().is_empty() { + format!("# {name}\n") + } else { + format!("# {name}\n\n{description}\n") + }; + + std::fs::write(path.join("README.md"), &readme).context("failed to write README.md")?; + + let blob = repo.write_object(gix::objs::Blob { + data: readme.into_bytes(), + })?; + + let tree = repo.write_object(gix::objs::Tree { + entries: vec![gix::objs::tree::Entry { + mode: gix::objs::tree::EntryKind::Blob.into(), + filename: gix::bstr::BString::from("README.md"), + oid: blob.into(), + }], + })?; + + let commit = repo.commit_as( + signature, + signature, + "HEAD", + "Initial commit", + tree, + Vec::::new(), + )?; + + // Populate the index so the fresh repository is clean, + // as `git add` and`git commit` would leave it. + let mut index = repo.index_from_tree(&tree)?; + index.write(gix::index::write::Options::default())?; + + let commit = commit.to_string(); + if commit.len() != 40 { + bail!("unexpected initial commit id: {commit}"); + } + + Ok(commit) +} + +/// The earliest unique commit of the repository at `repo_path`. +/// Used as the NIP-34 announcement's `euc` marker. +/// +/// `None` for a repository without commits. +pub fn root_commit(repo_path: &Path) -> Result> { + let Ok(repo) = gix::open(repo_path) else { + return Ok(None); + }; + + let Ok(head) = repo.head_id() else { + // An unborn HEAD with no commits yet has no root commit. + return Ok(None); + }; + + for info in repo + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, + )) + .all()? + { + let info = info?; + if info.parent_ids().next().is_none() { + let id = info.id().to_string(); + return Ok((id.len() == 40).then_some(id)); + } + } + + Ok(None) +} + +/// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`. +/// `prefix` is a ref namespace like `refs/fork//`. +/// +/// Returns an empty list when nothing matches. +pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result> { + let pattern = prefix.trim_end_matches('/'); + let repo = gix::open(repo_path)?; + let mut names = Vec::new(); + + for reference in repo.references()?.all()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + let name = String::from_utf8_lossy(reference.name().as_bstr()).into_owned(); + + // Match the pattern itself and everything beneath it, like `git for-each-ref`. + let under_pattern = name + .strip_prefix(pattern) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')); + + if under_pattern { + names.push(name); + } + } + + // Sort lexicographically, like `git for-each-ref`. + names.sort(); + + Ok(names) +} + +/// Delete every ref under `prefix` of the repository at `repo_path`. +/// `prefix` is a ref namespace like `refs/fork//`. +pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> { + use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog}; + + let refs = refs_with_prefix(repo_path, prefix)?; + if refs.is_empty() { + return Ok(()); + } + + let repo = gix::open(repo_path)?; + let edits: Vec = refs + .iter() + .map(|name| { + let full = gix::refs::FullName::try_from(name.as_str()) + .map_err(|e| anyhow::anyhow!("invalid ref name {name}: {e}"))?; + Ok(RefEdit { + change: Change::Delete { + expected: PreviousValue::Any, + log: RefLog::AndReference, + }, + name: full, + deref: false, + }) + }) + .collect::>>()?; + + // Delete all refs with the given prefix. + repo.edit_references(edits)?; + + Ok(()) +} + +/// Short name of the branch HEAD points to at `workdir`, +/// `None` when detached or unreadable, like `git branch --show-current`. +pub fn worktree_current_branch(workdir: &Path) -> Option { + let repo = gix::open(workdir).ok()?; + let head = repo.head().ok()?; + let name = head.referent_name()?; + Some(String::from_utf8_lossy(name.shorten()).into_owned()) +} + +/// Whether the reference `name` exists in the repository at `workdir`. +pub fn worktree_ref_exists(workdir: &Path, name: &str) -> bool { + let Ok(repo) = gix::open(workdir) else { + return false; + }; + repo.find_reference(name).is_ok() +} + +/// Fast-forward local branches that trail their remote-tracking counterpart. +/// +/// Returns whether any branch moved. +pub fn fast_forward_branches(workdir: &Path) -> Result { + let repo = gix::open(workdir)?; + let current = worktree_current_branch(workdir); + let heads = refs_with_prefix(workdir, "refs/heads")?; + + let (signature, mut time_buf) = repository_signature(); + let signature = signature.to_ref(&mut time_buf); + + let mut moved = false; + + for head in heads { + let Some(branch) = head.strip_prefix("refs/heads/") else { + continue; + }; + + let remote = format!("refs/remotes/origin/{branch}"); + // No remote-tracking counterpart means the remote lacks this branch. + let Ok(mut remote_reference) = repo.find_reference(&remote) else { + continue; + }; + + let Ok(mut local_reference) = repo.find_reference(&head) else { + continue; + }; + + let Ok(remote_oid) = remote_reference.peel_to_id() else { + continue; + }; + + let Ok(local_oid) = local_reference.peel_to_id() else { + continue; + }; + + let remote_oid = remote_oid.detach(); + let local_oid = local_oid.detach(); + + if local_oid == remote_oid { + continue; + } + + // Only fast-forward. + // Local-only commits or diverged history must never be rewritten by a refresh. + let Ok(base) = repo.merge_base(local_oid, remote_oid) else { + continue; + }; + + if base != local_oid { + continue; + } + + let full = gix::refs::FullName::try_from(head.as_str()) + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; + + let edit = |new: gix::refs::Target| { + use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; + RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: format!("merge {remote}: Fast-forward").into(), + }, + expected: PreviousValue::ExistingMustMatch(gix::refs::Target::Object( + local_oid, + )), + new, + }, + name: full.clone(), + deref: false, + } + }; + + if current.as_deref() == Some(branch) { + // Merge so the checked-out worktree follows the branch. + // Only proceed on a clean worktree, like `git merge --ff-only`. + if worktree_dirty(workdir) { + continue; + } + + let tree = repo.find_object(remote_oid)?.peel_to_tree()?.id; + + // Check out the remote tree, discarding local changes. + force_checkout(&repo, &tree)?; + + // Update the branch reference to point to the remote tree. + repo.edit_references_as( + [edit(gix::refs::Target::Object(remote_oid))], + Some(signature), + )?; + + moved = true; + } else { + // Update the branch reference to point to the remote tree. + repo.edit_references_as( + [edit(gix::refs::Target::Object(remote_oid))], + Some(signature), + )?; + + moved = true; + } + } + + Ok(moved) +} + +/// Short names of local branches, `refs/heads/*`, of `repo`, sorted alphabetically. +pub fn repo_branches(repo: &gix::Repository) -> Result> { + let mut names = Vec::new(); + for reference in repo.references()?.local_branches()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); + } + names.sort(); + Ok(names) +} + +/// Short names of tags, `refs/tags/*`, of `repo`, sorted alphabetically. +pub fn repo_tags(repo: &gix::Repository) -> Result> { + let mut names = Vec::new(); + for reference in repo.references()?.tags()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); + } + names.sort(); + Ok(names) +} + +/// Short names of local branches, `refs/heads/*`, sorted alphabetically. +pub fn worktree_branches(workdir: &Path) -> Result> { + repo_branches(&gix::open(workdir)?) +} + +/// Short name of the branch HEAD points to, or `None` when detached. +/// +/// Detached after checking out a tag or a commit directly. +pub fn current_branch(repo: &gix::Repository) -> Result> { + let head = repo.head()?; + let Some(name) = head.referent_name() else { + return Ok(None); + }; + Ok(Some(String::from_utf8_lossy(name.shorten()).into_owned())) +} + +/// Branch, tag and HEAD refs of a repository. +/// +/// Ready for a NIP-34 kind-30618 repository state announcement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepoRefState { + /// `(full refname, commit id)` pairs for heads and tags, sorted. + pub refs: Vec<(String, String)>, + /// Short branch name HEAD points to, or `None` when detached. + pub head: Option, +} + +/// Collect the refs of `repo`. +/// +/// Local branches and tags become `(refname, commit-id)` pairs. +/// Also reports the branch HEAD points to. +pub fn repo_ref_state(repo: &gix::Repository) -> Result { + let mut refs = Vec::new(); + + for reference in repo.references()?.local_branches()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + refs.push(( + String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), + reference.id().to_string(), + )); + } + + for reference in repo.references()?.tags()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + refs.push(( + String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), + reference.id().to_string(), + )); + } + refs.sort(); + + let head = match repo.head() { + Ok(head) => head + .referent_name() + .filter(|name| name.as_bstr().starts_with(b"refs/heads/")) + .map(|name| String::from_utf8_lossy(name.shorten()).into_owned()), + Err(_) => None, + }; + + Ok(RepoRefState { refs, head }) +} + +/// [`repo_ref_state`] for the repository at `workdir`. +pub fn worktree_ref_state(workdir: &Path) -> Result { + repo_ref_state(&gix::open(workdir)?) +} diff --git a/crates/signed_git/src/scan.rs b/crates/signed_git/src/scan.rs new file mode 100644 index 0000000..718cb3e --- /dev/null +++ b/crates/signed_git/src/scan.rs @@ -0,0 +1,43 @@ +use std::path::{Path, PathBuf}; + +use ignore::WalkBuilder; + +/// Maximum directory nesting depth when scanning for local repositories. +/// +/// Pathological trees can't stall the scan. +const SCAN_MAX_DEPTH: usize = 12; + +/// Walk `root` recursively and collect the paths of git repositories below it, +/// honouring `.gitignore` (and `.ignore`) files. +pub fn find_git_repos(root: &Path) -> Vec { + if !root.is_dir() { + return Vec::new(); + } + + let walker = WalkBuilder::new(root) + .max_depth(Some(SCAN_MAX_DEPTH)) + // Honour `.gitignore` even when the scan root is not itself a repository. + .require_git(false) + .build(); + + let mut repos: Vec = walker + .flatten() + .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_dir())) + .map(ignore::DirEntry::into_path) + .filter(|dir| dir.join(".git").exists()) + .filter_map(|dir| dir.canonicalize().ok()) + .collect(); + + repos.sort(); + repos.dedup(); + + // A repository nested inside another, like a submodule worktree, is not reported. + let mut roots: Vec = Vec::with_capacity(repos.len()); + for repo in repos { + if !roots.iter().any(|kept| repo.starts_with(kept)) { + roots.push(repo); + } + } + + roots +} diff --git a/crates/signed_git/src/tests.rs b/crates/signed_git/src/tests.rs new file mode 100644 index 0000000..c73b066 --- /dev/null +++ b/crates/signed_git/src/tests.rs @@ -0,0 +1,1792 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use nostr::prelude::*; +use signed_core::{Announcement, repo_addr}; + +use super::*; + +#[test] +fn keeps_plain_ids() { + assert_eq!(sanitize_path_component("my-repo"), "my-repo"); + assert_eq!(sanitize_path_component("repo.v2"), "repo.v2"); + assert_eq!(sanitize_path_component("a_b-c"), "a_b-c"); +} + +#[test] +fn replaces_unsafe_characters() { + assert_eq!(sanitize_path_component("a/b\\c:d"), "a_b_c_d"); + assert_eq!(sanitize_path_component(""), ""); +} + +#[test] +fn blocks_parent_components() { + assert_eq!(sanitize_path_component(".."), "_"); + assert_eq!(sanitize_path_component("."), "_"); + // Separators are neutralized before the check, so these stay safe. + assert_eq!(sanitize_path_component("../.."), ".._.."); + assert_eq!(sanitize_path_component("a/../b"), "a_.._b"); +} + +#[test] +fn fork_namespace_combines_owner_and_sanitized_id() { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::GitRepoAnnouncement, "") + .tags([Tag::parse(["d", "my/repo"]).expect("valid tag")]) + .finalize(&keys) + .expect("signed event"); + let announcement = Announcement::from_event(&event).expect("parses"); + + assert_eq!( + fork_namespace(&announcement), + format!("{}/my_repo", keys.public_key().to_hex()) + ); +} + +#[test] +fn repo_path_stays_inside_root() { + let cache = GitCache::new("/cache".into()); + let owner = Keys::generate().public_key(); + + let path = cache.repo_path(&repo_addr(owner, "..")); + assert!(path.starts_with("/cache")); + assert_eq!( + path.file_name().map(|n| n.to_string_lossy().into_owned()), + Some("_".into()) + ); +} + +#[test] +fn find_git_repos_discovers_repositories_recursively() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + + // Repositories are found at any depth. + // A linked worktree, with a `.git` file instead of a directory, counts too. + let nested = root.join("a/b/project"); + std::fs::create_dir_all(nested.join(".git")).unwrap(); + let worktree = root.join("wt"); + std::fs::create_dir_all(&worktree).unwrap(); + std::fs::write( + worktree.join(".git"), + "gitdir: ../a/b/project/.git/worktrees/wt", + ) + .unwrap(); + + // Plain directories are not repositories. + std::fs::create_dir_all(root.join("plain")).unwrap(); + + // A `.gitignore` at the root excludes dependency caches. + std::fs::write(root.join(".gitignore"), "node_modules/\n").unwrap(); + std::fs::create_dir_all(root.join("node_modules/pkg/.git")).unwrap(); + + // Hidden entries are skipped. + std::fs::create_dir_all(root.join(".hidden/repo/.git")).unwrap(); + + // A repository is not descended into. + // Repositories inside it, like submodule worktrees, are not reported. + let outer = root.join("outer"); + std::fs::create_dir_all(outer.join(".git")).unwrap(); + std::fs::create_dir_all(outer.join("sub/other/.git")).unwrap(); + + let mut found = find_git_repos(root); + found.sort(); + + let mut expected = vec![ + nested.canonicalize().unwrap(), + worktree.canonicalize().unwrap(), + outer.canonicalize().unwrap(), + ]; + expected.sort(); + assert_eq!(found, expected); +} + +#[test] +fn root_commit_reports_the_first_ancestor() { + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + + let dir = dir.path(); + let root = root_commit(dir).expect("root").expect("commit"); + assert_eq!(root.len(), 40); + + // The root commit does not change when history grows. + std::fs::write(dir.join("b.txt"), b"two").expect("write"); + commit_all(&repo, "second"); + assert_eq!( + root_commit(dir).expect("root").as_deref(), + Some(root.as_str()) + ); +} + +#[test] +fn root_commit_is_none_without_commits() { + let (_dir, repo) = fixture(&[("a.txt", b"one")]); + let workdir = repo.workdir().expect("workdir"); + assert_eq!(root_commit(workdir).expect("root"), None); +} + +#[test] +fn push_all_mirrors_branches_and_tags() { + // A bare server repository reachable via a `file://` URL. + // Mirrors a grasp server's `{base}/{owner}/{repo-id}.git` layout. + let server = tempfile::tempdir().unwrap(); + let server_repo = server.path().join("npub1test").join("my-repo.git"); + std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); + let init_status = Command::new("git") + .args(["init", "--bare", "-q"]) + .arg(&server_repo) + .status() + .expect("spawn git init --bare"); + assert!(init_status.success()); + + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + let dir = dir.path(); + + // Two branches plus a tag are all mirrored. + git_run(dir, &["checkout", "-b", "feature"]); + std::fs::write(dir.join("b.txt"), b"two").expect("write"); + commit_all(&repo, "feature work"); + git_run(dir, &["checkout", "-"]); + git_run(dir, &["tag", "v1.0"]); + + let base_url = format!("file://{}", server.path().display()); + push_all(dir, &base_url, "npub1test", "my-repo").expect("push"); + + let refs = git_in(&server_repo, &["show-ref"]).expect("server refs"); + assert!(refs.contains("refs/heads/main")); + assert!(refs.contains("refs/heads/feature")); + assert!(refs.contains("refs/tags/v1.0")); +} + +#[test] +fn push_all_tolerates_a_missing_ref_kind() { + // A repository with only tags and no branches still pushes. + // Wildcard refspecs without a local match are ignored. + let server = tempfile::tempdir().unwrap(); + let server_repo = server.path().join("npub1test").join("my-repo.git"); + std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); + let init_status = Command::new("git") + .args(["init", "--bare", "-q"]) + .arg(&server_repo) + .status() + .expect("spawn git init --bare"); + assert!(init_status.success()); + + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + let dir = dir.path(); + git_run(dir, &["tag", "v1.0"]); + git_run(dir, &["update-ref", "-d", "refs/heads/main"]); + + let base_url = format!("file://{}", server.path().display()); + push_all(dir, &base_url, "npub1test", "my-repo").expect("push"); + + let refs = git_in(&server_repo, &["show-ref"]).expect("server refs"); + assert!(refs.contains("refs/tags/v1.0")); + assert!(!refs.contains("refs/heads/")); +} + +#[test] +fn remote_has_refs_reports_whether_pushed_refs_landed() { + let server = tempfile::tempdir().unwrap(); + let server_repo = server.path().join("npub1test").join("my-repo.git"); + std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); + let init_status = Command::new("git") + .args(["init", "--bare", "-q"]) + .arg(&server_repo) + .status() + .expect("spawn git init --bare"); + assert!(init_status.success()); + + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + let dir = dir.path(); + let main = git_in(dir, &["rev-parse", "refs/heads/main"]).expect("main oid"); + let url = format!("file://{}/npub1test/my-repo.git", server.path().display()); + let expected = vec![("refs/heads/main".to_owned(), main.clone())]; + + // Nothing pushed yet: the ref is absent. + assert!(!remote_has_refs(dir, &url, &expected).expect("probe")); + + push_all( + dir, + &format!("file://{}", server.path().display()), + "npub1test", + "my-repo", + ) + .expect("push"); + + // The pushed ref is advertised at the expected commit. + assert!(remote_has_refs(dir, &url, &expected).expect("probe")); + + // A stale expectation - the exact race a retry resolves - is false. + let stale = vec![("refs/heads/main".to_owned(), "0".repeat(40))]; + assert!(!remote_has_refs(dir, &url, &stale).expect("probe")); + + // Extra remote refs (e.g. a tag pushed later) do not invalidate the + // refs this push wanted to land. + git_run(dir, &["tag", "v1.0"]); + push_all( + dir, + &format!("file://{}", server.path().display()), + "npub1test", + "my-repo", + ) + .expect("push"); + assert!(remote_has_refs(dir, &url, &expected).expect("probe")); +} + +#[test] +fn repo_ref_state_lists_branches_tags_and_head() { + let (_dir, repo) = fixture(&[("a.txt", b"hello")]); + commit_all(&repo, "initial"); + let workdir = repo.workdir().expect("workdir").to_path_buf(); + + let state = repo_ref_state(&repo).expect("refs"); + + let branch = current_branch(&repo).expect("branch").expect("on a branch"); + assert_eq!(state.head.as_deref(), Some(branch.as_str())); + assert_eq!(state.refs.len(), 1); + assert_eq!(state.refs[0].0, format!("refs/heads/{branch}")); + assert_eq!(state.refs[0].1.len(), 40); + + // Additional branches and tags are listed alongside. + git_run(&workdir, &["branch", "feature"]); + git_run(&workdir, &["tag", "v1.0"]); + + let state = repo_ref_state(&repo).expect("refs"); + let mut expected: Vec = vec![ + format!("refs/heads/{branch}"), + "refs/heads/feature".to_owned(), + "refs/tags/v1.0".to_owned(), + ]; + expected.sort(); + assert_eq!( + state + .refs + .iter() + .map(|(name, _)| name.clone()) + .collect::>(), + expected + ); + + // A detached HEAD yields no head branch. + git_run(&workdir, &["checkout", "--detach"]); + let state = repo_ref_state(&repo).expect("refs"); + assert!(state.head.is_none()); + assert_eq!(state.refs.len(), 3); +} + +/// Build a throwaway non-bare repository from `(rel, bytes)` file pairs. +fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) { + let dir = tempfile::tempdir().expect("tempdir"); + let repo = gix::init(&dir).expect("init"); + + for (rel, bytes) in files { + let path = dir.path().join(rel); + std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + std::fs::write(&path, bytes).expect("write"); + } + + (dir, repo) +} + +#[test] +fn worktree_entries_lists_all_files_and_dirs() { + let (_dir, repo) = fixture(&[ + ("README.md", b"# Hi"), + ("src/main.rs", b"fn main() {}"), + ("src/lib.rs", b""), + ("docs/guide.md", b"guide"), + ]); + + let entries = worktree_entries(&repo).expect("entries"); + let entries: Vec = entries + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + + assert_eq!( + entries, + vec![ + "docs", + "src", + "README.md", + "docs/guide.md", + "src/lib.rs", + "src/main.rs" + ] + ); +} + +#[test] +fn worktree_read_returns_bytes_or_none() { + let (_dir, repo) = fixture(&[("a.txt", b"hello"), ("sub/b.bin", b"\x00\x01")]); + + assert_eq!( + worktree_read(&repo, Path::new("a.txt")).expect("read"), + Some(b"hello".to_vec()) + ); + assert_eq!( + worktree_read(&repo, Path::new("sub/b.bin")).expect("read"), + Some(vec![0x00, 0x01]) + ); + assert_eq!( + worktree_read(&repo, Path::new("missing.txt")).expect("read"), + None + ); +} + +/// Stage everything and create a commit with the git CLI. +/// Like [`apply_patch`], the crate already shells out to the CLI. +fn commit_all(repo: &gix::Repository, message: &str) { + git_run(repo.workdir().expect("workdir"), &["add", "-A"]); + git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]); +} + +#[test] +fn merge_base_finds_the_fork_point_and_reports_unrelated_history() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("repo"); + let initial = init_repository(&path, "My Repo", "desc").expect("init"); + + // A feature branch and a mainline commit diverge from the initial commit. + // The initial commit is their merge base. + git_run(&path, &["checkout", "-b", "feature"]); + std::fs::write(path.join("feature.txt"), "feature\n").expect("write"); + commit_all(&gix::open(&path).expect("open"), "feature commit"); + git_run(&path, &["checkout", "main"]); + std::fs::write(path.join("main.txt"), "main\n").expect("write"); + commit_all(&gix::open(&path).expect("open"), "mainline commit"); + + assert_eq!( + merge_base(&path, "feature", "main") + .expect("merge base") + .as_deref(), + Some(initial.as_str()) + ); + + // An orphan branch shares no history with main, so `Ok(None)`. + git_run(&path, &["checkout", "--orphan", "orphan"]); + std::fs::write(path.join("orphan.txt"), "orphan\n").expect("write"); + commit_all(&gix::open(&path).expect("open"), "orphan commit"); + assert_eq!(merge_base(&path, "orphan", "main").expect("ok"), None); + + // An unresolvable revision is an error, not a missing ancestor. + assert!(merge_base(&path, "orphan", "no-such-ref").is_err()); +} + +#[test] +fn format_patch_between_produces_the_series_and_rejects_empty_ranges() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("repo"); + let initial = init_repository(&path, "My Repo", "desc").expect("init"); + + git_run(&path, &["checkout", "-b", "feature"]); + std::fs::write(path.join("feature.txt"), "feature\n").expect("write"); + commit_all(&gix::open(&path).expect("open"), "feature commit"); + + let patch = format_patch_between(&path, &initial, "feature").expect("patch"); + assert!(patch.contains("Subject: [PATCH] feature commit")); + assert!(patch.contains("feature.txt")); + + // An empty range has no commits to send. + assert!(format_patch_between(&path, "feature", "feature").is_err()); +} + +#[test] +fn push_commit_ref_pushes_to_the_event_namespace() { + // A bare server repository reachable via a `file://` URL. + // Mirrors a grasp server's `{base}/{owner}/{repo-id}.git` layout. + let server = tempfile::tempdir().unwrap(); + let server_repo = server.path().join("npub1test").join("my-repo.git"); + std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); + let init_status = Command::new("git") + .args(["init", "--bare", "-q"]) + .arg(&server_repo) + .status() + .expect("spawn git init --bare"); + assert!(init_status.success()); + + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + let dir = dir.path(); + let tip = git_in(dir, &["rev-parse", "HEAD"]).expect("tip"); + + let url = format!("file://{}/npub1test/my-repo.git", server.path().display()); + push_commit_ref(dir, &url, &tip, "refs/nostr/abcd1234").expect("push"); + + let refs = git_in(&server_repo, &["show-ref"]).expect("server refs"); + assert!(refs.contains("refs/nostr/abcd1234")); +} + +#[test] +fn split_patch_series_splits_real_multi_commit_mboxes() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("repo"); + let initial = init_repository(&path, "My Repo", "desc").expect("init"); + + git_run(&path, &["checkout", "-b", "feature"]); + std::fs::write(path.join("one.txt"), "one\n").expect("write"); + commit_all(&gix::open(&path).expect("open"), "first commit"); + std::fs::write(path.join("two.txt"), "two\n").expect("write"); + commit_all(&gix::open(&path).expect("open"), "second commit"); + + let series = format_patch_between(&path, &initial, "feature").expect("series"); + let parts = split_patch_series(&series); + + assert_eq!(parts.len(), 2); + assert!(parts[0].contains("Subject: [PATCH 1/2] first commit")); + assert!(parts[1].contains("Subject: [PATCH 2/2] second commit")); + // Each part starts its own mbox message with its own commit id. + let first = parts[0].lines().next().expect("first header"); + let second = parts[1].lines().next().expect("second header"); + assert!(first.starts_with("From ") && first.len() >= 45); + assert_ne!(first, second); +} + +#[test] +fn split_patch_series_keeps_single_patches_whole() { + let patch = "From abcdefabcdefabcdefabcdefabcdefabcdefab Mon Sep 17 00:00:00 2001\nFrom: A \nSubject: [PATCH] fix\n\n---\n"; + let parts = split_patch_series(patch); + assert_eq!(parts.len(), 1); + assert_eq!(parts[0], patch); +} + +#[test] +fn head_commit_and_commits_since_track_applied_commits() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("repo"); + let initial = init_repository(&path, "My Repo", "desc").expect("init"); + + assert_eq!( + head_commit_id(&path).expect("head").as_deref(), + Some(initial.as_str()) + ); + // No commits yet, `HEAD` alone. + assert_eq!( + commits_since(&path, None).expect("commits"), + vec![initial.clone()] + ); + + std::fs::write(path.join("one.txt"), "one\n").expect("write"); + commit_all(&gix::open(&path).expect("open"), "first commit"); + let first = head_commit_id(&path).expect("head").expect("on a branch"); + + std::fs::write(path.join("two.txt"), "two\n").expect("write"); + commit_all(&gix::open(&path).expect("open"), "second commit"); + let second = head_commit_id(&path).expect("head").expect("on a branch"); + + // Oldest first, like the order `git am` creates them. + assert_eq!( + commits_since(&path, Some(&initial)).expect("commits"), + vec![first.clone(), second.clone()] + ); + assert_eq!( + commits_since(&path, Some(&first)).expect("commits"), + vec![second] + ); +} + +#[test] +fn head_commit_reports_unborn_repositories() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("repo"); + let status = Command::new("git") + .args(["init", "-q"]) + .arg(&path) + .status() + .expect("spawn git init"); + assert!(status.success()); + + assert_eq!(head_commit_id(&path).expect("head"), None); + assert_eq!( + commits_since(&path, None).expect("commits"), + Vec::::new() + ); +} + +#[test] +fn init_repository_creates_main_branch_and_readme() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("my-repo"); + + let commit = init_repository(&path, "My Repo", "Does things.\n\nCool.").expect("init"); + assert_eq!(commit.len(), 40); + + let repo = gix::open(&path).expect("open"); + let workdir = repo.workdir().expect("workdir"); + + assert_eq!( + std::fs::read_to_string(workdir.join("README.md")).expect("read"), + "# My Repo\n\nDoes things.\n\nCool.\n" + ); + + let branch = current_branch(&repo).expect("branch").expect("on a branch"); + assert_eq!(branch, "main"); + // [`FileCommit`] carries the short id, the full id is 40 chars. + assert_eq!( + head_commit(&repo).expect("head").expect("commit").id, + &commit[..7] + ); + + let state = repo_ref_state(&repo).expect("refs"); + assert_eq!(state.head.as_deref(), Some("main")); + assert_eq!(state.refs, vec![("refs/heads/main".to_owned(), commit)]); + + // The index matches the committed tree, so the fresh repo is clean. + assert!(!worktree_dirty(workdir)); +} + +#[test] +fn init_repository_omits_description_when_empty() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("my-repo"); + + init_repository(&path, "My Repo", " ").expect("init"); + let repo = gix::open(&path).expect("open"); + let workdir = repo.workdir().expect("workdir"); + + assert_eq!( + std::fs::read_to_string(workdir.join("README.md")).expect("read"), + "# My Repo\n" + ); +} + +#[test] +fn ensure_origin_adds_remote_only_once() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("my-repo"); + init_repository(&path, "My Repo", "").expect("init"); + + ensure_origin(&path, "https://gitnostr.com/npub1test/repo.git").expect("add"); + assert_eq!( + git_in(&path, &["remote", "get-url", "origin"]).expect("url"), + "https://gitnostr.com/npub1test/repo.git" + ); + // The standard fetch mapping is configured with the remote. + // Later `git fetch origin` updates `refs/remotes/origin/*`. + assert_eq!( + git_in(&path, &["config", "remote.origin.fetch"]).expect("refspec"), + "+refs/heads/*:refs/remotes/origin/*" + ); + + // A second call must not override the existing remote. + ensure_origin(&path, "https://other.example/repo.git").expect("keep"); + assert_eq!( + git_in(&path, &["remote", "get-url", "origin"]).expect("url"), + "https://gitnostr.com/npub1test/repo.git" + ); +} + +#[test] +fn origin_url_reads_the_remote_or_reports_none() { + let (dir, _repo) = fixture(&[("a.txt", b"one")]); + commit_all(&_repo, "initial"); + let dir = dir.path(); + + // No remote configured yet. + assert_eq!(origin_url(dir).expect("read"), None); + + ensure_origin(dir, "https://gitnostr.com/npub1test/repo.git").expect("add"); + assert_eq!( + origin_url(dir).expect("read").as_deref(), + Some("https://gitnostr.com/npub1test/repo.git") + ); +} + +#[test] +fn set_origin_creates_or_replaces_the_remote() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("my-repo"); + init_repository(&path, "My Repo", "").expect("init"); + + // No origin yet, so one is added. + set_origin(&path, "https://gitnostr.com/npub1test/repo.git").expect("add"); + assert_eq!( + origin_url(&path).expect("url").as_deref(), + Some("https://gitnostr.com/npub1test/repo.git") + ); + + // An existing origin is replaced, not duplicated. + // A clone's origin points at the cloned-from path. + // It is re-targeted at the grasp server. + set_origin(&path, "https://grasp.example/npub1test/repo.git").expect("replace"); + assert_eq!( + origin_url(&path).expect("url").as_deref(), + Some("https://grasp.example/npub1test/repo.git") + ); +} + +#[test] +fn working_copy_cloned_from_the_mirror_matches_head_and_origin() { + // The mirror is a freshly initialized repository, standing in for + // the grasp server. Its `origin` points at the (fake) grasp server. + // `GitCache::ensure_clone` lazily clones from a URL shaped like this + // the first time a repository is opened. + let dir = tempfile::tempdir().expect("tempdir"); + let mirror = dir.path().join("mirror"); + let commit = init_repository(&mirror, "My Repo", "Does things.").expect("init"); + ensure_origin(&mirror, "https://gitnostr.com/npub1test/my-repo.git").expect("origin"); + + // The working copy is cloned from the mirror. + // It then shares the announced history exactly. + // `origin` is re-pointed at the grasp server instead of the mirror path. + let destination = dir.path().join("folder").join("My_Repo"); + std::fs::create_dir_all(destination.parent().unwrap()).expect("parent"); + clone_repo(&[format!("file://{}", mirror.display())], &destination).expect("clone"); + set_origin(&destination, "https://gitnostr.com/npub1test/my-repo.git").expect("set origin"); + + assert_eq!( + origin_url(&destination).expect("url").as_deref(), + Some("https://gitnostr.com/npub1test/my-repo.git") + ); + assert_eq!( + head_commit_id(&destination).expect("head").as_deref(), + Some(commit.as_str()) + ); + assert!(destination.join("README.md").is_file()); +} + +#[test] +fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { + // A bare server, like a grasp server's `{base}/{owner}/{repo}.git` layout. + let dir = tempfile::tempdir().expect("tempdir"); + let base_server = dir.path().join("npub1test").join("repo.git"); + std::fs::create_dir_all(base_server.parent().unwrap()).unwrap(); + let init_status = Command::new("git") + .args(["init", "--bare", "-q"]) + .arg(&base_server) + .status() + .expect("spawn git init --bare"); + assert!(init_status.success()); + + // The owner's working repo pushes the initial commit. + let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]); + commit_all(&work_repo, "initial"); + let work = work_dir.path(); + let base_url = format!("file://{}", dir.path().display()); + push_all(work, &base_url, "npub1test", "repo").expect("push"); + + // A mirror clone, like the app's GitCache clones. + let mirror = dir.path().join("mirror"); + git_run( + dir.path(), + &[ + "clone", + "-q", + &format!("{base_url}/npub1test/repo.git"), + mirror.to_str().unwrap(), + ], + ); + let initial = git_in(&mirror, &["rev-parse", "HEAD"]).expect("initial"); + + // The owner pushes a new commit. + // The mirror fetches it, but its local `main` and worktree stay behind. + std::fs::write(work.join("new.txt"), b"new\n").expect("write"); + commit_all(&gix::open(work).expect("open"), "new commit"); + push_all(work, &base_url, "npub1test", "repo").expect("push"); + git_run(&mirror, &["fetch", "origin"]); + let remote = git_in(&mirror, &["rev-parse", "refs/remotes/origin/main"]).expect("remote"); + assert_eq!( + git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"), + initial + ); + assert_ne!(remote, initial); + + // Fast-forwarding catches the branch and its worktree up. + // The second call has nothing left to move. + assert!(fast_forward_branches(&mirror).expect("ff")); + assert_eq!( + git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"), + remote + ); + assert!(mirror.join("new.txt").is_file()); + assert!(!fast_forward_branches(&mirror).expect("idle")); + + // A branch with local commits of its own is never touched. + git_run(&mirror, &["checkout", "-b", "wip"]); + std::fs::write(mirror.join("wip.txt"), b"wip\n").expect("write"); + commit_all(&gix::open(&mirror).expect("open"), "local wip"); + let wip = git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip"); + assert!(!fast_forward_branches(&mirror).expect("wip skipped")); + assert_eq!( + git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip kept"), + wip + ); +} + +#[test] +fn fetch_repo_refs_imports_heads_under_a_prefix() { + let dir = tempfile::tempdir().expect("tempdir"); + + // A bare base server holding the initial commit. + // Like a grasp server's `{base}/{owner}/{repo-id}.git` layout. + let base_server = dir.path().join("npub1base").join("base.git"); + std::fs::create_dir_all(base_server.parent().unwrap()).unwrap(); + let init_status = Command::new("git") + .args(["init", "--bare", "-q"]) + .arg(&base_server) + .status() + .expect("spawn git init --bare"); + assert!(init_status.success()); + + let (upstream_dir, upstream_repo) = fixture(&[("a.txt", b"one")]); + commit_all(&upstream_repo, "initial"); + let upstream_path = upstream_dir.path(); + let initial = git_in(upstream_path, &["rev-parse", "HEAD"]).expect("initial"); + push_all( + upstream_path, + &format!("file://{}", dir.path().display()), + "npub1base", + "base", + ) + .expect("push"); + + // The base mirror is a plain clone of the base server. + let base_url = format!("file://{}", base_server.display()); + let mirror = dir.path().join("mirror"); + git_run( + dir.path(), + &["clone", "-q", &base_url, mirror.to_str().unwrap()], + ); + + // The fork server has the same initial commit. + // It also carries a feature commit on its own `feature` branch. + let fork_work = dir.path().join("fork-work"); + git_run( + dir.path(), + &["clone", "-q", &base_url, fork_work.to_str().unwrap()], + ); + git_run(&fork_work, &["checkout", "-b", "feature"]); + std::fs::write(fork_work.join("feature.txt"), "feature\n").expect("write"); + commit_all(&gix::open(&fork_work).expect("open"), "feature commit"); + let tip = git_in(&fork_work, &["rev-parse", "HEAD"]).expect("tip"); + + let fork_server = dir.path().join("npub1fork").join("fork.git"); + std::fs::create_dir_all(fork_server.parent().unwrap()).unwrap(); + let init_status = Command::new("git") + .args(["init", "--bare", "-q"]) + .arg(&fork_server) + .status() + .expect("spawn git init --bare"); + assert!(init_status.success()); + push_commit_ref( + &fork_work, + &format!("file://{}", fork_server.display()), + &tip, + "refs/heads/feature", + ) + .expect("push"); + + // Import the fork's heads into the mirror under a private prefix. + // The first dead URL is skipped, the second works. + let dead = format!("file://{}/missing.git", dir.path().display()); + fetch_repo_refs( + &mirror, + &[dead, format!("file://{}", fork_server.display())], + "+refs/heads/*:refs/fork/npub1fork/fork/*", + ) + .expect("fetch"); + + // The imported refs are listed under the prefix only. + assert_eq!( + refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"), + vec!["refs/fork/npub1fork/fork/feature"] + ); + // Nothing leaked into the normal ref namespaces. + assert_eq!( + refs_with_prefix(&mirror, "refs/heads/fork").expect("refs"), + Vec::::new() + ); + + // The mirror can now range across both histories. + // The fork point is the shared initial commit, the proposal covers the fork commit. + assert_eq!( + merge_base( + &mirror, + "refs/remotes/origin/main", + "refs/fork/npub1fork/fork/feature", + ) + .expect("merge base") + .as_deref(), + Some(initial.as_str()) + ); + let patch = + format_patch_between(&mirror, &initial, "refs/fork/npub1fork/fork/feature").expect("patch"); + assert!(patch.contains("Subject: [PATCH] feature commit")); + assert!(patch.contains("feature.txt")); + + // Pruning the prefix removes the import again. + delete_refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("delete"); + assert_eq!( + refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"), + Vec::::new() + ); +} + +#[test] +fn fetch_repo_refs_fails_when_every_url_fails() { + let (_dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + let dir = _dir.path(); + + let dead = format!("file://{}/missing.git", dir.display()); + let err = + fetch_repo_refs(dir, &[dead], "+refs/heads/*:refs/fork/x/*").expect_err("all URLs fail"); + assert!(err.to_string().contains("failed to fetch")); + + // Without any URL there is nothing to try. + let err = + fetch_repo_refs(dir, &[] as &[String], "+refs/heads/*:refs/fork/x/*").expect_err("no URLs"); + assert!(err.to_string().contains("no clone URLs")); +} + +#[test] +fn delete_refs_with_prefix_is_a_noop_without_matches() { + let (_dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + delete_refs_with_prefix(_dir.path(), "refs/fork/nothing").expect("noop"); +} + +/// Run a git command in `dir`, asserting success. +fn git_run(dir: &Path, args: &[&str]) { + let status = Command::new("git") + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "Test Author") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test Author") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .env("GIT_EDITOR", "true") + .args(args) + .status() + .expect("spawn git"); + assert!(status.success(), "git {args:?} failed"); +} + +#[test] +fn last_commit_returns_most_recent_change() { + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + + std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); + commit_all(&repo, "change a"); + + // A commit touching another file must not be reported for a.txt. + std::fs::write(dir.path().join("b.txt"), b"other").expect("write"); + commit_all(&repo, "add b"); + + let commit = last_commit(&repo, Path::new("a.txt")) + .expect("lookup") + .expect("found"); + assert_eq!(commit.summary, "change a"); + assert_eq!(commit.author, "Test Author"); + assert!(!commit.id.is_empty()); + assert!(commit.time > 0); +} + +#[test] +fn all_commits_lists_every_commit() { + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + + std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); + commit_all(&repo, "second"); + std::fs::write(dir.path().join("b.txt"), b"b").expect("write"); + commit_all(&repo, "third"); + + let list = all_commits(&repo).expect("commits"); + assert_eq!(list.total, 3); + let mut summaries: Vec<&str> = list.commits.iter().map(|c| c.summary.as_str()).collect(); + summaries.sort(); + assert_eq!(summaries, vec!["initial", "second", "third"]); + assert!( + list.commits + .iter() + .all(|c| c.author == "Test Author" && !c.id.is_empty() && c.time > 0) + ); +} + +#[test] +fn all_commits_returns_empty_without_head() { + let (_dir, repo) = fixture(&[("a.txt", b"one")]); + + let list = all_commits(&repo).expect("commits"); + assert!(list.commits.is_empty()); + assert_eq!(list.total, 0); +} + +#[test] +fn last_commit_returns_none_for_untracked_files() { + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + std::fs::write(dir.path().join("untracked.txt"), b"x").expect("write"); + + let commit = last_commit(&repo, Path::new("untracked.txt")).expect("lookup"); + assert!(commit.is_none()); +} + +#[test] +fn last_commit_reports_merge_commits() { + let (dir, repo) = fixture(&[("a.txt", b"base")]); + commit_all(&repo, "initial"); + + let run = |args: &[&str]| { + let status = Command::new("git") + .current_dir(dir.path()) + .env("GIT_AUTHOR_NAME", "Test Author") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test Author") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .env("GIT_EDITOR", "true") + .args(args) + .status() + .expect("spawn git"); + assert!(status.success(), "git {args:?} failed"); + }; + run(&["checkout", "-b", "feature"]); + std::fs::write(dir.path().join("a.txt"), b"feature").expect("write"); + commit_all(&repo, "feature change"); + run(&["checkout", "-"]); + // `--no-ff` forces a merge commit, it is the latest commit changing a.txt. + run(&["merge", "--no-ff", "--no-edit", "feature"]); + + let commit = last_commit(&repo, Path::new("a.txt")) + .expect("lookup") + .expect("found"); + assert_eq!( + commit.id, + repo.head_id().expect("head").shorten_or_id().to_string() + ); + assert!(commit.summary.starts_with("Merge branch")); +} + +#[test] +fn last_commits_batches_multiple_paths() { + let (dir, repo) = fixture(&[("a.txt", b"one"), ("b.txt", b"b")]); + commit_all(&repo, "initial"); + + std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); + commit_all(&repo, "change a"); + std::fs::write(dir.path().join("b.txt"), b"bb").expect("write"); + commit_all(&repo, "change b"); + + let found = worktree_last_commits( + dir.path(), + &[ + PathBuf::from("a.txt"), + PathBuf::from("b.txt"), + // Untracked paths are absent from the result. + PathBuf::from("missing.txt"), + ], + ) + .expect("commits"); + let by_path: HashMap<&Path, &FileCommit> = found + .iter() + .map(|(path, commit)| (path.as_path(), commit)) + .collect(); + assert_eq!(by_path.len(), 2); + assert_eq!(by_path[Path::new("a.txt")].summary, "change a"); + assert_eq!(by_path[Path::new("b.txt")].summary, "change b"); +} + +#[test] +fn find_readme_prefers_markdown() { + let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]); + + let readme = find_readme(&repo).expect("find"); + assert_eq!( + readme.map(|p| p.to_string_lossy().into_owned()), + Some("README.md".into()) + ); +} + +#[test] +fn find_readme_falls_back_to_any_readme() { + let (_dir, repo) = fixture(&[("README.rst", b"rst")]); + + let readme = find_readme(&repo).expect("find"); + assert_eq!( + readme.map(|p| p.to_string_lossy().into_owned()), + Some("README.rst".into()) + ); +} + +#[test] +fn find_readme_returns_none_without_one() { + let (_dir, repo) = fixture(&[("main.rs", b"")]); + assert!(find_readme(&repo).expect("find").is_none()); +} + +#[test] +fn head_commit_reports_head() { + let (_dir, repo) = fixture(&[("a.txt", b"one")]); + + // Unborn HEAD means no commit yet. + assert!(head_commit(&repo).expect("head").is_none()); + + commit_all(&repo, "initial"); + let head = head_commit(&repo).expect("head").expect("commit"); + assert_eq!( + head.id, + repo.head_id().expect("head id").shorten_or_id().to_string() + ); + assert_eq!(head.summary, "initial"); + assert_eq!(head.author, "Test Author"); +} + +#[test] +fn worktree_branches_and_tags_list_short_names() { + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + let dir = dir.path(); + + git_run(dir, &["checkout", "-b", "feature"]); + git_run(dir, &["tag", "v0.9"]); + git_run(dir, &["tag", "v1.0"]); + + // The initial branch name depends on git configuration. + // Only the branch we created is fixed. + let branches = worktree_branches(dir).expect("branches"); + assert_eq!(branches.len(), 2); + assert!(branches.contains(&"feature".to_string())); + assert!(branches.windows(2).all(|pair| pair[0] <= pair[1]), "sorted"); + + assert_eq!( + repo_tags(&repo).expect("tags"), + vec!["v0.9".to_string(), "v1.0".to_string()] + ); +} + +#[test] +fn current_branch_tracks_checkout() { + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + let dir = dir.path(); + + let default = worktree_branches(dir) + .expect("branches") + .into_iter() + .next() + .expect("default branch"); + assert_eq!( + current_branch(&repo).expect("branch").as_deref(), + Some(default.as_str()) + ); + + git_run(dir, &["checkout", "-b", "feature"]); + assert_eq!( + current_branch(&repo).expect("branch").as_deref(), + Some("feature") + ); + + // Tags detach HEAD. + git_run(dir, &["tag", "v1.0"]); + worktree_checkout_tag(dir, "v1.0").expect("checkout tag"); + assert_eq!(current_branch(&repo).expect("branch"), None); + + // Branches re-attach HEAD. + worktree_checkout_branch(dir, &default).expect("checkout branch"); + assert_eq!( + current_branch(&repo).expect("branch").as_deref(), + Some(default.as_str()) + ); +} + +#[test] +fn worktree_snapshot_reflects_checked_out_ref() { + let (dir, repo) = fixture(&[("README.md", b"# main"), ("a.txt", b"one")]); + commit_all(&repo, "initial"); + let dir = dir.path(); + + git_run(dir, &["checkout", "-b", "feature"]); + std::fs::write(dir.join("README.md"), b"# feature").expect("write"); + std::fs::write(dir.join("b.txt"), b"b").expect("write"); + commit_all(&repo, "feature work"); + + let snapshot = worktree_snapshot(dir).expect("snapshot"); + assert_eq!(snapshot.current_branch.as_deref(), Some("feature")); + assert_eq!( + snapshot.head_commit.as_ref().expect("head commit").summary, + "feature work" + ); + assert_eq!( + String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"), + "# feature" + ); + let entries: Vec = snapshot + .entries + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + assert!(entries.contains(&"b.txt".to_string())); + + let default = worktree_branches(dir) + .expect("branches") + .into_iter() + .find(|name| name != "feature") + .expect("default branch"); + worktree_checkout_branch(dir, &default).expect("checkout"); + + let snapshot = worktree_snapshot(dir).expect("snapshot"); + assert_eq!(snapshot.current_branch.as_deref(), Some(default.as_str())); + assert_eq!( + snapshot.head_commit.as_ref().expect("head commit").summary, + "initial" + ); + assert_eq!( + String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"), + "# main" + ); + assert!( + !snapshot + .entries + .iter() + .any(|p| p.to_string_lossy() == "b.txt") + ); +} + +#[test] +fn commit_diff_lists_added_modified_and_deleted_files() { + let (dir, repo) = fixture(&[("keep.txt", b"keep"), ("mod.txt", b"one\ntwo\nthree\n")]); + commit_all(&repo, "initial"); + + std::fs::write(dir.path().join("mod.txt"), b"one\ntwo!\nthree\n").expect("write"); + std::fs::write(dir.path().join("new.txt"), b"hello\n").expect("write"); + std::fs::remove_file(dir.path().join("keep.txt")).expect("remove"); + commit_all(&repo, "changes"); + + let head = repo.head_id().expect("head").shorten_or_id().to_string(); + let diff = worktree_commit_diff(dir.path(), &head).expect("diff"); + + let by_path: HashMap<&str, &FileDiff> = diff + .files + .iter() + .map(|file| (file.path.as_str(), file)) + .collect(); + assert_eq!(by_path.len(), 3); + + let added = by_path["new.txt"]; + assert_eq!(added.status, DiffStatus::Added); + assert_eq!(added.insertions, 1); + assert_eq!(added.deletions, 0); + assert_eq!(added.hunks.len(), 1); + assert_eq!(added.hunks[0].lines.len(), 1); + assert_eq!(added.hunks[0].lines[0].kind, DiffLineKind::Addition); + assert_eq!(added.hunks[0].lines[0].old, None); + assert_eq!(added.hunks[0].lines[0].new, Some(1)); + assert_eq!(added.hunks[0].lines[0].text, "hello"); + + let modified = by_path["mod.txt"]; + assert_eq!(modified.status, DiffStatus::Modified); + assert_eq!(modified.insertions, 1); + assert_eq!(modified.deletions, 1); + assert!(!modified.binary); + let lines = &modified.hunks[0].lines; + // One hunk with context around the single-line change. + // The removed line is old 2, the added line is new 2. + assert!(lines.iter().any(|line| { + line.kind == DiffLineKind::Deletion + && line.old == Some(2) + && line.new.is_none() + && line.text == "two" + })); + assert!(lines.iter().any(|line| { + line.kind == DiffLineKind::Addition + && line.old.is_none() + && line.new == Some(2) + && line.text == "two!" + })); + assert!(lines.iter().any(|line| { + line.kind == DiffLineKind::Context && line.old == Some(1) && line.new == Some(1) + })); + + let deleted = by_path["keep.txt"]; + assert_eq!(deleted.status, DiffStatus::Deleted); + assert_eq!(deleted.deletions, 1); + assert_eq!(deleted.hunks[0].lines[0].kind, DiffLineKind::Deletion); + assert_eq!(deleted.hunks[0].lines[0].old, Some(1)); + assert_eq!(deleted.hunks[0].lines[0].new, None); +} + +#[test] +fn commit_range_diff_lists_changes_between_two_commits() { + let (dir, repo) = fixture(&[("a.txt", b"a\n"), ("b.txt", b"b\n")]); + commit_all(&repo, "first"); + let base = repo.head_id().expect("head").to_string(); + + std::fs::write(dir.path().join("a.txt"), b"changed\n").expect("write"); + std::fs::write(dir.path().join("c.txt"), b"new\n").expect("write"); + commit_all(&repo, "second"); + let tip = repo.head_id().expect("head").to_string(); + + let diff = worktree_commit_range_diff(dir.path(), &base, &tip).expect("diff"); + + let by_path: HashMap<&str, &FileDiff> = diff + .files + .iter() + .map(|file| (file.path.as_str(), file)) + .collect(); + assert_eq!(by_path.len(), 2); + assert_eq!(by_path["a.txt"].status, DiffStatus::Modified); + assert_eq!(by_path["a.txt"].insertions, 1); + assert_eq!(by_path["a.txt"].deletions, 1); + assert_eq!(by_path["c.txt"].status, DiffStatus::Added); + // b.txt is unchanged between the two commits. + assert!(diff.files.iter().all(|file| file.path != "b.txt")); +} + +#[test] +fn commit_range_commits_lists_only_new_commits_newest_first() { + let (dir, repo) = fixture(&[("a.txt", b"one\n")]); + commit_all(&repo, "one"); + let base = repo.head_id().expect("head").to_string(); + + std::fs::write(dir.path().join("a.txt"), b"two\n").expect("write"); + commit_all(&repo, "two"); + std::fs::write(dir.path().join("a.txt"), b"three\n").expect("write"); + commit_all(&repo, "three"); + let tip = repo.head_id().expect("head").to_string(); + + let commits = worktree_commit_range_commits(dir.path(), &base, &tip).expect("commits"); + + assert_eq!(commits.len(), 2); + assert_eq!(commits[0].summary, "three"); + assert_eq!(commits[1].summary, "two"); +} + +#[test] +fn commit_diff_reports_binary_files_without_hunks() { + let (_dir, repo) = fixture(&[("blob.bin", b"\x00\x01\x02")]); + commit_all(&repo, "initial"); + + std::fs::write(_dir.path().join("blob.bin"), b"\x00\x03").expect("write"); + commit_all(&repo, "binary change"); + + let head = repo.head_id().expect("head").shorten_or_id().to_string(); + let diff = worktree_commit_diff(_dir.path(), &head).expect("diff"); + let file = diff + .files + .iter() + .find(|f| f.path == "blob.bin") + .expect("file"); + assert!(file.binary); + assert!(file.hunks.is_empty()); + assert_eq!(file.insertions, 0); + assert_eq!(file.deletions, 0); +} + +#[test] +fn commit_diff_resolves_short_ids_and_root_commit() { + let (dir, repo) = fixture(&[("a.txt", b"one\n")]); + commit_all(&repo, "initial"); + + // The root commit diffs against the empty tree, everything is added. + let head = repo.head_id().expect("head").shorten_or_id().to_string(); + let diff = worktree_commit_diff(dir.path(), &head).expect("diff"); + assert_eq!(diff.files.len(), 1); + assert_eq!(diff.files[0].path, "a.txt"); + assert_eq!(diff.files[0].status, DiffStatus::Added); + assert_eq!(diff.files[0].insertions, 1); +} + +#[test] +fn file_commit_includes_message_body() { + let (_dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "title"); + + // A single-line message has no body. + let head = head_commit(&repo).expect("head").expect("commit"); + assert_eq!(head.summary, "title"); + assert_eq!(head.description, None); + + // A message with a body exposes it, trimmed. + let dir = _dir.path(); + let status = Command::new("git") + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "Test Author") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test Author") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .env("GIT_EDITOR", "true") + .args([ + "commit", + "--allow-empty", + "-m", + "title two", + "-m", + "line one\n\nline two", + ]) + .status() + .expect("spawn git"); + assert!(status.success(), "git commit failed"); + + let head = head_commit(&repo).expect("head").expect("commit"); + assert_eq!(head.summary, "title two"); + assert_eq!(head.description.as_deref(), Some("line one\n\nline two")); +} + +#[test] +fn commit_diff_reports_renames() { + let (_dir, repo) = fixture(&[("old.txt", b"same content\n")]); + commit_all(&repo, "initial"); + + std::fs::rename(_dir.path().join("old.txt"), _dir.path().join("new.txt")).expect("rename"); + commit_all(&repo, "rename"); + + let head = repo.head_id().expect("head").shorten_or_id().to_string(); + let diff = worktree_commit_diff(_dir.path(), &head).expect("diff"); + let file = diff + .files + .iter() + .find(|f| f.path == "new.txt") + .expect("file"); + assert_eq!(file.status, DiffStatus::Renamed); + assert_eq!(file.old_path.as_deref(), Some("old.txt")); + // A pure rename has no content change, the file is still listed. + assert!(file.hunks.is_empty()); + assert_eq!(file.insertions, 0); + assert_eq!(file.deletions, 0); +} + +#[test] +fn parses_format_patch_output() { + let patch = r#"From 1f6c0c5f3f1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a Mon Sep 17 00:00:00 2001 +From: A +Subject: [PATCH] fix + +fix the thing + +--- + src/lib.rs | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/src/lib.rs b/src/lib.rs +index 1234567..89abcde 100644 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1,3 +1,3 @@ + fn main() { +- println!("old"); ++ println!("new"); + } +"#; + let diff = patch_diffs(patch).expect("parse"); + + assert_eq!(diff.files.len(), 1); + let file = &diff.files[0]; + assert_eq!(file.path, "src/lib.rs"); + assert_eq!(file.old_path, None); + assert_eq!(file.status, DiffStatus::Modified); + assert_eq!(file.insertions, 1); + assert_eq!(file.deletions, 1); + + let hunk = &file.hunks[0]; + assert_eq!(hunk.old_start, 1); + assert_eq!(hunk.old_lines, 3); + assert_eq!(hunk.new_start, 1); + assert_eq!(hunk.new_lines, 3); + assert_eq!(hunk.lines.len(), 4); + assert_eq!(hunk.lines[0].kind, DiffLineKind::Context); + assert_eq!(hunk.lines[0].old, Some(1)); + assert_eq!(hunk.lines[0].new, Some(1)); + assert_eq!(hunk.lines[1].kind, DiffLineKind::Deletion); + assert_eq!(hunk.lines[1].old, Some(2)); + assert_eq!(hunk.lines[1].new, None); + assert_eq!(hunk.lines[2].kind, DiffLineKind::Addition); + assert_eq!(hunk.lines[2].old, None); + assert_eq!(hunk.lines[2].new, Some(2)); + assert_eq!(hunk.lines[3].kind, DiffLineKind::Context); + assert_eq!(hunk.lines[3].old, Some(3)); + assert_eq!(hunk.lines[3].new, Some(3)); +} + +#[test] +fn parses_new_file_as_added() { + let patch = r#"diff --git a/README.md b/README.md +new file mode 100644 +index 0000000..1234567 +--- /dev/null ++++ b/README.md +@@ -0,0 +1 @@ ++# hello +"#; + let diff = patch_diffs(patch).expect("parse"); + + let file = &diff.files[0]; + assert_eq!(file.path, "README.md"); + assert_eq!(file.status, DiffStatus::Added); + assert_eq!(file.old_path, None); + assert_eq!(file.insertions, 1); + assert_eq!(file.deletions, 0); + assert_eq!(file.hunks[0].old_start, 0); + assert_eq!(file.hunks[0].old_lines, 0); + assert_eq!(file.hunks[0].new_start, 1); +} + +#[test] +fn parses_renames_with_old_path() { + let patch = r#"diff --git a/old.rs b/new.rs +similarity index 85% +rename from old.rs +rename to new.rs +index 123..456 100644 +--- a/old.rs ++++ b/new.rs +@@ -1 +1 @@ +-fn main() {} ++fn main() { println!("hi"); } +"#; + let diff = patch_diffs(patch).expect("parse"); + + let file = &diff.files[0]; + assert_eq!(file.path, "new.rs"); + assert_eq!(file.old_path.as_deref(), Some("old.rs")); + assert_eq!(file.status, DiffStatus::Renamed); + assert_eq!(file.insertions, 1); + assert_eq!(file.deletions, 1); +} + +#[test] +fn parses_patch_series_and_skips_envelope() { + let patch = r#"From aaaa Mon Sep 17 00:00:00 2001 +From: A +Subject: [PATCH 1/2] one + +--- + a.txt | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/a.txt b/a.txt +index 1..2 100644 +--- a/a.txt ++++ b/a.txt +@@ -1 +1,2 @@ + a ++b + +From bbbb Mon Sep 17 00:00:00 2001 +From: A +Subject: [PATCH 2/2] two + +diff --git a/b.txt b/b.txt +index 3..4 100644 +--- a/b.txt ++++ b/b.txt +@@ -1 +1 @@ +-x ++y +"#; + let diff = patch_diffs(patch).expect("parse"); + + assert_eq!(diff.files.len(), 2); + assert_eq!(diff.files[0].path, "a.txt"); + assert_eq!(diff.files[0].insertions, 1); + assert_eq!(diff.files[1].path, "b.txt"); + assert_eq!(diff.files[1].deletions, 1); +} + +#[test] +fn patch_commits_lists_every_patch_in_order() { + let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001 +From: Alice +Date: Tue, 1 Aug 2023 10:00:00 +0200 +Subject: [PATCH 1/2] first + +body one +--- + a.txt | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/a.txt b/a.txt +@@ -1 +1,2 @@ + a ++b + +From 2222222222222222222222222222222222222222 Mon Sep 17 00:00:00 2001 +From: Bob +Date: Wed, 2 Aug 2023 11:30:00 +0000 +Subject: [PATCH 2/2] second + +body two +--- + b.txt | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/b.txt b/b.txt +@@ -1 +1,2 @@ + x ++y +"#; + + let commits = patch_commits(patch); + assert_eq!(commits.len(), 2); + + assert_eq!(commits[0].id, "1111111111111111111111111111111111111111"); + assert_eq!(commits[0].summary, "first"); + assert_eq!(commits[0].author, "Alice"); + assert_eq!(commits[0].time, 1690876800); + + assert_eq!(commits[1].id, "2222222222222222222222222222222222222222"); + assert_eq!(commits[1].summary, "second"); + assert_eq!(commits[1].author, "Bob"); + assert_eq!(commits[1].time, 1690975800); +} + +#[test] +fn patch_commits_strips_patch_subject_prefixes() { + let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001 +From: A +Subject: [RFC PATCH v3 4/7] the real title + +--- +"#; + + let commits = patch_commits(patch); + assert_eq!(commits.len(), 1); + assert_eq!(commits[0].summary, "the real title"); +} + +#[test] +fn patch_commits_handles_missing_headers() { + // A hand-written patch without author or date headers still lists a commit. + // Time stays 0 and the author stays empty. + let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001 +Subject: [PATCH] plain + +--- +"#; + + let commits = patch_commits(patch); + assert_eq!(commits.len(), 1); + assert_eq!(commits[0].summary, "plain"); + assert_eq!(commits[0].author, ""); + assert_eq!(commits[0].time, 0); +} + +#[test] +fn patch_commits_ignores_non_patch_lines() { + assert!(patch_commits("").is_empty()); + assert!(patch_commits("just some text\nFrom 123\n").is_empty()); + // A diff-only body without an mbox envelope has no commits. + let patch = "diff --git a/x b/x\n--- a/x\n+++ b/x\n"; + assert!(patch_commits(patch).is_empty()); +} + +#[test] +fn marks_binary_sections() { + let patch = r#"diff --git a/img.png b/img.png +index 123..456 100644 +Binary files a/img.png and b/img.png differ +"#; + let diff = patch_diffs(patch).expect("parse"); + + assert!(diff.files[0].binary); + assert!(diff.files[0].hunks.is_empty()); +} + +#[test] +fn unquotes_quoted_paths() { + let patch = r#"diff --git "a/weird file.rs" "b/weird file.rs" +index 123..456 100644 +--- "a/weird file.rs" ++++ "b/weird file.rs" +@@ -1 +1 @@ +-x ++y +"#; + let diff = patch_diffs(patch).expect("parse"); + + assert_eq!(diff.files[0].path, "weird file.rs"); + assert_eq!(diff.files[0].status, DiffStatus::Modified); +} + +#[test] +fn unquotes_non_ascii_quoted_paths() { + let patch = r#"diff --git "a/说明.md" "b/说明.md" +index 123..456 100644 +--- "a/说明.md" ++++ "b/说明.md" +@@ -1 +1 @@ +-x ++y +"#; + let diff = patch_diffs(patch).expect("parse"); + + assert_eq!(diff.files[0].path, "说明.md"); + assert_eq!(diff.files[0].status, DiffStatus::Modified); +} + +#[test] +fn unquotes_octal_escaped_paths() { + let patch = r#"diff --git "a/\345\270\226.md" "b/\345\270\226.md" +index 123..456 100644 +--- "a/\345\270\226.md" ++++ "b/\345\270\226.md" +@@ -1 +1 @@ +-x ++y +"#; + let diff = patch_diffs(patch).expect("parse"); + + assert_eq!(diff.files[0].path, "帖.md"); + assert_eq!(diff.files[0].status, DiffStatus::Modified); +} + +#[test] +fn empty_or_unparseable_patch_yields_no_files() { + assert_eq!(patch_diffs("").expect("parse").files.len(), 0); + assert_eq!(patch_diffs("just some text").expect("parse").files.len(), 0); + assert_eq!( + patch_diffs("---\nnot a patch\n") + .expect("parse") + .files + .len(), + 0 + ); +} + +#[test] +fn parses_real_format_patch_output() { + // Build a commit touching a mix of file kinds. + // Feed genuine `git format-patch` output through the parser. + // It covers quoted and octal-escaped paths. + // There are also a rename-free modification, an addition and a binary deletion. + let (dir, repo) = fixture(&[ + ("src/main.rs", b"fn main() {\n println!(\"one\");\n}\n"), + ("my file.txt", b"hello\n"), + ("\u{8bf4}\u{660e}.md", "# \u{8bf4}\u{660e}\n".as_bytes()), + ("img.png", b"\x89PNG\r\n\x1a\n\x00binary"), + ]); + commit_all(&repo, "initial"); + + std::fs::write( + dir.path().join("src/main.rs"), + b"fn main() {\n println!(\"two\");\n println!(\"three\");\n}\n", + ) + .expect("write"); + std::fs::write(dir.path().join("my file.txt"), b"hello world\n").expect("write"); + std::fs::write( + dir.path().join("\u{8bf4}\u{660e}.md"), + "# \u{8bf4}\u{660e}\nupdated\n", + ) + .expect("write"); + std::fs::remove_file(dir.path().join("img.png")).expect("remove"); + std::fs::write(dir.path().join("new file.md"), b"# new\n").expect("write"); + commit_all(&repo, "changes"); + + let output = Command::new("git") + .current_dir(dir.path()) + .env("GIT_AUTHOR_NAME", "Test Author") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test Author") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .args(["format-patch", "-1", "--stdout"]) + .output() + .expect("spawn git format-patch"); + assert!(output.status.success(), "git format-patch failed"); + let patch = String::from_utf8(output.stdout).expect("patch is utf-8"); + + let diff = patch_diffs(&patch).expect("parse real format-patch output"); + + let by_path = |path: &str| { + diff.files + .iter() + .find(|file| file.path == path) + .unwrap_or_else(|| panic!("missing file {path:?}")) + }; + + // Space in the name makes git quote the path in the header. + let file = by_path("my file.txt"); + assert_eq!(file.status, DiffStatus::Modified); + assert_eq!(file.insertions, 1); + + // UTF-8 names are emitted as octal escapes. + let file = by_path("\u{8bf4}\u{660e}.md"); + assert_eq!(file.status, DiffStatus::Modified); + assert_eq!(file.insertions, 1); + + let file = by_path("src/main.rs"); + assert_eq!(file.status, DiffStatus::Modified); + assert_eq!(file.insertions, 2); + assert_eq!(file.deletions, 1); + assert!(!file.hunks.is_empty()); + + let file = by_path("new file.md"); + assert_eq!(file.status, DiffStatus::Added); + assert_eq!(file.insertions, 1); + + // A binary deletion emits no `---` or `+++` lines. + // Only the mode line and the `Binary files` marker remain. + let file = by_path("img.png"); + assert_eq!(file.status, DiffStatus::Deleted); + assert!(file.binary); + assert!(file.hunks.is_empty()); +} + +#[test] +fn worktree_dirty_tracks_changes_and_untracked_files() { + let (dir, repo) = fixture(&[("tracked.txt", b"one")]); + commit_all(&repo, "initial"); + let workdir = dir.path(); + + assert!(!worktree_dirty(workdir)); + + // A modified tracked file is dirty. + std::fs::write(workdir.join("tracked.txt"), b"two").expect("write"); + assert!(worktree_dirty(workdir)); + + // After restoring, an untracked file alone is dirty as well. + git_run(workdir, &["checkout", "--", "tracked.txt"]); + assert!(!worktree_dirty(workdir)); + std::fs::write(workdir.join("untracked.txt"), b"new").expect("write"); + assert!(worktree_dirty(workdir)); + + // A staged change counts too. + git_run(workdir, &["rm", "--cached", "tracked.txt"]); + assert!(worktree_dirty(workdir)); + + // A missing directory is clean, not an error. + assert!(!worktree_dirty(&dir.path().join("missing"))); +} + +#[test] +fn worktree_dirty_reports_unborn_worktrees_with_files() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("repo"); + let status = Command::new("git") + .args(["init", "-q"]) + .arg(&path) + .status() + .expect("spawn git init"); + assert!(status.success()); + + // No commits and no files: porcelain is empty. + assert!(!worktree_dirty(&path)); + // An unborn repository holding files is dirty. + std::fs::write(path.join("README.md"), "# hello\n").expect("write"); + assert!(worktree_dirty(&path)); +} + +#[test] +fn worktree_commits_ahead_counts_branch_only_commits() { + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + let path = dir.path(); + + git_run(path, &["checkout", "-q", "-b", "feature"]); + std::fs::write(path.join("f.txt"), b"f\n").expect("write"); + commit_all(&gix::open(path).expect("open"), "feature work"); + + assert_eq!(worktree_commits_ahead(path, "main", "feature"), 1); + assert_eq!(worktree_commits_ahead(path, "feature", "main"), 0); + + git_run(path, &["checkout", "-q", "main"]); + assert_eq!(worktree_current_branch(path).as_deref(), Some("main")); + assert!(worktree_ref_exists(path, "refs/heads/feature")); + assert!(!worktree_ref_exists(path, "refs/heads/nope")); + assert_eq!(worktree_commits_ahead(path, "main", "feature"), 1); +} diff --git a/crates/signed_git/src/worktree.rs b/crates/signed_git/src/worktree.rs new file mode 100644 index 0000000..d89a3f0 --- /dev/null +++ b/crates/signed_git/src/worktree.rs @@ -0,0 +1,354 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use gix::progress::Discard; + +use crate::history::{FileCommit, head_commit}; +use crate::repo::{current_branch, repository_signature}; + +/// Whether the worktree of `workdir` has uncommitted changes. +/// +/// Best-effort: any read failure is reported as clean. +pub fn worktree_dirty(workdir: &Path) -> bool { + let Ok(repo) = gix::open(workdir) else { + return false; + }; + + // Changes to tracked files, staged or not; untracked files are excluded. + match repo.is_dirty() { + Ok(true) => return true, + Ok(false) => {} + Err(_) => return false, + } + + // Untracked files surface as `DirectoryContents` items of the index-vs-worktree walk, + // tracked files only appear there when modified. + let Ok(platform) = repo.status(Discard) else { + return false; + }; + + let Ok(mut changes) = platform.into_index_worktree_iter(Vec::::new()) + else { + return false; + }; + + for change in changes.by_ref() { + match change { + Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true, + Ok(_) => {} + Err(_) => return false, + } + } + + false +} + +/// Commits in `base..branch` of the checkout at `workdir`. +/// +/// Best-effort: 0 when the range cannot be computed. +pub fn worktree_commits_ahead(workdir: &Path, base: &str, branch: &str) -> u32 { + let Ok(repo) = gix::open(workdir) else { + return 0; + }; + + let (Some(base), Some(branch)) = (resolve_commit(&repo, base), resolve_commit(&repo, branch)) + else { + return 0; + }; + + let Ok(walk) = repo.rev_walk([branch]).with_hidden([base]).all() else { + return 0; + }; + + walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32 +} + +/// Resolve `rev` to a commit id, accepting full refs, +/// symbolic refs and the bare branch names callers pass, like git's DWIM. +fn resolve_commit<'a>(repo: &'a gix::Repository, rev: &str) -> Option> { + if let Ok(id) = repo.rev_parse_single(rev.as_bytes()) { + return Some(id); + } + + // Branch names arrive bare, like git resolving `main`. + if rev.contains('/') { + return None; + } + + repo.rev_parse_single(format!("refs/heads/{rev}").as_bytes()) + .ok() +} + +/// Relative paths of all entries in the worktree, files and directories. +/// +/// The `.git` directory is skipped. +pub fn worktree_entries(repo: &gix::Repository) -> Result> { + let workdir = repo.workdir().context("repository has no worktree")?; + + let mut entries: Vec<(PathBuf, bool)> = Vec::new(); + collect_entries(workdir, workdir, &mut entries)?; + + entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| { + b_is_dir + .cmp(a_is_dir) + .then_with(|| a.as_os_str().cmp(b.as_os_str())) + }); + Ok(entries.into_iter().map(|(path, _)| path).collect()) +} + +/// Read a file from the worktree. +/// +/// Returns `Ok(None)` if the path is missing or not a regular file. +pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result>> { + let workdir = repo.workdir().context("repository has no worktree")?; + let path = workdir.join(rel); + + match std::fs::read(&path) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None), + Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), + } +} + +/// Find the README file in the repository root. +/// +/// Falls back to any other file whose name starts with `readme`. +pub fn find_readme(repo: &gix::Repository) -> Result> { + let Some(workdir) = repo.workdir() else { + return Ok(None); + }; + + let mut candidates: Vec = Vec::new(); + for entry in std::fs::read_dir(workdir)? { + let entry = entry?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if name.to_ascii_lowercase().starts_with("readme") { + candidates.push(entry.path()); + } + } + + candidates.sort_by_key(|path| { + let ext = path + .extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()); + match ext.as_deref() { + Some("md") => 0, + Some("markdown") => 1, + Some("mdown") => 2, + Some("mkdn") => 3, + Some(_) => 5, + None => 4, + } + }); + + Ok(candidates + .into_iter() + .next() + .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) +} + +/// Everything the browser needs to refresh after a branch or tag switch. +pub struct WorktreeSnapshot { + /// Relative paths of all worktree entries, directories first. + pub entries: Vec, + /// README path relative to the worktree, if any. + pub readme_path: Option, + /// Contents of the README, if any. + pub readme: Option>, + /// Branch HEAD points to, `None` when detached, for example on a tag. + pub current_branch: Option, + /// Commit HEAD points to, if any, see [`head_commit`]. + pub head_commit: Option, +} + +/// Snapshot the worktree after a branch or tag switch. +/// +/// Collects entries, the README, the branch HEAD points to and its commit. +pub fn worktree_snapshot(workdir: &Path) -> Result { + let repo = gix::open(workdir)?; + let readme_path = find_readme(&repo)?; + let readme = match &readme_path { + Some(path) => worktree_read(&repo, path)?, + None => None, + }; + Ok(WorktreeSnapshot { + entries: worktree_entries(&repo)?, + readme_path, + readme, + current_branch: current_branch(&repo)?, + head_commit: head_commit(&repo)?, + }) +} + +/// Check out `tree` into the worktree of `repo` +pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> Result<()> { + let workdir = repo + .workdir() + .context("repository has no worktree")? + .to_path_buf(); + + let mut index = repo.index_from_tree(tree)?; + + // Files the previous index tracked but `tree` no longer contains are removed, + // like git deleting files that vanish between branches. + if let Ok(previous) = repo.index_or_empty() { + let keep: HashSet = index + .entries() + .iter() + .map(|entry| PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned())) + .collect(); + for entry in previous.entries() { + let rel = entry.path(&previous); + let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned()); + + if keep.contains(&rel) { + continue; + } + + let path = workdir.join(&rel); + + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("failed to remove {}", path.display())); + } + } + } + } + + let mut options = + repo.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?; + options.overwrite_existing = true; + + let objects = repo.objects.clone().into_arc()?; + let files = gix::progress::Discard; + let bytes = gix::progress::Discard; + + // Check out the index into the worktree. + gix_worktree_state::checkout( + &mut index, + workdir, + objects, + &files, + &bytes, + &gix::interrupt::IS_INTERRUPTED, + options, + )?; + + // Write the index to disk. + index.write(gix::index::write::Options::default())?; + + Ok(()) +} + +/// Point `HEAD` at `target` and record the switch in the reflog. +fn move_head( + repo: &gix::Repository, + signature: gix::actor::SignatureRef<'_>, + target: gix::refs::Target, + message: &str, +) -> Result<()> { + use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; + + let head = gix::refs::FullName::try_from("HEAD") + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; + + // Update the reference, creating a reflog entry. + repo.edit_references_as( + [RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: message.into(), + }, + expected: PreviousValue::Any, + new: target, + }, + name: head, + deref: false, + }], + Some(signature), + )?; + + Ok(()) +} + +/// Check out the local branch `name`, HEAD stays attached to it. +pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> { + let repo = gix::open(workdir)?; + let full = format!("refs/heads/{name}"); + + let branch = gix::refs::FullName::try_from(full.as_str()) + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; + + let mut reference = repo.find_reference(&full)?; + let tree = reference.peel_to_tree()?.id; + + let (signature, mut time_buf) = repository_signature(); + let signature = signature.to_ref(&mut time_buf); + + // Move HEAD to the branch, creating a reflog entry. + move_head( + &repo, + signature, + gix::refs::Target::Symbolic(branch), + &format!("checkout: moving to {name}"), + )?; + + // Check out the branch's tree, replacing index + worktree. + force_checkout(&repo, &tree)?; + + Ok(()) +} + +/// Check out the tag `name`, HEAD becomes detached at the tagged commit. +pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> { + let repo = gix::open(workdir)?; + let full = format!("refs/tags/{name}"); + + let mut reference = repo.find_reference(&full)?; + + let commit = reference.peel_to_id()?; + let tree = reference.peel_to_tree()?.id; + + let (signature, mut time_buf) = repository_signature(); + let signature = signature.to_ref(&mut time_buf); + + // Move HEAD to the tag, creating a reflog entry. + move_head( + &repo, + signature, + gix::refs::Target::Object(commit.detach()), + &format!("checkout: moving to {name}"), + )?; + + // Check out the tag's tree, replacing index + worktree. + force_checkout(&repo, &tree)?; + + Ok(()) +} + +fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + if entry.file_name() == ".git" { + continue; + } + + let is_dir = entry.file_type()?.is_dir(); + let path = entry.path(); + let rel = path.strip_prefix(root)?.to_path_buf(); + out.push((rel, is_dir)); + + if is_dir { + collect_entries(root, &path, out)?; + } + } + Ok(()) +} diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index cf4a568..43765a2 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -1,20 +1,16 @@ -use std::collections::hash_map::DefaultHasher; use std::collections::{HashMap, HashSet}; -use std::future::Future; -use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use std::str::FromStr; -use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use anyhow::{Context as AnyhowContext, Error, anyhow, bail}; +use anyhow::{Error, anyhow, bail}; use bitcoin_hashes::sha1::Hash as Sha1Hash; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; use nostr::event::IntoEventBuilder; use nostr_connect::prelude::*; use nostr_sdk::client::SyncSummary; use nostr_sdk::prelude::*; -use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_name, repo_addr}; +use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_name}; use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; use crate::git_store::GitStore; @@ -35,8 +31,12 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [ /// Relays used to index the user's NIP-65 relay list. pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"]; -/// How long an identical fetch or sync request is suppressed after it started. -const FETCH_DEDUP_WINDOW: Duration = Duration::from_secs(5 * 60); +/// Delay the notification pump waits for more events before emitting a batch. +/// +/// A negentropy sync can deliver hundreds of events in a burst; batching +/// them here means every subscriber debounces the burst once, not once per +/// subscriber. +const PUMP_DEBOUNCE: Duration = Duration::from_millis(200); #[derive(Debug, Clone)] pub enum BackendEvent { @@ -46,8 +46,13 @@ pub enum BackendEvent { PassphraseRequired, /// The signer changed on login, logout or account switch. SignerChanged, - /// A new event was received from a relay and stored in the database. - NostrUpdate(Update), + /// New events were received from a relay and stored in the database. + /// + /// Batched: [`Backend`]'s notification pump coalesces everything a + /// relay delivers within one debounce window into a single event, + /// instead of emitting per-event and making every subscriber debounce + /// the same burst independently. + NostrUpdate(Vec), /// A negentropy sync completed. Synced, /// A negentropy sync is in flight. @@ -82,33 +87,18 @@ pub struct Backend { sync_progress: Option<(u64, u64)>, /// True when the stored credential is NIP-49 encrypted. passphrase_required: bool, - /// Fingerprints of recently started fetches and syncs, a relay plus filter set. - recent_fetches: HashMap, /// Repositories with a push in flight, mirror or checkout based. - pushing_repos: Arc>>, - tasks: Vec>>, + /// + /// A child entity: views that only care whether one repository is + /// pushing can `cx.observe` it without being invoked on unrelated + /// `Backend` changes (a `sync_progress` tick, a new relay connecting). + pushing_repos: Entity>, } struct GlobalBackend(Entity); impl Global for GlobalBackend {} -/// Removes its repository from the in-flight push set when dropped. -/// -/// A push task cancelled by its panel closing cannot leave the repository locked. -struct PushGuard { - repos: Arc>>, - addr: RepoAddr, -} - -impl Drop for PushGuard { - fn drop(&mut self) { - if let Ok(mut repos) = self.repos.lock() { - repos.remove(&self.addr); - } - } -} - impl EventEmitter for Backend {} impl Backend { @@ -124,48 +114,74 @@ impl Backend { pub(crate) fn new(client: Client, signer: UniversalSigner, cx: &mut Context) -> Self { let pump_client = client.clone(); - let pump = cx.spawn(async move |this, cx| { + let pump: Task> = cx.spawn(async move |this, cx| { let mut notifications = pump_client.notifications(); + let mut pending: Vec = Vec::new(); - while let Some(notification) = notifications.next().await { - let ClientNotification::Event { event, .. } = notification else { - continue; - }; + 'outer: loop { + // Wait for the first event of a batch. + match notifications.next().await { + Some(ClientNotification::Event { event, .. }) => { + pending.push(Update::from_event(&event)); + } + Some(_) => continue, + None => break, + } - let update = Update::from_event(&event); + // Collect everything else that arrives within the debounce window. + let deadline = Instant::now() + PUMP_DEBOUNCE; - if this - .update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(update))) - .is_err() - { - break; + loop { + let now = Instant::now(); + if now >= deadline { + break; + } + let timer = cx.background_executor().timer(deadline - now); + futures::pin_mut!(timer); + let next = notifications.next(); + futures::pin_mut!(next); + match futures::future::select(next, timer).await { + futures::future::Either::Left(( + Some(ClientNotification::Event { event, .. }), + _, + )) => { + pending.push(Update::from_event(&event)); + } + futures::future::Either::Left((Some(_), _)) => continue, + futures::future::Either::Left((None, _)) => break 'outer, + futures::future::Either::Right(_) => break, + } + } + + // Collect and emit the collected events. + let batch = std::mem::take(&mut pending); + + if let Err(e) = this.update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(batch))) { + log::warn!("failed to emit nostr update: {e}"); } } Ok(()) }); - let mut this = Self { + pump.detach(); + + // Bootstrap the client. + let weak = cx.entity().downgrade(); + cx.defer(move |cx| { + if let Err(error) = weak.update(cx, |this, cx| this.bootstrap(cx)) { + log::warn!("backend dropped before bootstrap could run: {error}"); + } + }); + + Self { client, signer, current_user: None, sync_progress: None, passphrase_required: false, - recent_fetches: HashMap::new(), - pushing_repos: Arc::new(Mutex::new(HashSet::new())), - tasks: vec![pump], - }; - - this.bootstrap(cx); - this - } - - /// Track a spawned task, pruning finished tasks first. - /// - /// Keeps the store's task list bounded by the number of in-flight tasks. - fn push_task(&mut self, task: Task>) { - self.tasks.retain(|task| !task.is_ready()); - self.tasks.push(task); + pushing_repos: cx.new(|_| HashSet::new()), + } } /// Bootstrap the client. @@ -176,19 +192,19 @@ impl Backend { let task = cx.background_spawn(async move { for url in BOOTSTRAP_RELAYS { - client.add_relay(url).await?; + client.add_relay(url).and_connect().await?; } for url in INDEXER_RELAYS { client .add_relay(url) .capabilities(RelayCapabilities::DISCOVERY) + .and_connect() .await?; } - client.connect().await; Ok::<(), Error>(()) }); - self.push_task(cx.spawn(async move |this, cx| { + let notify_task: Task> = cx.spawn(async move |this, cx| { match task.await { Ok(()) => { this.update(cx, |_this, cx| cx.notify())?; @@ -198,7 +214,8 @@ impl Backend { } } Ok(()) - })); + }); + notify_task.detach(); self.restore_session(cx); } @@ -216,7 +233,7 @@ impl Backend { let user = cx.read_credentials(USER_KEYRING); - self.push_task(cx.spawn(async move |this, cx| { + let task: Task> = cx.spawn(async move |this, cx| { let content = match user.await { Ok(Some((_username, secret))) => String::from_utf8(secret)?, _ => { @@ -264,7 +281,8 @@ impl Backend { } Ok(()) - })); + }); + task.detach(); } /// Decrypt the NIP-49 keyring credential with the given passphrase. @@ -365,24 +383,31 @@ impl Backend { ] .to_vec(); - this.send_fire_and_forget(RelayList::new(relays).into_event_builder(), cx); - let metadata = Metadata::new() .name(&name) .display_name(&name) .into_event_builder(); - this.send_fire_and_forget(metadata, cx); - let grasp_servers: Vec = ["wss://gitnostr.com", "wss://relay.ngit.dev"] .into_iter() .map(|url| RelayUrl::parse(url).expect("valid relay URL")) .collect(); - this.send_fire_and_forget( + let client = this.client.clone(); + let signer = this.signer.clone(); + + for builder in [ + RelayList::new(relays).into_event_builder(), + metadata, GitUserGraspList { grasp_servers }.into_event_builder(), - cx, - ); + ] { + let client = client.clone(); + let signer = signer.clone(); + cx.spawn(async move |_this, _cx| { + publish_best_effort(&client, &signer, builder).await + }) + .detach(); + } })?; Ok(public_key) @@ -434,17 +459,27 @@ impl Backend { ))); } - let addr = repo_addr(public_key, repo_id.clone()); - let cache = GitStore::global(cx).cache().clone(); - let path = cache.repo_path(&addr); let owner = public_key.to_bech32().unwrap(); let servers = grasp_servers.clone(); + let client = self.client.clone(); + + // Initialize directly at the user's chosen destination. + // No mirror is pre-populated: `GitCache::ensure_clone` lazily clones + // from the grasp server the first time the repo detail view needs it, + // exactly like every other repository. + let destination = { + let dir_name = signed_git::sanitize_path_component(&name); + let dir_name = if dir_name.is_empty() { + "repository".to_owned() + } else { + dir_name + }; + folder.join(dir_name) + }; cx.spawn(async move |this, cx| { - // Initialize the local clone and create the user's working copy from it. let work = cx.background_spawn({ - let path = path.clone(); - let folder = folder.clone(); + let destination = destination.clone(); let name = name.clone(); let description = description.clone(); let owner = owner.clone(); @@ -452,60 +487,28 @@ impl Backend { let servers = servers.clone(); async move { - let parent = path - .parent() - .ok_or_else(|| anyhow!("invalid repository path"))?; - std::fs::create_dir_all(parent)?; - let commit = signed_git::init_repository(&path, &name, &description)?; - - // Point `origin` at the first grasp server. - // Later fetches and pushes have a target, like ngit. - if let Some(base) = servers.first().and_then(grasp_base_url) { - let url = format!("{base}/{owner}/{repo_id}.git"); - signed_git::ensure_origin(&path, &url).ok(); + if destination.exists() { + bail!("destination {} already exists", destination.display()); } - // A working copy at `/`, like the header's Clone action. - // Cloned from the mirror above so it shares the announced history. - // `origin` is set to the first grasp server, not the mirror path. - let destination = { - let dir_name = signed_git::sanitize_path_component(&name); - let dir_name = if dir_name.is_empty() { - "repository".to_owned() - } else { - dir_name - }; - folder.join(dir_name) - }; - - let mirror_url = Url::from_file_path(&path) - .map_err(|_| anyhow!("invalid mirror path"))? - .to_string(); - - signed_git::clone_repo(&[mirror_url], &destination).with_context(|| { - format!( - "failed to create the working copy at {}", - destination.display() - ) - })?; + let commit = signed_git::init_repository(&destination, &name, &description)?; if let Some(base) = servers.first().and_then(grasp_base_url) { let url = format!("{base}/{owner}/{repo_id}.git"); signed_git::set_origin(&destination, &url)?; } - Ok::<_, Error>((commit, destination)) + Ok::<_, Error>(commit) } }); - let (commit, checkout_path) = work.await?; + let commit = work.await?; let commit_sha = Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid id"))?; // The nostr client queues events until each relay is connected. - this.update(cx, |this, cx| { - let urls: Vec = servers.iter().map(ToString::to_string).collect(); - this.add_relays(urls, cx); - })?; + for url in &servers { + client.add_relay(url).and_connect().await.ok(); + } // The state event is the push authorization. It must be accepted before the push below. let announcement = GitRepositoryAnnouncement { @@ -522,24 +525,27 @@ impl Backend { maintainers: Vec::new(), }; - let event = this - .update(cx, |this, cx| { - this.send(announcement.into_event_builder(), cx) - })? - .await?; + let signer = this.update(cx, |this, _cx| this.signer.clone())?; + + let event = { + let builder = announcement.into_event_builder(); + let event = builder.finalize_async(&signer).await?; + let output = client.send_event(&event).broadcast().await?; + let event = require_relay_accepted(output, event)?; + this.update(cx, |this, cx| this.announce_published(event.clone(), cx))?; + event + }; // The state event is the push authorization. Stage it on each // grasp server's relay, then push the initial commit. // Creation fails only when no server accepted the push, the announcement // is then retracted so the repository is not left announced without content. - let (client, signer) = - this.update(cx, |this, _cx| (this.client.clone(), this.signer.clone()))?; let refs = vec![("refs/heads/main".to_owned(), commit)]; let push = cx.background_spawn({ let client = client.clone(); let signer = signer.clone(); - let path = path.clone(); + let destination = destination.clone(); let owner = owner.clone(); let repo_id = repo_id.clone(); let servers = servers.clone(); @@ -551,7 +557,7 @@ impl Backend { &repo_id, &refs, Some("main"), - &path, + &destination, &owner, &servers, signed_git::push_main, @@ -581,9 +587,11 @@ impl Backend { // Staging already stored the event locally, publishing makes it // visible to the other relays and clients. if let Some(state_event) = &outcome.state_event { - broadcast_event(&client, state_event).await.ok(); - this.update(cx, |_this, cx| { - cx.emit(BackendEvent::Published(Box::new(state_event.clone()))); + if let Err(e) = client.send_event(state_event).broadcast().await { + log::warn!("failed to broadcast repository state: {e}"); + } + this.update(cx, |this, cx| { + this.announce_published(state_event.clone(), cx) }) .ok(); } @@ -591,7 +599,7 @@ impl Backend { let announcement = Announcement::from_event(&event) .ok_or_else(|| anyhow!("failed to parse announcement"))?; - Ok((announcement, checkout_path)) + Ok((announcement, destination)) }) } @@ -636,6 +644,7 @@ impl Backend { let owner = public_key.to_bech32().unwrap(); let servers = grasp_servers.clone(); + let client = self.client.clone(); cx.spawn(async move |this, cx| { let work = cx.background_spawn({ @@ -649,10 +658,9 @@ impl Backend { let (state, euc) = work.await?; // The nostr client queues events until each relay is connected. - this.update(cx, |this, cx| { - let urls: Vec = servers.iter().map(ToString::to_string).collect(); - this.add_relays(urls, cx); - })?; + for url in &servers { + client.add_relay(url).and_connect().await.ok(); + } // The state event is the push authorization. It must be accepted before the push below. let announcement = GitRepositoryAnnouncement { @@ -669,11 +677,16 @@ impl Backend { maintainers: Vec::new(), }; - let event = this - .update(cx, |this, cx| { - this.send(announcement.into_event_builder(), cx) - })? - .await?; + let signer = this.update(cx, |this, _cx| this.signer.clone())?; + + let event = { + let builder = announcement.into_event_builder(); + let event = builder.finalize_async(&signer).await?; + let output = client.send_event(&event).broadcast().await?; + let event = require_relay_accepted(output, event)?; + this.update(cx, |this, cx| this.announce_published(event.clone(), cx))?; + event + }; let refs = state.refs.clone(); let head = state.head.clone(); @@ -683,9 +696,6 @@ impl Backend { // fails only when no server accepted it. The announcement is then // retracted so the repository is not left announced without content. // An empty repository has no state to stage and nothing to push. - let (client, signer) = - this.update(cx, |this, _cx| (this.client.clone(), this.signer.clone()))?; - if !refs.is_empty() { let push = cx.background_spawn({ let client = client.clone(); @@ -731,11 +741,11 @@ impl Backend { // Fan the state out to the relays once a git server holds the objects. // Staging already stored the event locally, publishing makes it visible to the other relays and clients. if let Some(state_event) = &outcome.state_event { - broadcast_event(&client, state_event).await.ok(); - this.update(cx, |_this, cx| { - cx.emit(BackendEvent::Published(Box::new(state_event.clone()))); - }) - .ok(); + if let Err(e) = client.send_event(state_event).broadcast().await { + log::warn!("failed to broadcast repository state: {e}"); + } + this.update(cx, |this, cx| this.announce_published(state_event.clone(), cx)) + .ok(); } } @@ -790,31 +800,34 @@ impl Backend { cx: &mut Context, ) -> Task> { let addr = announcement.addr(); - let guard = { - let mut pushing = self - .pushing_repos - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if !pushing.insert(addr.clone()) { - return Task::ready(Err(anyhow!( - "A push to this repository is already in progress" - ))); - } + if self.pushing_repos.read(cx).contains(&addr) { + return Task::ready(Err(anyhow!( + "A push to this repository is already in progress" + ))); + } - PushGuard { - repos: self.pushing_repos.clone(), - addr: addr.clone(), - } - }; + self.pushing_repos.update(cx, |pushing, cx| { + pushing.insert(addr.clone()); + cx.notify(); + }); let owner = announcement.owner.to_bech32().unwrap(); let repo_id = announcement.id.clone(); let relays = announcement.relays.clone(); cx.spawn(async move |this, cx| { - // Held for the whole task. Dropped on completion, on error and on cancellation alike. - let _guard = guard; + // Held for the whole task. Runs on completion, on error and on + // cancellation alike, since dropping the task drops this guard. + let _guard = cx.on_drop(&this, { + let addr = addr.clone(); + move |backend, cx| { + backend.pushing_repos.update(cx, |pushing, cx| { + pushing.remove(&addr); + cx.notify(); + }); + } + }); let mut state = { let work = cx.background_spawn({ @@ -890,9 +903,11 @@ impl Backend { // Staging already stored the event locally, publishing notifies // the repository views and other relays and clients. if let Some(state_event) = &outcome.state_event { - broadcast_event(&client, state_event).await.ok(); - this.update(cx, |_this, cx| { - cx.emit(BackendEvent::Published(Box::new(state_event.clone()))); + if let Err(e) = client.send_event(state_event).broadcast().await { + log::warn!("failed to broadcast repository state: {e}"); + } + this.update(cx, |this, cx| { + this.announce_published(state_event.clone(), cx) }) .ok(); } @@ -982,14 +997,15 @@ impl Backend { let pubkey = keys.public_key().to_hex(); let write = cx.write_credentials(USER_KEYRING, &pubkey, nsec.as_bytes()); - self.push_task(cx.spawn(async move |this, cx| { + let task: Task> = cx.spawn(async move |this, cx| { if let Err(e) = write.await { this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; return Ok(()); } this.update(cx, |this, cx| this.set_signer(keys, cx))?; Ok(()) - })); + }); + task.detach(); } /// Login with a `bunker://...` URI, NIP-46. @@ -1008,7 +1024,7 @@ impl Backend { let credential = with_master_key(&uri_string, &keys); let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes()); - self.push_task(cx.spawn(async move |this, cx| { + let task: Task> = cx.spawn(async move |this, cx| { let result = async { let mut signer = NostrConnect::new( connect_uri, @@ -1033,14 +1049,15 @@ impl Backend { } Ok(()) - })); + }); + task.detach(); } /// Remove the saved credential and reset to an anonymous session. pub fn logout(&mut self, cx: &mut Context) { let delete = cx.delete_credentials(USER_KEYRING); - self.push_task(cx.spawn(async move |this, cx| { + let task: Task> = cx.spawn(async move |this, cx| { delete.await.ok(); this.update(cx, |this, cx| { @@ -1053,25 +1070,26 @@ impl Backend { })?; Ok(()) - })); + }); + task.detach(); } - /// Fetch the user's grasp list and add the listed grasp servers as relays. + /// Sync the user's grasp list and add the listed grasp servers as relays. fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context) { let client = self.client.clone(); - self.push_task(cx.spawn(async move |this, cx| { + let task: Task> = cx.spawn(async move |this, cx| { let result = async { - let events: Vec = client - .fetch_events(filters::grasp_list(public_key)) - .await? - .into_iter() - .collect(); + sync_bootstrap_only( + &client, + filters::grasp_list(public_key), + SyncOptions::default(), + ) + .await?; - for url in latest_grasp_list_servers(events) { - client.add_relay(url.as_str()).await.ok(); + for url in user_grasp_list_servers(client.clone(), public_key).await? { + client.add_relay(url).and_connect().await.ok(); } - client.connect().await; Ok::<_, Error>(()) } @@ -1082,7 +1100,8 @@ impl Backend { } Ok(()) - })); + }); + task.detach(); } /// Get the nostr client. @@ -1095,6 +1114,13 @@ impl Backend { self.signer.clone() } + /// Repositories with a push in flight, mirror or checkout based. + /// + /// A child entity: `cx.observe` it to react only to push-state changes. + pub fn pushing_repos(&self) -> Entity> { + self.pushing_repos.clone() + } + /// Get the current user's public key. pub fn current_user(&self) -> Option { self.current_user @@ -1123,7 +1149,7 @@ impl Backend { ::Error: std::error::Error + Send + Sync + 'static, ::Error: std::error::Error + Send + Sync + 'static, { - let task = cx.spawn(async move |this, cx| { + let task: Task> = cx.spawn(async move |this, cx| { match new_signer.get_public_key_async().await { Ok(public_key) => { this.update(cx, |this, cx| { @@ -1144,99 +1170,48 @@ impl Backend { Ok(()) }); - self.push_task(task); - } - - /// Add relays and connect to them. - pub fn add_relays(&mut self, urls: Vec, cx: &mut Context) { - let client = self.client.clone(); - - let task = cx.background_spawn(async move { - for url in urls { - client.add_relay(&url).await?; - } - client.connect().await; - Ok::<(), Error>(()) - }); - - self.push_task(cx.spawn(async move |this, cx| { - match task.await { - Ok(()) => { - this.update(cx, |_this, cx| cx.notify())?; - } - Err(e) => { - this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; - } - } - Ok(()) - })); - } - - /// Whether an identical fetch started within [`FETCH_DEDUP_WINDOW`] is still recent. - /// - /// Records the fingerprint when returning `false`, pruning expired entries first. - fn fetch_recently_started(&mut self, fingerprint: u64) -> bool { - self.recent_fetches - .retain(|_, started| started.elapsed() < FETCH_DEDUP_WINDOW); - if self.recent_fetches.contains_key(&fingerprint) { - return true; - } - self.recent_fetches.insert(fingerprint, Instant::now()); - false + task.detach(); } /// Connect to a repository's announced relays, its NIP-34 `relays` tag. + /// + /// Callers are responsible for not repeating this for relays they already + /// connected, e.g. `RepoStore::repo_relays`. pub fn connect_repo_relays( &mut self, relays: Vec, filters: Vec, cx: &mut Context, ) { - let relay_strs: Vec<&str> = relays.iter().map(|url| url.as_str()).collect(); - let fingerprint = fetch_fingerprint(&relay_strs, &filters); - if self.fetch_recently_started(fingerprint) { - log::debug!("skipping duplicate repo relay fetch"); - return; - } - let client = self.client.clone(); - self.push_task(cx.spawn(async move |this, cx| { - if let Err(e) = connect_repo_relays_only(&client, relays, filters).await { + let task: Task> = cx.spawn(async move |_this, _cx| { + if let Err(e) = connect_repo_relays(&client, relays, filters).await { log::warn!("repo relay fetch failed: {e}"); - // Allow an immediate retry after a failure. - this.update(cx, |this, _cx| { - this.recent_fetches.remove(&fingerprint); - }) - .ok(); } Ok(()) - })); + }); + task.detach(); } /// One-shot subscription on the bootstrap relays only. pub fn subscribe_bootstrap(&mut self, filters: Vec, cx: &mut Context) { let client = self.client.clone(); - let task = + let fetch = cx.background_spawn(async move { subscribe_bootstrap_only(&client, filters).await }); - self.push_task(cx.spawn(async move |this, cx| { - if let Err(e) = task.await { + let task: Task> = cx.spawn(async move |this, cx| { + if let Err(e) = fetch.await { this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } Ok(()) - })); + }); + task.detach(); } /// Negentropy-sync the given filter against the bootstrap relays. pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context) { - let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter)); - if self.fetch_recently_started(fingerprint) { - log::debug!("skipping duplicate bootstrap sync"); - return; - } - let client = self.client.clone(); self.sync_progress = Some((0, 0)); @@ -1244,7 +1219,7 @@ impl Backend { let (tx, mut rx) = SyncProgress::channel(); - self.push_task(cx.spawn(async move |this, cx| { + let progress_task: Task> = cx.spawn(async move |this, cx| { let mut last_percent: u64 = 0; while rx.changed().await.is_ok() { @@ -1270,15 +1245,16 @@ impl Backend { } Ok(()) - })); + }); + progress_task.detach(); - let task = cx.background_spawn(async move { + let sync = cx.background_spawn(async move { let opts = SyncOptions::default().progress(tx); sync_bootstrap_only(&client, filter, opts).await }); - self.push_task(cx.spawn(async move |this, cx| { - match task.await { + let task: Task> = cx.spawn(async move |this, cx| { + match sync.await { Ok(summary) => { log::debug!( "sync done: {} received, {} sent", @@ -1294,116 +1270,68 @@ impl Backend { Err(e) => { this.update(cx, |this, cx| { this.sync_progress = None; - // Allow an immediate retry after a failure. - this.recent_fetches.remove(&fingerprint); cx.emit(BackendEvent::error(e.to_string())) })?; } } Ok(()) - })); + }); + task.detach(); } - /// Sign, broadcast and locally store an event. - pub fn send( - &mut self, - builder: EventBuilder, - cx: &mut Context, - ) -> Task> { + /// Emit [`BackendEvent::Published`] for cross-store invalidation. + /// + /// Callers publish with `client.send_event(...)` directly, then call this + /// so stores like `RepoListStore` refresh without re-querying the relays. + pub fn announce_published(&self, event: Event, cx: &mut Context) { + cx.emit(BackendEvent::Published(Box::new(event))); + } + + /// Publish a NIP-09 deletion for each of `events`, best-effort. + /// + /// Each target gets its own deletion event: a relay rejecting or + /// dropping one does not affect the others. + fn retract_events(&mut self, events: &[Event], cx: &mut Context) { let client = self.client.clone(); let signer = self.signer.clone(); - self.publish_task(cx, async move { - // Sign with the current signer, broadcast and save locally. - // The event is immediately visible to database queries. - let event = builder.finalize_async(&signer).await?; - broadcast_event(&client, &event).await - }) - } + for event in events.iter().cloned() { + let client = client.clone(); + let signer = signer.clone(); - /// Broadcast and locally store an already-signed event. - pub fn publish_event( - &mut self, - event: Event, - cx: &mut Context, - ) -> Task> { - let client = self.client.clone(); - self.publish_task(cx, async move { broadcast_event(&client, &event).await }) - } - - /// Run `work` in the background, then emit its outcome as a [`BackendEvent`]. - fn publish_task( - &mut self, - cx: &mut Context, - work: impl Future> + 'static + Send, - ) -> Task> { - cx.spawn(async move |this, cx| { - let result = cx.background_spawn(work).await; - - match &result { - Ok(event) => { - this.update(cx, |_this, cx| { - cx.emit(BackendEvent::Published(Box::new(event.clone()))); - }) - .ok(); + cx.spawn(async move |_this, _cx| { + if let Err(e) = retract_event(&client, &signer, &event).await { + log::warn!("failed to retract event {}: {e}", event.id); } - Err(e) => { - this.update(cx, |_this, cx| { - cx.emit(BackendEvent::error(e.to_string())); - }) - .ok(); - } - } - - result - }) - } - - /// Sign, broadcast and store an event without awaiting the result. - fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context) { - let task = self.send(builder, cx); - - self.push_task(cx.spawn(async move |this, cx| { - if let Err(e) = task.await { - this.update(cx, |_this, cx| { - cx.emit(BackendEvent::error(e.to_string())); - }) - .ok(); - } - Ok(()) - })); - } - - /// Publish NIP-09 deletions for `events`, best-effort. - fn retract_events(&mut self, events: &[Event], cx: &mut Context) { - if events.is_empty() { - return; + }) + .detach(); } - - let mut tags: Vec = Vec::with_capacity(events.len() * 2); - - for event in events { - tags.push(Tag::event(event.id)); - tags.push(Tag::parse(["k", &event.kind.to_string()]).expect("valid kind tag")); - } - - let task = self.send(EventBuilder::new(Kind::EventDeletion, "").tags(tags), cx); - - self.push_task(cx.spawn(async move |_this, _cx| { - if let Err(e) = task.await { - log::warn!("failed to retract repository events: {e}"); - } - Ok(()) - })); } } -/// Broadcast an event and fail when no relay accepted it. -/// -/// The client stores accepted events locally, visible to database queries. -async fn broadcast_event(client: &Client, event: &Event) -> Result { - let output = client.send_event(event).await?; +/// Sign and send a single NIP-09 deletion request for `event`. +async fn retract_event( + client: &Client, + signer: &UniversalSigner, + event: &Event, +) -> Result<(), Error> { + let builder = EventDeletionRequest::new() + .id(event.id) + .into_event_builder(); + let deletion = builder.finalize_async(signer).await?; + client.send_event(&deletion).broadcast().await?; + Ok(()) +} +/// The event was accepted by at least one relay, or a descriptive error otherwise. +/// +/// The SDK does not treat "accepted by zero relays" as an error on its own: +/// [`SendEventOutput::success`] may be empty while the call still returns `Ok`. +/// This turns that case into an error the caller can surface. +pub(crate) fn require_relay_accepted( + output: SendEventOutput, + event: Event, +) -> Result { if output.success.is_empty() && !output.failed.is_empty() { let reasons = output .failed @@ -1411,76 +1339,51 @@ async fn broadcast_event(client: &Client, event: &Event) -> Result .cloned() .collect::>() .join(", "); - return Err(anyhow!("event not accepted by any relay: {reasons}")); + bail!("event not accepted by any relay: {reasons}"); } - Ok(event.clone()) + Ok(event) } -/// Fingerprint of a relay and filter set, for fetch dedup. +/// Sign and broadcast `builder`, logging rather than surfacing failures. /// -/// Relays and filters are sorted first, so the fingerprint is order-independent. -fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 { - let mut relays: Vec<&str> = relays.to_vec(); - relays.sort_unstable(); - let mut filters: Vec<&Filter> = filters.iter().collect(); - filters.sort_unstable(); +/// Used for best-effort identity bootstrap events, where a relay hiccup +/// should not block sign-up. +async fn publish_best_effort(client: &Client, signer: &UniversalSigner, builder: EventBuilder) { + let result: Result<(), Error> = async { + let event = builder.finalize_async(signer).await?; + let output = client.send_event(&event).broadcast().await?; + require_relay_accepted(output, event)?; + Ok(()) + } + .await; - let mut hasher = DefaultHasher::new(); - relays.hash(&mut hasher); - filters.hash(&mut hasher); - hasher.finish() + if let Err(e) = result { + log::warn!("failed to publish identity bootstrap event: {e}"); + } } /// Add the given relays, connect and fetch the filters. -async fn connect_repo_relays_only( +async fn connect_repo_relays( client: &Client, relays: Vec, filters: Vec, ) -> Result<(), Error> { - if relays.is_empty() { + if relays.is_empty() || filters.is_empty() { return Ok(()); } - let mut added = false; - for url in &relays { - added |= client.add_relay(url).await?; + // Ensure relay connections + for url in relays.iter() { + client.add_relay(url).and_connect().await?; } - // Connect only when the pool grew. - if added { - client.connect().await; - } - - let opts = SubscribeAutoCloseOptions::default() - .exit_policy(ReqExitPolicy::ExitOnEOSE) - .timeout(Some(Duration::from_secs(10))); - - let target: HashMap<&str, Vec> = relays - .iter() - .map(|url| (url.as_str(), filters.clone())) - .collect(); - client.subscribe(target).close_on(opts).await?; - - // Sync the filters concurrently. - let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5)); - let syncs = filters.into_iter().map(|filter| { - let client = &client; - let relays = &relays; - let sync_opts = sync_opts.clone(); - async move { - if let Err(e) = client - .sync(filter) - .with(relays.iter()) - .opts(sync_opts) - .await - { - log::warn!("repo relay negentropy sync failed: {e}"); - } + // Run neg sync for each filter + for filter in filters.into_iter() { + if let Err(e) = client.sync(filter).with(relays.iter()).await { + log::warn!("repo relay negentropy sync failed: {e}"); } - }); - - futures::future::join_all(syncs).await; + } Ok(()) } @@ -1799,9 +1702,9 @@ async fn stage_event_on_relay( ) -> Result<(), String> { client .add_relay(relay) + .and_connect() .await .map_err(|e| format!("could not add relay {relay}: {e}"))?; - client.connect().await; let output = client .send_event(event) diff --git a/crates/signed_state/src/checkouts.rs b/crates/signed_state/src/checkouts.rs index 21758ab..723e978 100644 --- a/crates/signed_state/src/checkouts.rs +++ b/crates/signed_state/src/checkouts.rs @@ -3,16 +3,15 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::Error; -use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; +use gpui::{App, AppContext, Context, Entity, Global, Subscription}; use nostr::prelude::*; use settings::{CheckoutRecord, SettingsStore}; use signed_core::{Announcement, RepoAddr}; use crate::backend::{Backend, BackendEvent}; use crate::git_store::GitStore; -use crate::local_repos::LocalReposStore; use crate::refresh::{RefreshGate, RefreshRequest}; -use crate::repo_list::RepoListStore; +use crate::repos::{LocalReposStore, RepoListStore}; /// Delay between a refresh request and the actual re-computation. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); @@ -105,7 +104,6 @@ pub struct CheckoutsStore { /// The local pass runs a full pass again once this is older than the /// reconciliation cadence, so remote moves still land. last_full_sync: Option, - tasks: Vec>>, _subscriptions: Vec, } @@ -155,7 +153,16 @@ impl CheckoutsStore { })); } - let mut store = Self { + if !cfg!(target_arch = "wasm32") { + let weak = cx.entity().downgrade(); + cx.defer(move |cx| { + if let Err(error) = weak.update(cx, |this, cx| this.refresh(cx)) { + log::warn!("checkouts store dropped before initial refresh could run: {error}"); + } + }); + } + + Self { by_repo: HashMap::new(), statuses: HashMap::new(), status_requested: HashSet::new(), @@ -165,23 +172,8 @@ impl CheckoutsStore { refresh: RefreshGate::default(), local_pending: false, last_full_sync: None, - tasks: Vec::new(), _subscriptions: subscriptions, - }; - - if !cfg!(target_arch = "wasm32") { - store.refresh(cx); } - - store - } - - /// Track a spawned task, pruning finished tasks first. - /// - /// Keeps the store's task list bounded by the number of in-flight tasks. - fn push_task(&mut self, task: Task>) { - self.tasks.retain(|task| !task.is_ready()); - self.tasks.push(task); } /// Remember a successful local-checkout use. @@ -302,12 +294,11 @@ impl CheckoutsStore { return; } - let task = cx.spawn(async move |this, cx| { + cx.spawn(async move |this, cx| { cx.background_executor().timer(REFRESH_DEBOUNCE).await; this.update(cx, |this, cx| this.run_refresh(cx)) - }); - - self.push_task(task); + }) + .detach(); } /// One full resolve and apply cycle, the debounced entry point. @@ -388,7 +379,7 @@ impl CheckoutsStore { Ok::<_, Error>((associations, statuses, push_statuses)) }); - self.push_task(cx.spawn(async move |this, cx| { + cx.spawn(async move |this, cx| { let (associations, statuses, push_statuses) = match work.await { Ok(results) => results, Err(_) => { @@ -435,7 +426,8 @@ impl CheckoutsStore { })?; Ok(()) - })); + }) + .detach(); } /// Schedule the fast local status pass, unless one is already pending. @@ -449,15 +441,14 @@ impl CheckoutsStore { } self.local_pending = true; - let task = cx.spawn(async move |this, cx| { + cx.spawn(async move |this, cx| { cx.background_executor().timer(LOCAL_POLL).await; this.update(cx, |this, cx| { this.local_pending = false; this.local_tick(cx); }) - }); - - self.push_task(task); + }) + .detach(); } /// The fast local status pass. @@ -525,7 +516,7 @@ impl CheckoutsStore { Ok::<_, Error>((statuses, push_statuses)) }); - self.push_task(cx.spawn(async move |this, cx| { + let task: gpui::Task> = cx.spawn(async move |this, cx| { let Ok((statuses, push_statuses)) = work.await else { // Git reads are best-effort, keep the last results. return Ok(()); @@ -550,7 +541,8 @@ impl CheckoutsStore { })?; Ok(()) - })); + }); + task.detach(); } } diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 9d90403..f7b4bcc 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -1,11 +1,10 @@ mod backend; mod checkouts; mod git_store; -mod local_repos; mod profile; mod refresh; mod repo; -mod repo_list; +mod repos; use std::path::{Path, PathBuf}; @@ -13,11 +12,10 @@ pub use backend::{Backend, BackendEvent, user_grasp_list_servers}; pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout}; pub use git_store::GitStore; use gpui::{App, AppContext, Entity}; -pub use local_repos::LocalReposStore; pub use nostr_sdk::prelude::Timestamp; pub use profile::{Profile, ProfileStore}; pub use repo::RepoStore; -pub use repo_list::{RepoActivityCounts, RepoListStore}; +pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore}; use signed_nostr::new_backend; /// Initialize the backend and stores, and install them as globals. diff --git a/crates/signed_state/src/local_repos.rs b/crates/signed_state/src/local_repos.rs deleted file mode 100644 index 776943d..0000000 --- a/crates/signed_state/src/local_repos.rs +++ /dev/null @@ -1,104 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use anyhow::Error; -use gpui::{App, AppContext, Context, Entity, Global, Task}; -use signed_git::find_git_repos; - -struct GlobalLocalReposStore(Entity); - -impl Global for GlobalLocalReposStore {} - -/// Store of the git repositories discovered under a set of scan paths. -pub struct LocalReposStore { - /// The directories being scanned. - pub roots: Arc>, - /// Git repositories discovered under [`Self::roots`], sorted by path. - pub repos: Arc>, - /// A scan is currently running. - pub scanning: bool, - /// A scan was requested while one was already running. - scan_dirty: bool, - tasks: Vec>>, -} - -impl LocalReposStore { - /// Retrieve the global local-repositories store. - pub fn global(cx: &App) -> Entity { - cx.global::().0.clone() - } - - pub(crate) fn set_global(entity: Entity, cx: &mut App) { - cx.set_global(GlobalLocalReposStore(entity)); - } - - /// Create a store scanning `roots` right away. - pub fn new(roots: Vec, cx: &mut Context) -> Self { - let mut store = Self { - roots: Arc::new(roots), - repos: Arc::new(Vec::new()), - scanning: false, - scan_dirty: false, - tasks: Vec::new(), - }; - store.rescan(cx); - store - } - - /// Forget a repository that has just been published to NIP-34. - pub fn remove(&mut self, path: &Path, cx: &mut Context) { - self.repos = Arc::new( - self.repos - .iter() - .filter(|repo| repo.as_path() != path) - .cloned() - .collect(), - ); - cx.notify(); - } - - /// Re-run the scan. - pub fn rescan(&mut self, cx: &mut Context) { - if self.scanning { - self.scan_dirty = true; - return; - } - if self.roots.is_empty() { - return; - } - - self.scanning = true; - cx.notify(); - - let roots = self.roots.clone(); - let work = cx.background_spawn(async move { - let mut repos = Vec::new(); - for root in roots.iter() { - repos.extend(find_git_repos(root)); - } - repos.sort(); - repos.dedup(); - repos - }); - - self.tasks.push(cx.spawn(async move |this, cx| { - let repos = work.await; - let again = this.update(cx, |this, cx| { - this.repos = Arc::new(repos); - this.scanning = false; - cx.notify(); - - let dirty = this.scan_dirty; - this.scan_dirty = false; - dirty - })?; - - // Scans requested while this one ran are coalesced into one follow-up scan. - if again { - this.update(cx, |this, cx| this.rescan(cx))?; - } - - Ok(()) - })); - } -} diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index c2d74ab..a525282 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -75,7 +75,6 @@ pub struct ProfileStore { seen: RefCell>, /// Sender for queuing fetch requests, batched by a background task. sender: Sender, - tasks: Vec>>, _subscription: Subscription, } @@ -97,8 +96,13 @@ impl ProfileStore { let backend = Backend::global(cx); let subscription = cx.subscribe(&backend, |this, _backend, event, cx| match event { - BackendEvent::NostrUpdate(update) if update.kind == Kind::Metadata => { - this.apply_author(update.author, cx); + BackendEvent::NostrUpdate(updates) => { + for update in updates + .iter() + .filter(|update| update.kind == Kind::Metadata) + { + this.apply_author(update.author, cx); + } } BackendEvent::Published(event) if event.kind == Kind::Metadata => { let metadata = Metadata::from_json(&event.content).unwrap_or_default(); @@ -114,30 +118,24 @@ impl ProfileStore { let (sender, receiver) = flume::unbounded::(); let entity = cx.entity().downgrade(); - let mut tasks = Vec::new(); - - tasks.push(cx.spawn(async move |_this, cx| { + cx.spawn(async move |_this, cx| { Self::handle_requests(entity, &client, &receiver, cx).await - })); + }) + .detach(); - let mut store = Self { + let weak = cx.entity().downgrade(); + cx.defer(move |cx| { + if let Err(error) = weak.update(cx, |this, cx| this.load(cx)) { + log::warn!("profile store dropped before initial load could run: {error}"); + } + }); + + Self { profiles: HashMap::new(), seen: RefCell::new(HashSet::new()), sender, - tasks, _subscription: subscription, - }; - - store.load(cx); - store - } - - /// Track a spawned task, pruning finished tasks first. - /// - /// Keeps the store's task list bounded by the number of in-flight tasks. - fn push_task(&mut self, task: Task>) { - self.tasks.retain(|task| !task.is_ready()); - self.tasks.push(task); + } } /// Get a profile. @@ -181,7 +179,7 @@ impl ProfileStore { Ok::<_, Error>(profiles) }); - self.push_task(cx.spawn(async move |this, cx| { + let task: Task> = cx.spawn(async move |this, cx| { let profiles = work.await?; this.update(cx, |this, cx| { @@ -192,7 +190,8 @@ impl ProfileStore { })?; Ok(()) - })); + }); + task.detach(); } /// Re-read the latest metadata of an author from the local database. @@ -217,7 +216,7 @@ impl ProfileStore { Ok::<_, Error>(profile) }); - self.push_task(cx.spawn(async move |this, cx| { + let task: Task> = cx.spawn(async move |this, cx| { let profile = work.await?; this.update(cx, |this, cx| { @@ -228,7 +227,8 @@ impl ProfileStore { })?; Ok(()) - })); + }); + task.detach(); } /// Re-read the latest metadata of every requested author from the local database. @@ -273,7 +273,7 @@ impl ProfileStore { Ok::<_, Error>(profiles) }); - self.push_task(cx.spawn(async move |this, cx| { + let task: Task> = cx.spawn(async move |this, cx| { let profiles = work.await?; this.update(cx, |this, cx| { @@ -284,7 +284,8 @@ impl ProfileStore { })?; Ok(()) - })); + }); + task.detach(); } /// Sync metadata for requested authors in batches, debounced to collect requests. @@ -337,7 +338,7 @@ impl ProfileStore { // Re-apply from the database afterwards. match sync_bootstrap_only(client, filter, SyncOptions::default()).await { Ok(_) => { - let _ = this.update(cx, |this, cx| this.apply_seen(cx)); + this.update(cx, |this, cx| this.apply_seen(cx)).ok(); } Err(e) => log::warn!("profile sync failed: {e}"), } diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index e2d2e1d..2bd907b 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::time::Duration; -use anyhow::Error; +use anyhow::{Error, bail}; use bitcoin_hashes::sha1::Hash as Sha1Hash; use gpui::{App, AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity}; use nostr::event::IntoEventBuilder; @@ -14,12 +14,13 @@ use signed_core::{ }; use crate::backend::{ - Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, user_grasp_list_servers, + Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, require_relay_accepted, + user_grasp_list_servers, }; use crate::checkouts::CheckoutsStore; use crate::git_store::GitStore; use crate::refresh::{RefreshGate, RefreshRequest}; -use crate::repo_list::RepoListStore; +use crate::repos::RepoListStore; /// Delay between a refresh request and the actual re-query. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); @@ -81,7 +82,6 @@ pub struct RepoStore { root_fetches: HashSet, /// Refresh coalescing, see [`RefreshGate`]. refresh: RefreshGate, - tasks: Vec>>, _subscription: Subscription, } @@ -91,7 +91,7 @@ impl RepoStore { let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { let relevant = match event { - BackendEvent::NostrUpdate(update) => { + BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| { // Deletions may target any event of this repository. let deletion = update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish; @@ -107,7 +107,7 @@ impl RepoStore { let status = RepoStatus::from_kind(update.kind).is_some(); deletion || coordinate || (author && kind) || comment || status - } + }), BackendEvent::Published(event) => { let kind = event.kind == Kind::GitRepoAnnouncement; let author = event.pubkey == this.addr.public_key; @@ -127,7 +127,20 @@ impl RepoStore { } }); - let mut store = Self { + let weak = cx.entity().downgrade(); + cx.defer(move |cx| { + let result = weak.update(cx, |this, cx| { + this.subscribe_remote(cx); + this.connect_announced_relays(&announced_relays, cx); + this.refresh(cx); + }); + + if let Err(error) = result { + log::warn!("repo store dropped before bootstrap could run: {error}"); + } + }); + + Self { addr, announcement: None, head: None, @@ -148,16 +161,7 @@ impl RepoStore { root_fetches: HashSet::new(), refresh: RefreshGate::default(), _subscription: subscription, - tasks: Vec::new(), - }; - - store.subscribe_remote(cx); - // The announcement we opened the repo from may already list its relays. - // Connect to them right away. - // Do not wait for the bootstrap fetch to return the same event. - store.connect_announced_relays(&announced_relays, cx); - store.refresh(cx); - store + } } /// Returns the repository's address. @@ -229,14 +233,12 @@ impl RepoStore { return; } - let task = cx.spawn(async move |this, cx| { + cx.spawn(async move |this, cx| { cx.background_executor().timer(REFRESH_DEBOUNCE).await; this.update(cx, |this, cx| this.run_refresh(cx)) - }); - - self.tasks.retain(|task| !task.is_ready()); - self.tasks.push(task); + }) + .detach(); } fn run_refresh(&mut self, cx: &mut Context) { @@ -372,9 +374,7 @@ impl RepoStore { )) }); - self.tasks.retain(|task| !task.is_ready()); - - self.tasks.push(cx.spawn(async move |this, cx| { + cx.spawn(async move |this, cx| { let ( announcement, state, @@ -466,7 +466,8 @@ impl RepoStore { } Ok(()) - })); + }) + .detach(); } /// Resolve the status of a root event, an issue, patch or PR, per NIP-34. @@ -514,7 +515,7 @@ impl RepoStore { } .into_event_builder(); - self.send(builder, cx); + self.publish(builder, cx); } /// Comments on a root event, an issue or PR, oldest first. @@ -545,7 +546,7 @@ impl RepoStore { .and_then(|a| a.relays.first()) .cloned(); - self.send( + self.publish( comment_builder(root, parent, relay_hint.as_ref(), &self.addr, content), cx, ); @@ -638,7 +639,7 @@ impl RepoStore { .collect() }; - self.tasks.push(cx.spawn(async move |this, cx| { + cx.spawn(async move |this, cx| { // The PR references the root patch event. // Viewers can then find the patch without carrying it inline. let root_patch = match publish_patch_series( @@ -798,12 +799,15 @@ impl RepoStore { } } - let publish_task = this.update(cx, |_this, cx| { - let backend = Backend::global(cx); - backend.update(cx, |backend, cx| backend.publish_event(event, cx)) - })?; + let client = this.update(cx, |_this, cx| Backend::global(cx).read(cx).client())?; - let pr_event = match publish_task.await { + let publish_result: Result = async { + let output = client.send_event(&event).broadcast().await?; + require_relay_accepted(output, event) + } + .await; + + let pr_event = match publish_result { Ok(event) => event, Err(e) => { return this.update(cx, |this, cx| { @@ -813,6 +817,11 @@ impl RepoStore { } }; + this.update(cx, |_this, cx| { + Backend::global(cx) + .update(cx, |backend, cx| backend.announce_published(pr_event.clone(), cx)) + })?; + // A draft PR carries a kind-1633 status event, NIP-34. // Publish it right after the PR event so viewers never show it open. if draft { @@ -822,7 +831,61 @@ impl RepoStore { } Ok(()) - })); + }) + .detach(); + } + + /// Generate the patch between `merge_base` and `compare_ref` in `repo_path`, + /// then open a pull request from it. + /// + /// Fails descriptively when there are no commits to propose or the patch + /// could not be generated; otherwise publishes exactly like + /// [`Self::open_pull_request`]. + #[allow(clippy::too_many_arguments)] + pub fn open_pull_request_from_refs( + &mut self, + repo_path: PathBuf, + merge_base: String, + compare_ref: String, + subject: Option, + description: String, + branch_name: Option, + draft: bool, + cx: &mut Context, + ) -> Task> { + cx.spawn(async move |this, cx| { + // Regenerate the series at submit time. + // The published patch covers the current tip of the compare branch. + let patch = cx + .background_spawn({ + let repo_path = repo_path.clone(); + let merge_base = merge_base.clone(); + let compare_ref = compare_ref.clone(); + async move { + signed_git::format_patch_between(&repo_path, &merge_base, &compare_ref) + } + }) + .await; + + let patch = match patch { + Ok(patch) if !patch.is_empty() => patch, + Ok(_) => bail!("No commits between the branches to propose"), + Err(error) => bail!("Failed to generate the patch: {error}"), + }; + + this.update(cx, |this, cx| { + this.open_pull_request( + subject, + description, + branch_name, + patch, + draft, + Some(merge_base), + Some(repo_path), + cx, + ); + }) + }) } /// Update a pull request. @@ -894,7 +957,7 @@ impl RepoStore { .map(|a| a.clone.clone()) .unwrap_or_default(); - self.tasks.push(cx.spawn(async move |this, cx| { + cx.spawn(async move |this, cx| { if let Err(e) = publish_patch_series( &this, cx, @@ -913,7 +976,7 @@ impl RepoStore { }); } - let update_task = this.update(cx, |this, cx| { + let builder = this.update(cx, |this, _cx| { let builder = GitPullRequestUpdate { repository: this.addr.clone(), pull_request_event: root.id, @@ -926,24 +989,44 @@ impl RepoStore { // The `r` EUC tag lets clients subscribe to all PR updates. // The SDK builder omits it. - let builder = match euc.as_deref() { + match euc.as_deref() { Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")), None => builder, - }; - - let backend = Backend::global(cx); - backend.update(cx, |backend, cx| backend.send(builder, cx)) + } })?; - if let Err(e) = update_task.await { - return this.update(cx, |this, cx| { - this.last_error = Some(e.to_string()); - cx.notify(); - }); + let (client, signer) = this.update(cx, |_this, cx| { + let backend = Backend::global(cx); + let backend = backend.read(cx); + (backend.client(), backend.signer()) + })?; + + let publish_result: Result = async { + let event = builder.finalize_async(&signer).await?; + let output = client.send_event(&event).broadcast().await?; + require_relay_accepted(output, event) + } + .await; + + match publish_result { + Ok(event) => { + this.update(cx, |_this, cx| { + Backend::global(cx).update(cx, |backend, cx| { + backend.announce_published(event.clone(), cx) + }) + })?; + } + Err(e) => { + return this.update(cx, |this, cx| { + this.last_error = Some(e.to_string()); + cx.notify(); + }); + } } Ok(()) - })); + }) + .detach(); } /// Set the status of a root event. @@ -982,7 +1065,7 @@ impl RepoStore { Tag::coordinate(self.addr.clone(), None), ]); - self.send(builder, cx); + self.publish(builder, cx); } /// Merge a pull request. @@ -1002,10 +1085,10 @@ impl RepoStore { let cache = GitStore::global(cx).cache().clone(); let addr = self.addr.clone(); - let clone_urls: Vec = self + let clone_urls: Vec = self .announcement .as_ref() - .map(|a| a.clone.iter().map(ToString::to_string).collect()) + .map(|a| a.clone.clone()) .unwrap_or_default(); let patch = pull_request_patch(root, self.patches.iter()); @@ -1040,7 +1123,7 @@ impl RepoStore { Ok::<_, Error>(applied) }); - self.tasks.push(cx.spawn(async move |this, cx| { + let task: Task> = cx.spawn(async move |this, cx| { match apply.await { Ok(applied) => { this.update(cx, |this, cx| { @@ -1062,7 +1145,8 @@ impl RepoStore { } } Ok(()) - })); + }); + task.detach(); } /// The latest announcement of this repository, @@ -1224,7 +1308,7 @@ impl RepoStore { return self.action_error("Repository announcement is not loaded yet", cx); }; - let clone_urls: Vec = announcement.clone.iter().map(ToString::to_string).collect(); + let clone_urls = announcement.clone.clone(); let addr = self.addr.clone(); self.cloning = true; @@ -1328,24 +1412,50 @@ impl RepoStore { } } - self.send(EventBuilder::new(Kind::GitStatusApplied, "").tags(tags), cx); + self.publish(EventBuilder::new(Kind::GitStatusApplied, "").tags(tags), cx); } - fn send(&mut self, builder: EventBuilder, cx: &mut Context) { + /// Sign `builder`, broadcast it and track the outcome in [`Self::last_error`]. + /// + /// Every one-shot repository event (issue, comment, status) goes through + /// this. Multi-step flows (opening or updating a pull request, a patch + /// series) call the SDK directly instead, since their error handling and + /// post-conditions differ per step. + fn publish(&mut self, builder: EventBuilder, cx: &mut Context) { self.last_error = None; let backend = Backend::global(cx); - let task = backend.update(cx, |backend, cx| backend.send(builder, cx)); + let (client, signer) = { + let backend = backend.read(cx); + (backend.client(), backend.signer()) + }; - self.tasks.push(cx.spawn(async move |this, cx| { - if let Err(e) = task.await { - this.update(cx, |this, cx| { - this.last_error = Some(e.to_string()); - cx.notify(); - })?; + let task: Task> = cx.spawn(async move |this, cx| { + let publish_result: Result = async { + let event = builder.finalize_async(&signer).await?; + let output = client.send_event(&event).broadcast().await?; + require_relay_accepted(output, event) } + .await; + + match publish_result { + Ok(event) => { + this.update(cx, |_this, cx| { + Backend::global(cx) + .update(cx, |backend, cx| backend.announce_published(event, cx)) + })?; + } + Err(e) => { + this.update(cx, |this, cx| { + this.last_error = Some(e.to_string()); + cx.notify(); + })?; + } + } + Ok(()) - })); + }); + task.detach(); } } @@ -1426,6 +1536,12 @@ async fn publish_patch_series( first_marker: &str, reply_to: Option, ) -> Result { + let (client, signer) = this.update(cx, |_this, cx| { + let backend = Backend::global(cx); + let backend = backend.read(cx); + (backend.client(), backend.signer()) + })?; + let mut root: Option = None; let mut previous = reply_to; @@ -1466,11 +1582,14 @@ async fn publish_patch_series( let builder = EventBuilder::new(Kind::GitPatch, part.clone()).tags(tags); - let task = this.update(cx, |_this, cx| { - let backend = Backend::global(cx); - backend.update(cx, |backend, cx| backend.send(builder, cx)) + let event = builder.finalize_async(&signer).await?; + let output = client.send_event(&event).broadcast().await?; + let event = require_relay_accepted(output, event)?; + this.update(cx, |_this, cx| { + Backend::global(cx).update(cx, |backend, cx| { + backend.announce_published(event.clone(), cx) + }) })?; - let event = task.await?; if root.is_none() { root = Some(event.clone()); diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repos.rs similarity index 73% rename from crates/signed_state/src/repo_list.rs rename to crates/signed_state/src/repos.rs index 58a4efd..b4a88f7 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repos.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -6,10 +7,116 @@ use anyhow::Error; use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; use nostr_sdk::prelude::*; use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr}; +use signed_git::find_git_repos; use crate::backend::{Backend, BackendEvent}; use crate::refresh::{RefreshGate, RefreshRequest}; +struct GlobalLocalReposStore(Entity); + +impl Global for GlobalLocalReposStore {} + +/// Store of the git repositories discovered under a set of scan paths. +pub struct LocalReposStore { + /// The directories being scanned. + pub roots: Arc>, + /// Git repositories discovered under [`Self::roots`], sorted by path. + pub repos: Arc>, + /// A scan is currently running. + pub scanning: bool, + /// A scan was requested while one was already running. + scan_dirty: bool, +} + +impl LocalReposStore { + /// Retrieve the global local-repositories store. + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + pub(crate) fn set_global(entity: Entity, cx: &mut App) { + cx.set_global(GlobalLocalReposStore(entity)); + } + + /// Create a store scanning `roots` right away. + pub fn new(roots: Vec, cx: &mut Context) -> Self { + let weak = cx.entity().downgrade(); + cx.defer(move |cx| { + if let Err(error) = weak.update(cx, |this, cx| this.rescan(cx)) { + log::warn!("local repos store dropped before initial scan could run: {error}"); + } + }); + + Self { + roots: Arc::new(roots), + repos: Arc::new(Vec::new()), + scanning: false, + scan_dirty: false, + } + } + + /// Forget a repository that has just been published to NIP-34. + pub fn remove(&mut self, path: &Path, cx: &mut Context) { + self.repos = Arc::new( + self.repos + .iter() + .filter(|repo| repo.as_path() != path) + .cloned() + .collect(), + ); + cx.notify(); + } + + /// Re-run the scan. + pub fn rescan(&mut self, cx: &mut Context) { + if self.scanning { + self.scan_dirty = true; + return; + } + + if self.roots.is_empty() { + return; + } + + self.scanning = true; + cx.notify(); + + let roots = self.roots.clone(); + + let work = cx.background_spawn(async move { + let mut repos = Vec::new(); + for root in roots.iter() { + repos.extend(find_git_repos(root)); + } + repos.sort(); + repos.dedup(); + repos + }); + + let task: Task> = cx.spawn(async move |this, cx| { + let repos = work.await; + let again = this.update(cx, |this, cx| { + this.repos = Arc::new(repos); + this.scanning = false; + cx.notify(); + + let dirty = this.scan_dirty; + this.scan_dirty = false; + dirty + })?; + + // Scans requested while this one ran are coalesced into one follow-up scan. + if again { + this.update(cx, |this, cx| this.rescan(cx))?; + } + + Ok(()) + }); + + task.detach(); + } +} + /// Delay between a refresh request and the actual re-query. /// /// Bursts of events, e.g. sync progress ticks, collapse into one query. @@ -55,7 +162,6 @@ pub struct RepoListStore { pub counts: Arc>, /// Refresh coalescing, see [`RefreshGate`]. refresh: RefreshGate, - tasks: Vec>>, _subscription: Subscription, } @@ -75,7 +181,7 @@ impl RepoListStore { let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { let relevant = match event { - BackendEvent::NostrUpdate(update) => { + BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| { // Deletions may target anything we list, always refresh. if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish { true @@ -88,7 +194,7 @@ impl RepoListStore { let is_repo_state = update.kind == Kind::RepoState; is_announcement || is_repo_state } - } + }), BackendEvent::Published(event) => { let announcement = event.kind == Kind::GitRepoAnnouncement; @@ -99,7 +205,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, }; @@ -108,20 +217,26 @@ impl RepoListStore { } }); - let mut store = Self { + let weak = cx.entity().downgrade(); + cx.defer(move |cx| { + let result = weak.update(cx, |this, cx| { + this.subscribe_remote(cx); + // Query the local database right away. + // The list never waits for the relay syncs started above to finish. + this.refresh_initial(cx); + }); + if let Err(error) = result { + log::warn!("repo list store dropped before bootstrap could run: {error}"); + } + }); + + Self { announcements: Arc::new(Vec::new()), last_activity: Arc::new(HashMap::new()), counts: Arc::new(HashMap::new()), refresh: RefreshGate::default(), _subscription: subscription, - tasks: Vec::new(), - }; - - store.subscribe_remote(cx); - // Query the local database right away. - // The list never waits for the relay syncs started above to finish. - store.refresh_initial(cx); - store + } } /// The announcements of `user`, newest first. @@ -133,14 +248,6 @@ impl RepoListStore { .collect() } - /// Track a spawned task, pruning finished tasks first. - /// - /// Keeps the store's task list bounded by the number of in-flight tasks. - fn push_task(&mut self, task: Task>) { - self.tasks.retain(|task| !task.is_ready()); - self.tasks.push(task); - } - /// Negentropy-sync announcements with the bootstrap relays. fn subscribe_remote(&mut self, cx: &mut Context) { let backend = Backend::global(cx); @@ -171,13 +278,11 @@ impl RepoListStore { return; } - let task = cx.spawn(async move |this, cx| { + cx.spawn(async move |this, cx| { cx.background_executor().timer(REFRESH_DEBOUNCE).await; - this.update(cx, |this, cx| this.run_refresh(cx)) - }); - - self.push_task(task); + }) + .detach(); } /// One query and apply cycle, the debounced entry point. @@ -294,7 +399,7 @@ impl RepoListStore { Ok::<_, Error>((announcements, last_activity, counts)) }); - self.push_task(cx.spawn(async move |this, cx| { + cx.spawn(async move |this, cx| { let (announcements, last_activity, counts) = match work.await { Ok(results) => results, // Database errors are transient, keep the last list. @@ -321,6 +426,7 @@ impl RepoListStore { } Ok(()) - })); + }) + .detach(); } } diff --git a/crates/workspace/src/views/repo_detail/diff.rs b/crates/workspace/src/views/repo_detail/diff.rs index a03da9b..bcd6474 100644 --- a/crates/workspace/src/views/repo_detail/diff.rs +++ b/crates/workspace/src/views/repo_detail/diff.rs @@ -325,8 +325,6 @@ pub struct CommitDiffView { error: Option, /// Changed-files explorer and per-file diff, also used by the new PR panel's compare view. pane: Entity, - /// In-flight tasks, pruned on every push. - tasks: Vec>>, } impl CommitDiffView { @@ -358,7 +356,6 @@ impl CommitDiffView { loading: true, error: None, pane, - tasks: Vec::new(), } } @@ -371,42 +368,43 @@ impl CommitDiffView { let worktree = self.worktree.clone(); let id = self.commit.id.clone(); - let task = cx.spawn_in(window, async move |this, cx| { - let commit = cx - .background_spawn({ - let worktree = worktree.clone(); - let id = id.clone(); - async move { signed_git::worktree_commit(&worktree, &id) } - }) - .await; - let diff = cx - .background_spawn({ - let worktree = worktree.clone(); - let id = id.clone(); - async move { signed_git::worktree_commit_diff(&worktree, &id) } - }) - .await; + let task: gpui::Task> = + cx.spawn_in(window, async move |this, cx| { + let commit = cx + .background_spawn({ + let worktree = worktree.clone(); + let id = id.clone(); + async move { signed_git::worktree_commit(&worktree, &id) } + }) + .await; + let diff = cx + .background_spawn({ + let worktree = worktree.clone(); + let id = id.clone(); + async move { signed_git::worktree_commit_diff(&worktree, &id) } + }) + .await; - this.update_in(cx, |this, _window, cx| { - this.loading = false; - if let Ok(Some(commit)) = commit { - this.commit = commit; - } - match diff { - Ok(diff) => { - this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx)); + this.update_in(cx, |this, _window, cx| { + this.loading = false; + if let Ok(Some(commit)) = commit { + this.commit = commit; } - Err(error) => { - this.error = Some(error.to_string().into()); + match diff { + Ok(diff) => { + this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx)); + } + Err(error) => { + this.error = Some(error.to_string().into()); + } } - } - cx.notify(); - })?; + cx.notify(); + })?; - Ok(()) - }); + Ok(()) + }); - self.tasks.push(task); + task.detach(); } /// Header with the commit id, summary, author/time and overall change stats. diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 0446d16..8f4f4dc 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -10,8 +10,8 @@ use gix::Repository; use gpui::prelude::*; use gpui::{ Action, Anchor, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, - Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, Task, - WeakEntity, Window, div, px, relative, size, transparent_white, + Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, WeakEntity, + Window, div, px, relative, size, transparent_white, }; use gpui_base::{Button as BaseButton, Disableable, Popover}; use gpui_component::alert::Alert; @@ -24,7 +24,7 @@ use gpui_component::{ ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, VirtualListScrollHandle, h_flex, v_flex, }; -use nostr::prelude::{RelayUrl, ToBech32}; +use nostr::prelude::{RelayUrl, ToBech32, Url}; use signed_core::{Announcement, RepoAddr, RepoStatus, filters}; use signed_git::{CommitList, FileCommit}; use signed_state::{ @@ -175,9 +175,6 @@ pub struct RepoDetailView { /// Bumped on every branch/tag switch. /// In-flight loads with an older generation are discarded when they complete. ref_generation: u64, - /// In-flight tasks, finished tasks are pruned on every push. - /// The vec stays bounded by the number of concurrent loads. - tasks: Vec>>, /// Subscriptions keeping the selectors' confirm events alive. _subscriptions: Vec, /// `(path, branch)` ready-suggestions dismissed by the user, per panel. @@ -346,7 +343,6 @@ impl RepoDetailView { push_statuses: Vec::new(), pending_upstream: None, focus_handle: cx.focus_handle(), - tasks: Vec::new(), _subscriptions: subscriptions, } } @@ -363,7 +359,7 @@ impl RepoDetailView { // Local repositories live on disk at their scan path. // No clone step or network refresh applies here. if let Some(local_path) = self.local_path.clone() { - let task = cx.spawn_in(window, async move |this, cx| { + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { let data = cx .background_spawn(async move { let repo = gix::open(&local_path)?; @@ -383,7 +379,7 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + task.detach(); return; } @@ -394,7 +390,7 @@ impl RepoDetailView { let cache = GitStore::global(cx).cache().clone(); let addr = initial.addr(); - let clone_urls: Vec = initial.clone.iter().map(ToString::to_string).collect(); + let clone_urls: Vec = initial.clone.clone(); // Captured before the loads start. // A branch/tag switch bumps the generation, discarding the refresh below. @@ -411,7 +407,7 @@ impl RepoDetailView { }) }; - let task = cx.spawn_in(window, async move |this, cx| { + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { let disk = disk.await; let had_clone = matches!(&disk, Ok(Some(_))); @@ -531,7 +527,7 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + task.detach(); } /// Apply the loaded repository data. @@ -619,7 +615,7 @@ impl RepoDetailView { prompt: Some("Clone".into()), }); - let task = cx.spawn_in(window, async move |this, cx| { + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { // `Ok(Ok(Some(paths)))` means the user picked a folder. // A cancel or picker failure resolves to anything else. let picked = match prompt.await { @@ -649,7 +645,7 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + task.detach(); } /// Preview the file at `path`, relative to the worktree root. @@ -703,7 +699,7 @@ impl RepoDetailView { self.load_commit(&path, cx); let generation = self.ref_generation; - let task = cx.spawn_in(window, async move |this, cx| { + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { let path_for_read = path.clone(); let content = cx .background_spawn(async move { @@ -772,7 +768,7 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + task.detach(); } /// Queue `path` for the per-file commit query. @@ -804,7 +800,7 @@ impl RepoDetailView { let paths = std::mem::take(&mut self.pending_commits); let generation = self.ref_generation; - let task = cx.spawn(async move |this, cx| { + let task: gpui::Task> = cx.spawn(async move |this, cx| { let rels: Vec = paths.iter().map(PathBuf::from).collect(); let result = cx .background_spawn( @@ -834,7 +830,7 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + task.detach(); } /// Walk all commits reachable from HEAD on a background task. @@ -852,7 +848,7 @@ impl RepoDetailView { self.loading_all_commits = true; let generation = self.ref_generation; - let task = cx.spawn(async move |this, cx| { + let task: gpui::Task> = cx.spawn(async move |this, cx| { let result = cx .background_spawn(async move { signed_git::worktree_all_commits(&worktree) }) .await; @@ -876,7 +872,7 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + task.detach(); } /// Open a new panel showing the diff of `commit_id`. @@ -909,8 +905,9 @@ impl RepoDetailView { self.error = None; cx.notify(); - self.tasks - .push(store.update(cx, |store, cx| store.push_repository(cx))); + store + .update(cx, |store, cx| store.push_repository(cx)) + .detach(); } /// Push the unpushed commits of the local checkout at `path`. @@ -931,7 +928,7 @@ impl RepoDetailView { self.error = None; cx.notify(); - let task = cx.spawn_in(window, async move |this, cx| { + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { // The store owns the push, its busy flag and error reporting. let push = this.update_in(cx, |_this, _window, cx| { store.update(cx, |store, cx| store.push_checkout(path.clone(), cx)) @@ -948,7 +945,7 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + task.detach(); } /// Delete the repository from nostr, announcement, state and activity. @@ -956,8 +953,9 @@ impl RepoDetailView { let Some(store) = self.store.clone() else { return; }; - self.tasks - .push(store.update(cx, |store, cx| store.delete_repository(cx))); + store + .update(cx, |store, cx| store.delete_repository(cx)) + .detach(); } /// Open the issues list panel in the dock area. @@ -1025,7 +1023,7 @@ impl RepoDetailView { }); self.pending_upstream = Some(addr); - let task = cx.spawn_in(window, async move |this, cx| { + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { for _ in 0..60 { cx.background_executor() .timer(Duration::from_millis(250)) @@ -1060,7 +1058,7 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + task.detach(); } /// Check out `name`, a branch or tag picked in the header. @@ -1101,7 +1099,7 @@ impl RepoDetailView { cx.notify(); let checkout_name = name.clone(); - let task = cx.spawn_in(window, async move |this, cx| { + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { let result = cx .background_spawn(async move { match kind { @@ -1131,7 +1129,7 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + task.detach(); } /// Restore a selector to `previous`, or clear it after a failed switch. @@ -1157,7 +1155,7 @@ impl RepoDetailView { return; }; - let task = cx.spawn(async move |this, cx| { + let task: gpui::Task> = cx.spawn(async move |this, cx| { let result = cx .background_spawn(async move { let snapshot = signed_git::worktree_snapshot(&worktree)?; @@ -1218,7 +1216,7 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + task.detach(); } /// Refresh the file explorer, previews and commit list after the mirror @@ -1233,7 +1231,7 @@ impl RepoDetailView { return; }; - let task = cx.spawn(async move |this, cx| { + let task: gpui::Task> = cx.spawn(async move |this, cx| { let result = cx .background_spawn(async move { let snapshot = signed_git::worktree_snapshot(&worktree)?; @@ -1314,7 +1312,7 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + task.detach(); } /// Drop the cached preview, editor and commit state of `path`. diff --git a/crates/workspace/src/views/repo_detail/new_pull_request.rs b/crates/workspace/src/views/repo_detail/new_pull_request.rs index 6512ac4..b2b4a07 100644 --- a/crates/workspace/src/views/repo_detail/new_pull_request.rs +++ b/crates/workspace/src/views/repo_detail/new_pull_request.rs @@ -6,8 +6,7 @@ use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handl use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, PathPromptOptions, - Pixels, Render, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, relative, - size, + Pixels, Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size, }; use gpui_base::{Button as BaseButton, StyledExt}; use gpui_component::button::{Button, ButtonVariants}; @@ -22,10 +21,10 @@ use gpui_component::{ v_virtual_list, }; use nostr::prelude::*; -use signed_core::{Announcement, RepoAddr}; +use signed_core::{Announcement, RepoAddr, fork_candidates}; use signed_git::{ - delete_refs_with_prefix, fetch_repo_refs, format_patch_between, merge_base, refs_with_prefix, - sanitize_path_component, worktree_commit_range_commits, worktree_commit_range_diff, + delete_refs_with_prefix, fetch_repo_refs, fork_namespace, merge_base, refs_with_prefix, + worktree_commit_range_commits, worktree_commit_range_diff, }; use signed_state::{Backend, CheckoutsStore, GitStore, RepoListStore, RepoStore}; use signed_ui::{CountBadge, placeholder}; @@ -79,7 +78,6 @@ pub struct NewPullRequestView { scroll_handle: VirtualListScrollHandle, item_sizes: Rc>>, _subscriptions: Vec, - tasks: Vec>>, } /// A fork-backed compare. @@ -104,36 +102,6 @@ impl ForkCompare { } } -/// The refs namespace of a fork's import in the target mirror. -fn fork_namespace(announcement: &Announcement) -> String { - format!( - "{}/{}", - announcement.owner.to_hex(), - sanitize_path_component(&announcement.id) - ) -} - -/// The announced forks of `base` a New PR compare can be built from. -fn fork_candidates<'a>( - announcements: &'a [Announcement], - base: &RepoAddr, - base_euc: Option<&str>, - user: Option, -) -> Vec<&'a Announcement> { - let (mut own, mut others) = (Vec::new(), Vec::new()); - for announcement in announcements { - if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) { - continue; - } - if Some(announcement.owner) == user { - own.push(announcement); - } else { - others.push(announcement); - } - } - own.into_iter().chain(others).collect() -} - /// The display name of an announcement. /// /// Its human-readable name, falling back to the repository id. @@ -363,7 +331,6 @@ impl NewPullRequestView { scroll_handle: VirtualListScrollHandle::new(), item_sizes: Rc::new(Vec::new()), _subscriptions: subscriptions, - tasks: Vec::new(), }; // Prefill with the store's freshest associated checkout, no folder dialog. @@ -426,25 +393,26 @@ impl NewPullRequestView { prompt: Some("Choose local checkout".into()), }); - let task = cx.spawn_in(window, async move |this, cx| { - // `Ok(Ok(Some(paths)))` means the user picked a folder. - // A cancel or picker failure resolves to anything else. - let picked = match prompt.await { - Ok(Ok(Some(mut paths))) => paths.pop(), - _ => None, - }; + let task: gpui::Task> = + cx.spawn_in(window, async move |this, cx| { + // `Ok(Ok(Some(paths)))` means the user picked a folder. + // A cancel or picker failure resolves to anything else. + let picked = match prompt.await { + Ok(Ok(Some(mut paths))) => paths.pop(), + _ => None, + }; - let Some(path) = picked else { - return Ok(()); - }; + let Some(path) = picked else { + return Ok(()); + }; - this.update_in(cx, |this, window, cx| { - this.apply_folder_path(path, window, cx); - })?; + this.update_in(cx, |this, window, cx| { + this.apply_folder_path(path, window, cx); + })?; - Ok(()) - }); - self.tasks.push(task); + Ok(()) + }); + task.detach(); } /// Apply `path` as the local checkout, no picker. @@ -453,28 +421,29 @@ impl NewPullRequestView { fn apply_folder_path(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context) { let path = path.to_string_lossy().to_string(); - let task = cx.spawn_in(window, async move |this, cx| { - // Branches and the current branch are read off the UI thread. - let info = cx - .background_spawn({ - let path = path.clone(); - async move { - let repo = gix::open(Path::new(&path)).ok()?; - let branches = - signed_git::worktree_branches(Path::new(&path)).unwrap_or_default(); - let current = signed_git::current_branch(&repo).ok().flatten(); - Some((branches, current)) - } - }) - .await; + let task: gpui::Task> = + cx.spawn_in(window, async move |this, cx| { + // Branches and the current branch are read off the UI thread. + let info = cx + .background_spawn({ + let path = path.clone(); + async move { + let repo = gix::open(Path::new(&path)).ok()?; + let branches = + signed_git::worktree_branches(Path::new(&path)).unwrap_or_default(); + let current = signed_git::current_branch(&repo).ok().flatten(); + Some((branches, current)) + } + }) + .await; - this.update_in(cx, |this, window, cx| { - this.apply_checkout(path, info, window, cx); - })?; + this.update_in(cx, |this, window, cx| { + this.apply_checkout(path, info, window, cx); + })?; - Ok(()) - }); - self.tasks.push(task); + Ok(()) + }); + task.detach(); } /// Apply a picked checkout, filling the selectors and loading the compare. @@ -592,14 +561,14 @@ impl NewPullRequestView { let cache = GitStore::global(cx).cache().clone(); let mirror_path = cache.repo_path(&base); let namespace = fork_namespace(&announcement); - let clone_urls: Vec = announcement.clone.iter().map(ToString::to_string).collect(); + let clone_urls = announcement.clone.clone(); - let base_clone_urls: Vec = self + let base_clone_urls: Vec = self .store .read(cx) .announcement .as_ref() - .map(|a| a.clone.iter().map(ToString::to_string).collect()) + .map(|a| a.clone.clone()) .unwrap_or_default(); // Keep the current compare and base when the fork is already applied. @@ -615,88 +584,89 @@ impl NewPullRequestView { self.error = None; cx.notify(); - let task = cx.spawn_in(window, async move |this, cx| { - // The fork and base must share history for a merge-base to exist. - // The target's mirror is the object store both sides land in. - // `ensure_clone` fetches `origin` when the mirror already exists. - let result = cx - .background_spawn({ - let cache = cache.clone(); - let base = base.clone(); - let base_clone_urls = base_clone_urls.clone(); - let namespace = namespace.clone(); - let clone_urls = clone_urls.clone(); - let mirror_path = mirror_path.clone(); - async move { - // The fork and base must share history for a merge-base to exist. - // The target's mirror is the object store both sides land in. - // `ensure_clone` fetches `origin` when the mirror already exists. - cache.ensure_clone(&base, &base_clone_urls)?; + let task: gpui::Task> = + cx.spawn_in(window, async move |this, cx| { + // The fork and base must share history for a merge-base to exist. + // The target's mirror is the object store both sides land in. + // `ensure_clone` fetches `origin` when the mirror already exists. + let result = cx + .background_spawn({ + let cache = cache.clone(); + let base = base.clone(); + let base_clone_urls = base_clone_urls.clone(); + let namespace = namespace.clone(); + let clone_urls = clone_urls.clone(); + let mirror_path = mirror_path.clone(); + async move { + // The fork and base must share history for a merge-base to exist. + // The target's mirror is the object store both sides land in. + // `ensure_clone` fetches `origin` when the mirror already exists. + cache.ensure_clone(&base, &base_clone_urls)?; - // Prune stale imports of any fork. - // Then import this fork's heads under its namespace. - delete_refs_with_prefix(&mirror_path, "refs/fork")?; + // Prune stale imports of any fork. + // Then import this fork's heads under its namespace. + delete_refs_with_prefix(&mirror_path, "refs/fork")?; - fetch_repo_refs( - &mirror_path, - &clone_urls, - &format!("+refs/heads/*:refs/fork/{namespace}/*"), - )?; + fetch_repo_refs( + &mirror_path, + &clone_urls, + &format!("+refs/heads/*:refs/fork/{namespace}/*"), + )?; - // Both branch lists are short names, sorted like the checkout's. - let strip = |refs: Vec, prefix: &str| { - let mut names: Vec = refs - .into_iter() - .filter_map(|name| { - name.strip_prefix(prefix) - .map(|rest| rest.trim_start_matches('/').to_owned()) - }) - .filter(|name| !name.is_empty()) - .collect(); - names.sort(); - names - }; + // Both branch lists are short names, sorted like the checkout's. + let strip = |refs: Vec, prefix: &str| { + let mut names: Vec = refs + .into_iter() + .filter_map(|name| { + name.strip_prefix(prefix) + .map(|rest| rest.trim_start_matches('/').to_owned()) + }) + .filter(|name| !name.is_empty()) + .collect(); + names.sort(); + names + }; - let base_branches = strip( - refs_with_prefix(&mirror_path, "refs/remotes/origin")?, - "refs/remotes/origin", - ); + let base_branches = strip( + refs_with_prefix(&mirror_path, "refs/remotes/origin")?, + "refs/remotes/origin", + ); - let compare_branches = strip( - refs_with_prefix(&mirror_path, &format!("refs/fork/{namespace}"))?, - &format!("refs/fork/{namespace}"), - ); + let compare_branches = strip( + refs_with_prefix(&mirror_path, &format!("refs/fork/{namespace}"))?, + &format!("refs/fork/{namespace}"), + ); - Ok::<_, anyhow::Error>((base_branches, compare_branches)) + Ok::<_, anyhow::Error>((base_branches, compare_branches)) + } + }) + .await; + + this.update_in(cx, |this, window, cx| { + // A source switch mid-flight discards the stale result. + // E.g. the user picked a folder while the fork was fetching. + let applied = this.fork.as_ref().map(|fork| fork.announcement.addr()); + if applied != expected_fork { + this.loading = false; + cx.notify(); + return; } - }) - .await; - this.update_in(cx, |this, window, cx| { - // A source switch mid-flight discards the stale result. - // E.g. the user picked a folder while the fork was fetching. - let applied = this.fork.as_ref().map(|fork| fork.announcement.addr()); - if applied != expected_fork { - this.loading = false; - cx.notify(); - return; - } + this.apply_fork( + announcement, + mirror_path, + namespace, + result, + keep_base, + keep_compare, + window, + cx, + ); + })?; - this.apply_fork( - announcement, - mirror_path, - namespace, - result, - keep_base, - keep_compare, - window, - cx, - ); - })?; - - Ok(()) - }); - self.tasks.push(task); + Ok(()) + }); + task.detach(); } /// Apply an imported fork, filling the selectors and loading the compare. @@ -834,66 +804,68 @@ impl NewPullRequestView { return; } - let task = cx.spawn_in(window, async move |this, cx| { - let result = cx - .background_spawn({ - let repo_path = repo_path.clone(); - let base = base.clone(); - let compare = compare.clone(); - let base_name = base_name.clone(); - let compare_name = compare_name.clone(); - async move { - let merge_base = merge_base(Path::new(&repo_path), &base, &compare)? - .ok_or_else(|| { - anyhow::anyhow!( - "{base_name} and {compare_name} share no common ancestor" - ) - })?; - let commits = worktree_commit_range_commits( - Path::new(&repo_path), - &merge_base, - &compare, - )?; - let diff = worktree_commit_range_diff( - Path::new(&repo_path), - &merge_base, - &compare, - )?; - Ok::<_, anyhow::Error>((merge_base, commits, diff)) + let task: gpui::Task> = + cx.spawn_in(window, async move |this, cx| { + let result = cx + .background_spawn({ + let repo_path = repo_path.clone(); + let base = base.clone(); + let compare = compare.clone(); + let base_name = base_name.clone(); + let compare_name = compare_name.clone(); + async move { + let merge_base = merge_base(Path::new(&repo_path), &base, &compare)? + .ok_or_else(|| { + anyhow::anyhow!( + "{base_name} and {compare_name} share no common ancestor" + ) + })?; + let commits = worktree_commit_range_commits( + Path::new(&repo_path), + &merge_base, + &compare, + )?; + let diff = worktree_commit_range_diff( + Path::new(&repo_path), + &merge_base, + &compare, + )?; + Ok::<_, anyhow::Error>((merge_base, commits, diff)) + } + }) + .await; + + this.update_in(cx, |this, _window, cx| { + // A stale result, branches changed mid-flight, must not clobber a newer compare. + if generation != this.compare_generation { + return; } - }) - .await; + this.loading = false; - this.update_in(cx, |this, _window, cx| { - // A stale result, branches changed mid-flight, must not clobber a newer compare. - if generation != this.compare_generation { - return; - } - this.loading = false; - - match result { - Ok((merge_base, commits, diff)) => { - this.merge_base = Some(merge_base); - let count = commits.len(); - this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]); - this.commits = Some(commits); - this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx)); + match result { + Ok((merge_base, commits, diff)) => { + this.merge_base = Some(merge_base); + let count = commits.len(); + this.item_sizes = + Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]); + this.commits = Some(commits); + this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx)); + } + Err(error) => { + this.merge_base = None; + this.commits = None; + this.pane.update(cx, |pane, cx| pane.clear(cx)); + this.error = Some(error.to_string().into()); + } } - Err(error) => { - this.merge_base = None; - this.commits = None; - this.pane.update(cx, |pane, cx| pane.clear(cx)); - this.error = Some(error.to_string().into()); - } - } - cx.notify(); - })?; + cx.notify(); + })?; - Ok(()) - }); + Ok(()) + }); - self.tasks.push(task); + task.detach(); } /// Publish the pull request. @@ -927,76 +899,55 @@ impl NewPullRequestView { self.error = None; cx.notify(); - let task = cx.spawn_in(window, async move |this, cx| { - // Regenerate the series at submit time. - // The published patch covers the current tip of the compare branch. - let patch = cx - .background_spawn({ - let repo_path = repo_path.clone(); - let merge_base = merge_base.clone(); - let compare_ref = compare_ref.clone(); - async move { - format_patch_between(Path::new(&repo_path), &merge_base, &compare_ref) - } - }) - .await; - - let patch = match patch { - Ok(patch) if !patch.is_empty() => patch, - Ok(_) => { - this.update_in(cx, |this, _window, cx| { - this.submitting = false; - this.error = Some("No commits between the branches to propose".into()); - cx.notify(); - })?; - return Ok(()); - } - Err(error) => { - this.update_in(cx, |this, _window, cx| { - this.submitting = false; - this.error = Some(format!("Failed to generate the patch: {error}").into()); - cx.notify(); - })?; - return Ok(()); - } - }; - - this.update_in(cx, |this, window, cx| { - this.submitting = false; - - store.update(cx, |store, cx| { - store.open_pull_request( + let task: gpui::Task> = + cx.spawn_in(window, async move |this, cx| { + // Regenerate the series at submit time. + // The published patch covers the current tip of the compare branch. + let publish = store.update(cx, |store, cx| { + store.open_pull_request_from_refs( + repo_path, + merge_base, + compare_ref, (!subject.is_empty()).then_some(subject), description, Some(branch_name), - patch, false, - Some(merge_base), - Some(repo_path), cx, - ); + ) }); - // Close the panel once the publish is underway. - cx.defer_in(window, { - let dock_area = dock_area.clone(); - let entity = entity.clone(); - move |_, window, cx| { - if let Some(dock_area) = dock_area.upgrade() { - dock_area.update(cx, |dock, cx| { - dock.remove_panel(entity, window, cx); - }); + if let Err(error) = publish.await { + this.update_in(cx, |this, _window, cx| { + this.submitting = false; + this.error = Some(error.to_string().into()); + cx.notify(); + })?; + return Ok(()); + } + + this.update_in(cx, |this, window, cx| { + this.submitting = false; + + // Close the panel once the publish is underway. + cx.defer_in(window, { + let dock_area = dock_area.clone(); + let entity = entity.clone(); + move |_, window, cx| { + if let Some(dock_area) = dock_area.upgrade() { + dock_area.update(cx, |dock, cx| { + dock.remove_panel(entity, window, cx); + }); + } } - } - }); + }); - cx.notify(); - })?; + cx.notify(); + })?; - Ok(()) - }); + Ok(()) + }); - self.tasks.push(task); + task.detach(); } /// Open the diff of `commit_id`, from the Commits tab, in a new panel. @@ -1459,140 +1410,3 @@ impl Render for NewPullRequestView { ) } } - -#[cfg(test)] -mod tests { - use nostr::prelude::*; - use signed_core::repo_addr; - - use super::*; - - const OWNER_KEYS: [&str; 3] = [ - "0000000000000000000000000000000000000000000000000000000000000001", - "0000000000000000000000000000000000000000000000000000000000000002", - "0000000000000000000000000000000000000000000000000000000000000003", - ]; - - /// Build a signed kind-30617 event for `owner` with the given tags. - fn announcement_event(owner: &str, tags: &[&[&str]]) -> Event { - let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key")); - let tags: Vec = tags - .iter() - .map(|t| Tag::parse(t.to_vec()).expect("valid tag")) - .collect(); - EventBuilder::new(Kind::GitRepoAnnouncement, "") - .tags(tags) - .finalize(&keys) - .expect("signed event") - } - - fn announcements(owner_ix: usize, tags: &[&[&str]]) -> Vec { - vec![ - Announcement::from_event(&announcement_event(OWNER_KEYS[owner_ix], tags)) - .expect("parses"), - ] - } - - #[test] - fn fork_candidates_orders_own_forks_first() { - let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; - let clone = "https://grasp.example/npub1x/my-fork.git"; - - let base_addr = repo_addr( - PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"), - "upstream", - ); - // Newest first, as RepoListStore keeps them. - // Unrelated repo, the user's fork with the shared EUC, another fork with a `u` tag. - let all = vec![ - announcements( - 2, - &[ - &["d", "other-project"], - &["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"], - ], - ) - .pop() - .unwrap(), - announcements( - 1, - &[&["d", "my-fork"], &["r", euc, "euc"], &["clone", clone]], - ) - .pop() - .unwrap(), - announcements( - 2, - &[ - &["d", "their-fork"], - &["u", &base_addr.to_string()], - &["clone", clone], - ], - ) - .pop() - .unwrap(), - ]; - - let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey"); - let forks = fork_candidates(&all, &base_addr, Some(euc), Some(user)); - - // The user's fork comes first, then the other author's. - let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect(); - assert_eq!(ids, vec!["my-fork", "their-fork"]); - } - - #[test] - fn fork_candidates_excludes_base_unrelated_and_unfetchable() { - let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; - let base_owner = PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"); - let base_addr = repo_addr(base_owner, "upstream"); - - let mut all = vec![ - announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]]) - .pop() - .unwrap(), - announcements(1, &[&["d", "no-clone-fork"], &["r", euc, "euc"]]) - .pop() - .unwrap(), - announcements( - 2, - &[ - &["d", "other"], - &["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"], - ], - ) - .pop() - .unwrap(), - announcements( - 2, - &[ - &["d", "mirror"], - &["r", euc, "euc"], - &["clone", "https://grasp.example/x/mirror.git"], - ], - ) - .pop() - .unwrap(), - ]; - - let forks = fork_candidates(&all, &base_addr, Some(euc), Some(base_owner)); - assert_eq!(forks.len(), 1); - assert_eq!(forks[0].id, "mirror"); - - // Without a base EUC only `u`-tag forks match. - all.push( - announcements( - 2, - &[ - &["d", "u-fork"], - &["u", &base_addr.to_string()], - &["clone", "https://grasp.example/x/u-fork.git"], - ], - ) - .pop() - .unwrap(), - ); - let forks = fork_candidates(&all, &base_addr, None, Some(base_owner)); - let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect(); - assert_eq!(ids, vec!["u-fork"]); - } -} diff --git a/crates/workspace/src/views/repo_detail/pull_request_detail.rs b/crates/workspace/src/views/repo_detail/pull_request_detail.rs index e6409ce..86ed7ba 100644 --- a/crates/workspace/src/views/repo_detail/pull_request_detail.rs +++ b/crates/workspace/src/views/repo_detail/pull_request_detail.rs @@ -6,7 +6,7 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - SharedString, Size, Task, WeakEntity, Window, div, px, relative, size, + SharedString, Size, WeakEntity, Window, div, px, relative, size, }; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::clipboard::Clipboard; @@ -20,8 +20,11 @@ use gpui_component::{ ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, }; -use nostr::prelude::{Event, EventId, Kind, Nip34Tag}; -use signed_core::{activity_subject, pull_request_patch}; +use nostr::prelude::{Event, EventId, Kind}; +use signed_core::{ + activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update, + merge_base_of, pull_request_patch, +}; use signed_git::{FileCommit, patch_commits, patch_diffs}; use signed_state::{Backend, GitStore, ProfileStore, RepoStore}; use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge}; @@ -66,8 +69,6 @@ pub struct PullRequestDetailView { commit_item_sizes: Rc>>, /// Virtual list state of the commits tab. commit_scroll_handle: VirtualListScrollHandle, - /// In-flight tasks, finished tasks are pruned on every push. - tasks: Vec>>, } impl PullRequestDetailView { @@ -106,7 +107,6 @@ impl PullRequestDetailView { pane, commit_item_sizes: Rc::new(Vec::new()), commit_scroll_handle: VirtualListScrollHandle::new(), - tasks: Vec::new(), } } @@ -142,12 +142,8 @@ impl PullRequestDetailView { .and_then(merge_base_of) .or_else(|| merge_base_of(root)); - let clone_urls = clone_urls_of(root).or_else(|| { - store - .announcement - .as_ref() - .map(|a| a.clone.iter().map(ToString::to_string).collect()) - }); + let clone_urls = clone_urls_of(root) + .or_else(|| store.announcement.as_ref().map(|a| a.clone.clone())); ( root.content.clone(), @@ -162,101 +158,103 @@ impl PullRequestDetailView { self.description = description.into(); - let task = cx.spawn_in(window, async move |this, cx| { - let nostr_diff = cx - .background_spawn({ - let patch = patch.clone(); - async move { patch_diffs(&patch) } - }) - .await; - - let nostr_commits = cx - .background_spawn({ - let patch = patch.clone(); - async move { patch_commits(&patch) } - }) - .await; - - // PRs without patch events, e.g. published by ngit, carry their changes in git. - // Fetch the clone and diff the `merge-base..tip` range. - let use_nostr = match &nostr_diff { - Ok(diff) => has_patch_link || !diff.files.is_empty(), - Err(_) => true, - }; - - let git = if use_nostr { - None - } else { - let cache = cache.clone(); - let addr = addr.clone(); - let clone_urls = clone_urls.clone(); - let base = merge_base.clone(); - let tip = current_commit.clone(); - - Some( - cx.background_spawn(async move { - let repo = cache.ensure_clone(&addr, &clone_urls)?; - - let workdir = repo - .workdir() - .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? - .to_path_buf(); - - let tip = - tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?; - - let base = match base { - Some(base) => base, - // No `merge-base` tag. Use the merge base of the tip and the default branch. - None => { - let head = repo - .head_id() - .map_err(|_| anyhow::anyhow!("repository has no HEAD"))?; - let tip_id = repo.rev_parse_single(tip.as_bytes())?; - repo.merge_base(tip_id, head)?.to_string() - } - }; - - let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?; - let commits = - signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?; - - Ok::<_, anyhow::Error>((diff, commits, workdir)) + let task: gpui::Task> = + cx.spawn_in(window, async move |this, cx| { + let nostr_diff = cx + .background_spawn({ + let patch = patch.clone(); + async move { patch_diffs(&patch) } }) - .await, - ) - }; + .await; - let (diff, commits, worktree) = match git { - Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)), - Some(Err(error)) => (Err(error), Vec::new(), None), - None => (nostr_diff, nostr_commits, None), - }; + let nostr_commits = cx + .background_spawn({ + let patch = patch.clone(); + async move { patch_commits(&patch) } + }) + .await; - this.update_in(cx, |this, _window, cx| { - this.loading = false; - this.worktree = worktree; - this.current_commit = current_commit.map(SharedString::from); - this.commit_item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]); - this.commits = commits; + // PRs without patch events, e.g. published by ngit, carry their changes in git. + // Fetch the clone and diff the `merge-base..tip` range. + let use_nostr = match &nostr_diff { + Ok(diff) => has_patch_link || !diff.files.is_empty(), + Err(_) => true, + }; - match diff { - Ok(diff) => { - this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx)); + let git = if use_nostr { + None + } else { + let cache = cache.clone(); + let addr = addr.clone(); + let clone_urls = clone_urls.clone(); + let base = merge_base.clone(); + let tip = current_commit.clone(); + + Some( + cx.background_spawn(async move { + let repo = cache.ensure_clone(&addr, &clone_urls)?; + + let workdir = repo + .workdir() + .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? + .to_path_buf(); + + let tip = tip + .ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?; + + let base = match base { + Some(base) => base, + // No `merge-base` tag. Use the merge base of the tip and the default branch. + None => { + let head = repo + .head_id() + .map_err(|_| anyhow::anyhow!("repository has no HEAD"))?; + let tip_id = repo.rev_parse_single(tip.as_bytes())?; + repo.merge_base(tip_id, head)?.to_string() + } + }; + + let diff = + signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?; + let commits = + signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?; + + Ok::<_, anyhow::Error>((diff, commits, workdir)) + }) + .await, + ) + }; + + let (diff, commits, worktree) = match git { + Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)), + Some(Err(error)) => (Err(error), Vec::new(), None), + None => (nostr_diff, nostr_commits, None), + }; + + this.update_in(cx, |this, _window, cx| { + this.loading = false; + this.worktree = worktree; + this.current_commit = current_commit.map(SharedString::from); + this.commit_item_sizes = + Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]); + this.commits = commits; + + match diff { + Ok(diff) => { + this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx)); + } + Err(error) => { + this.error = Some(error.to_string().into()); + } } - Err(error) => { - this.error = Some(error.to_string().into()); - } - } - cx.notify(); - })?; + cx.notify(); + })?; - Ok(()) - }); + Ok(()) + }); - self.tasks.retain(|task| !task.is_ready()); - self.tasks.push(task); + task.detach(); } /// Open the diff of `commit_id` in the bottom dock of the area. @@ -706,66 +704,6 @@ fn open_update_pull_request_dialog( } /// The `c` tag of a PR event, the commit the proposal points at. -fn current_commit_of(root: &Event) -> Option { - root.tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()), - _ => None, - }) -} - -/// The `merge-base` tag of a PR event, as hex. -/// -/// The most recent common ancestor with the target branch. -fn merge_base_of(event: &Event) -> Option { - event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()), - _ => None, - }) -} - -/// The `clone` tag of a PR event. -/// -/// URLs where the proposed branch can be fetched, or `None` if the PR has none. -fn clone_urls_of(event: &Event) -> Option> { - event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Clone(urls)) => Some(urls.iter().map(ToString::to_string).collect()), - _ => None, - }) -} - -/// The `branch-name` tag of a PR event, if any. -fn branch_name_of(event: &Event) -> Option { - event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::BranchName(name)) => Some(name), - _ => None, - }) -} - -/// The latest PR update, kind 1619, revising `root`. -fn latest_update<'a>(events: impl Iterator, root: &Event) -> Option<&'a Event> { - let root_hex = root.id.to_hex(); - events - .filter(|e| e.kind == Kind::GitPullRequestUpdate) - .filter(|e| e.pubkey == root.pubkey) - .filter(|e| { - e.tags - .iter() - .any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str())) - }) - .max_by_key(|e| e.created_at) -} - /// One-line commit metadata for the commits list. /// /// Author and relative time, whichever is available. @@ -826,104 +764,9 @@ impl Render for PullRequestDetailView { #[cfg(test)] mod tests { - use nostr::prelude::{Tag, *}; - use super::*; const COMMIT_HEX: &str = "1111111111111111111111111111111111111111"; - const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222"; - - fn keys() -> Keys { - Keys::new( - SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001") - .expect("valid secret key"), - ) - } - - /// Build a signed event with a controlled `created_at`. - fn signed(kind: Kind, tags: Vec, created_at: u64) -> Event { - EventBuilder::new(kind, "") - .tags(tags) - .custom_created_at(Timestamp::from(created_at)) - .finalize(&keys()) - .expect("signed event") - } - - fn pr_root() -> Event { - signed( - Kind::GitPullRequest, - vec![ - Tag::parse(["c", COMMIT_HEX]).expect("valid tag"), - Tag::parse(["branch-name", "feature/x"]).expect("valid tag"), - ], - 100, - ) - } - - #[test] - fn reads_current_commit_and_branch_name() { - let pr = pr_root(); - assert_eq!(current_commit_of(&pr).as_deref(), Some(COMMIT_HEX)); - assert_eq!(branch_name_of(&pr).as_deref(), Some("feature/x")); - } - - #[test] - fn returns_none_without_pr_tags() { - let pr = signed(Kind::GitPullRequest, vec![], 100); - assert_eq!(current_commit_of(&pr), None); - assert_eq!(branch_name_of(&pr), None); - } - - #[test] - fn latest_update_picks_newest_revision_of_the_root() { - let root = pr_root(); - let root_hex = root.id.to_hex(); - - let revision = |created_at: u64| { - signed( - Kind::GitPullRequestUpdate, - vec![Tag::parse(["E", &root_hex]).expect("valid tag")], - created_at, - ) - }; - // An update revising a different PR must be ignored even though it is newer. - let unrelated = signed( - Kind::GitPullRequestUpdate, - vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")], - 999, - ); - - let events = [unrelated, revision(200), root.clone(), revision(300)]; - let latest = latest_update(events.iter(), &root).expect("an update"); - - assert_eq!(latest.created_at.as_secs(), 300); - assert_eq!(latest.kind, Kind::GitPullRequestUpdate); - } - - #[test] - fn latest_update_ignores_other_authors() { - let root = pr_root(); - let root_hex = root.id.to_hex(); - let other = Keys::new( - SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002") - .expect("valid secret key"), - ); - let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "") - .tags([Tag::parse(["E", &root_hex]).expect("valid tag")]) - .custom_created_at(Timestamp::from(999)) - .finalize(&other) - .expect("signed event"); - - // The tip of a PR is only mutable by its author. - // A newer update from anyone else must not win. - assert!(latest_update([&stranger, &root].into_iter(), &root).is_none()); - } - - #[test] - fn latest_update_ignores_roots_without_revisions() { - let root = pr_root(); - assert!(latest_update([&root].into_iter(), &root).is_none()); - } #[test] fn commit_meta_combines_author_and_time() { diff --git a/docs/backend-rearchitecture.md b/docs/backend-rearchitecture.md new file mode 100644 index 0000000..966fc2d --- /dev/null +++ b/docs/backend-rearchitecture.md @@ -0,0 +1,1384 @@ +# 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`** +(`rev 0c6fad2ac8ce934747096953f6dba355e3532614`, checked out locally at +`~/.cargo/git/checkouts/nostr-9dff06fa64f758da/0c6fad2/{nostr,nostr-sdk}/src`) +and the actual **GPUI source pinned by `Cargo.lock`** +(`git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a`, +checked out at `~/.cargo/git/checkouts/zed-a70e2ad075855582/1870e26/crates/{gpui,scheduler}/src`), +not from general knowledge of either. Every API claim below cites the file it +was verified against. + +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 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 + +1. Never call `fetch_events`. Bootstrap only via `subscribe`/`sync` (negentropy), read from `client.database()`. +2. Collapse the multiple "send an event" functions into direct `nostr-sdk` calls, no house wrappers. +3. Verify every API claim against the locally checked-out SDK/GPUI source. +4. Remove unnecessary logic (relay add/connect round trips, the fetch/sync dedup cache, unbounded task lists). +5. Re-evaluate `signed_git`'s dependence on `gix` — how much of it duplicates functionality `gix` (or another crate) already provides. +6. Check for unnecessary `cx.notify()` / over-broad re-renders vs. partial re-render. +7. Audit `crates/workspace` (the real UI call sites) for business-logic flaws of the same shape as `create_repository`, and for unnecessary string/type conversions and clones. + +Each is addressed below with concrete file:line references and a verified replacement. + +--- + +## 1. `fetch_events` — one call site, and it should go too + +``` +grep -rn "fetch_events" crates/ +crates/signed_state/src/backend.rs:1065 +``` + +The **only** use in the whole workspace is `Backend::bootstrap_user` +(`crates/signed_state/src/backend.rs:1059-1086`): + +```rust +fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context) { + let client = self.client.clone(); + self.push_task(cx.spawn(async move |this, cx| { + let result = async { + let events: Vec = client + .fetch_events(filters::grasp_list(public_key)) + .await? + .into_iter() + .collect(); + for url in latest_grasp_list_servers(events) { + client.add_relay(url.as_str()).await.ok(); + } + client.connect().await; + Ok::<_, Error>(()) + }.await; + ... + })); +} +``` + +Verified against `nostr-sdk/src/client/mod.rs:963-1018` (doc comment on +`Client::fetch_events`): it's explicitly the "buffer events, return a `Vec`" +sibling of `stream_events`, both explicitly documented as **short-lived** +subscriptions for one-off reads — the SDK's own guidance ("for long-lived +subscriptions use `Client::subscribe`") doesn't forbid `fetch_events` +outright, but the project rule you want is stricter: never bypass the +database. That's achievable here too, because `client.sync` degrades +gracefully to a plain fetch-and-store when the local DB has nothing yet. + +**Replacement** — sync against the bootstrap relays (same relays already +used for every other bootstrap query, see `BOOTSTRAP_RELAYS`, +`backend.rs:28-33`) and then read the result out of the database, exactly +like every other store in this codebase already does: + +```rust +// Also drops push_task/tasks in favor of .detach() — see §6. +fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context) { + let client = self.client.clone(); + cx.spawn(async move |this, cx| { + let result = async { + client + .sync(filters::grasp_list(public_key)) + .with(BOOTSTRAP_RELAYS) + .await?; + + let events = client.database().query(filters::grasp_list(public_key)).await?; + for url in latest_grasp_list_servers(events) { + client.add_relay(url).and_connect().await.ok(); // see §8 + } + Ok::<_, Error>(()) + }.await; + ... + }) + .detach(); +} +``` + +Verified against `nostr-sdk/src/client/api/sync.rs:1-32` and +`nostr-sdk/src/client/mod.rs:1020-1030` (`Client::sync` doc: "Performs a +negentropy-based reconciliation between the local database and one or more +relays" — this is exactly a bootstrap-and-store operation, no separate +"first fetch" step needed). No other code changes: `filters::grasp_list` and +`latest_grasp_list_servers` are unaffected. + +This also removes the last inconsistency in the codebase between "how we get +data:" everywhere else is sync-then-query; now it's sync-then-query +everywhere, no exceptions. + +--- + +## 2. The "send an event" functions — there are 8, there should be roughly 2 + +Grep for anything that ends up calling `client.send_event`: + +| Function | File:line | What it adds over `client.send_event` | +|---|---|---| +| `Backend::send` | `backend.rs:1308-1322` | signs with the current signer, then calls `broadcast_event` | +| `Backend::publish_event` | `backend.rs:1325-1332` | calls `broadcast_event` on an already-signed event | +| `Backend::publish_task` | `backend.rs:1335-1360` | wraps a future, emits `BackendEvent::Published`/`Error` | +| `Backend::send_fire_and_forget` | `backend.rs:1363-1375` | calls `send`, drops the result except logging | +| `Backend::retract_events` | `backend.rs:1378-1398` | hand-builds NIP-09 tags, calls `send` | +| `broadcast_event` (free fn) | `backend.rs:1404-1418` | calls `client.send_event`, turns "0 relays accepted" into an `Err` | +| `stage_event_on_relay` | `backend.rs:1768-1795` | calls `client.send_event(..).to([relay])`, same 0-accept-is-Err logic, different error type (`String`) | +| `RepoStore::send` | `repo.rs:1334-1344` | calls `Backend::send`, tracks `last_error` — **but several `RepoStore` methods bypass it** and call `Backend::send`/`Backend::publish_event` directly (`repo.rs:803`, `repo.rs:935`), so error surfacing is inconsistent across `RepoStore` methods | + +That's 8 layers for what the SDK already does in one call. Verified against +`nostr-sdk/src/client/api/send_event.rs:119-350`: + +- `client.send_event(&event)` **already** verifies the signature, saves the + event to the local database (`save_into_database`, default `true`), and + broadcasts — all before you touch anything (`send_event.rs:337-345`). +- Zero-relay-accepted is *not* an error from the SDK's point of view — it + returns `Ok` with `output.success` empty and `output.failed` populated. + Turning that into an app-level error is legitimate domain logic (the repo + already gets this right), it just doesn't need 3 separate functions + (`broadcast_event`, `stage_event_on_relay`, and the implicit success check + buried in `RepoStore::send`) doing the same "empty success ⇒ error" check. + +### Recommended shape: one helper, and direct SDK calls everywhere else + +Keep exactly **one** small helper because the "empty success ⇒ Err" rule is +real, repeated, app-specific policy (the SDK intentionally leaves that +decision to the caller): + +```rust +/// The event was accepted by at least one relay, or a descriptive error otherwise. +async fn require_relay_accepted(output: SendEventOutput) -> Result { + if output.success.is_empty() && !output.failed.is_empty() { + let reasons = output.failed.values().cloned().collect::>().join(", "); + bail!("event not accepted by any relay: {reasons}"); + } + Ok(event) +} +``` + +Then delete `Backend::send`, `Backend::publish_event`, +`Backend::send_fire_and_forget`, `broadcast_event`, and `RepoStore::send`. +Call `client.send_event(...)` **directly** at each call site, exactly like +`stage_event_on_relay` already does for the GRASP staging path — that +function is the one place in the codebase that already follows this +pattern (`.to([relay.clone()])`, explicit target, no extra wrapper beyond +the accept-check). Generalize *that* pattern instead of routing everything +through `Backend`. + +```rust +// A GPUI call site, e.g. RepoStore::open_issue, today: +self.send(builder, cx); + +// direct SDK call instead. No task list to push into and prune either — +// see §6, `.detach()` is the right default here. +let signer = Backend::global(cx).read(cx).signer(); +let client = Backend::global(cx).read(cx).client(); +cx.spawn(async move |this, cx| { + let event = builder.finalize_async(&signer).await?; + let output = client.send_event(&event).await?; + let event = require_relay_accepted(output, event).await?; + this.update(cx, |this, cx| { /* apply + cx.notify() */ }) +}) +.detach(); +``` + +`Backend` still owns the `Client`/`UniversalSigner` (a real, load-bearing +type — see §4 for why it must stay), but it should expose them +(`Backend::client()`/`Backend::signer()`, both already exist, +`backend.rs:1089-1096`) rather than mediate every publish through 4 layers +of wrapper. Emitting `BackendEvent::Published` for cross-store invalidation +(e.g. so `RepoListStore` refreshes when a new announcement lands) is the one +piece of `publish_task` worth keeping — but it can be a single `fn` taking +`&Event` that any call site invokes after its own `send_event`, not the +thing that *does* the sending. + +### `Backend::retract_events` — use the SDK's own NIP-09 builder, one deletion event per target + +`nostr` already ships `EventDeletionRequest` (verified in +`nostr/src/nips/nip09.rs:15-92`), which implements `IntoEventBuilder` exactly +like `GitRepositoryAnnouncement`/`GitIssue`/etc. already used elsewhere in +this codebase. Today's code hand-builds the tags for **one** deletion event +covering every target, plus a `k` tag per target: + +```rust +// today, backend.rs:1378-1398 +let mut tags: Vec = Vec::with_capacity(events.len() * 2); +for event in events { + tags.push(Tag::event(event.id)); + tags.push(Tag::parse(["k", &event.kind.to_string()]).expect("valid kind tag")); +} +let task = self.send(EventBuilder::new(Kind::EventDeletion, "").tags(tags), cx); +``` + +Per direction from the team: no `k` tag, and each event gets its own +deletion event rather than one deletion event listing multiple `e` tags. +`EventDeletionRequest` (`nip09.rs:15-92`) supports exactly that shape +already — call `.id(event.id)` once per event and send each independently: + +```rust +async fn retract_event(client: &Client, signer: &UniversalSigner, event: &Event) -> Result<(), Error> { + let builder = EventDeletionRequest::new().id(event.id).into_event_builder(); + let deletion = builder.finalize_async(signer).await?; + client.send_event(&deletion).await?; + Ok(()) +} + +fn retract_events(&mut self, events: &[Event], cx: &mut Context) { + let client = self.client.clone(); + let signer = self.signer.clone(); + + for event in events.to_vec() { + let client = client.clone(); + let signer = signer.clone(); + + cx.spawn(async move |_this, _cx| { + if let Err(e) = retract_event(&client, &signer, &event).await { + log::warn!("failed to retract event {}: {e}", event.id); + } + }) + .detach(); + } +} +``` + +No hand-rolled tag construction, no batching multiple targets into one +event, no `k` tag, and no task list to maintain (§6). Each deletion is +independent: a relay rejecting or dropping one doesn't affect the others. + +--- + +## 3. Remove the fetch/sync dedup cache — it duplicates state that already exists elsewhere + +`Backend` carries: + +```rust +recent_fetches: HashMap, // backend.rs:86 +const FETCH_DEDUP_WINDOW: Duration = ...; // backend.rs:39 +fn fetch_recently_started(&mut self, fingerprint: u64) -> bool { ... } // backend.rs:1178-1186 +fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 { ... } // backend.rs:1423-1433 +``` + +used at 3 call sites (`connect_repo_relays`, `sync_bootstrap`, and +indirectly wherever those are called), e.g.: + +```rust +pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context) { + let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter)); + if self.fetch_recently_started(fingerprint) { + log::debug!("skipping duplicate bootstrap sync"); + return; + } + ... +} +``` + +This is a generic "have I already asked for this filter recently" +cache, sorting + hashing relay lists and filters, pruning on a 5-minute +window, and un-inserting on error so a failed sync can retry immediately. +It exists purely to avoid redundant `sync`/`subscribe` calls — but every +call site that calls into `Backend::sync_bootstrap`/`connect_repo_relays` +**already has its own, more precise state for exactly this purpose**: + +- `RepoStore` tracks `repo_relays: HashSet` (`repo.rs:77`) — "have + I already connected+fetched this repo's relays" — and `root_fetches: + HashSet` (`repo.rs:81`) for per-root fetches. +- `RepoListStore` and `CheckoutsStore` each already run every refresh + through `RefreshGate` (`refresh.rs`), which itself exists to coalesce + bursts of refresh requests — that's the same "don't do this again right + now" idea, at the right granularity (per-store, per-purpose), not a + generic cross-cutting cache keyed by a hash of relays+filters. + +The `Backend`-level cache is solving the same problem a second time, at a +coarser and more error-prone granularity (a hash collision or an +order-sensitivity bug silently drops a legitimate sync; the 5-minute window +is a magic number with no connection to how often any of the 3 call sites +actually fire). Delete `recent_fetches`, `fetch_recently_started`, +`fetch_fingerprint`, `FETCH_DEDUP_WINDOW`, and `DefaultHasher`/`Hash`/`Hasher` +imports they pull in. Let each caller guard itself the way `RepoStore` +already does for `repo_relays`: + +```rust +// RepoStore, once per repo — this pattern already exists (repo.rs:189ish), +// just needs to also gate the *bootstrap* sync calls the same way instead +// of relying on a Backend-side cache. +if self.repo_relays.insert(relay.clone()) { + backend.update(cx, |backend, cx| backend.connect_repo_relays(vec![relay], filters, cx)); +} +``` + +`sync_bootstrap` for repo-independent filters (announcements, deletions) is +called from exactly one place today (`RepoListStore::subscribe_remote`, +`repo_list.rs:144-153`), on store construction — i.e., once per app +session. It does not need a dedup cache at all; if you're worried about a +second `RepoListStore` instance ever existing, that's a `Global`-uniqueness +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 + +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` +(`crates/signed_nostr/src/backend.rs:31-51`). This section is scoped down +to what falls out of that: how the currently-implemented send paths +interact with gossip being on, verified against the SDK source. + +```rust +let client = ClientBuilder::default() + .database(database) + .authenticator(authenticator) + .gossip(NostrGossipMemory::unbounded()) + .gossip_config(GossipConfig::default().no_background_refresh()) + ... + .build(); +``` + +Verified against `nostr-sdk/src/client/api/send_event.rs:337-388` and the +doc comment on `Client::send_event` (`client/mod.rs:1097-1130`): **when no +explicit target is set** (no `.to()`/`.broadcast()`/`.to_nip17()`/`.to_nip65()`), +and gossip is configured, `send_event` resolves the destination via the +gossip engine (NIP-65 relay discovery for the event's author + tagged +pubkeys), not simply "every relay you `add_relay`'d". Every one of the 8 +send-paths in §2 calls `client.send_event(&event)` with **no explicit +target** — meaning every one of them is going through gossip-based relay +resolution today, on top of the relays this app added on purpose +(`BOOTSTRAP_RELAYS`, the repo's own `relays` tag, GRASP servers). + +That happens not to lose anything today, because `gossip_prepare_urls` +(`send_event.rs:229-320`) *also* unions in `client.pool().write_relay_urls()` +at the end — so events still reach every WRITE relay in the pool, gossip +only adds more relays on top. But it's not free: every plain `send_event` +call (opening an issue, commenting, reacting to a PR) does gossip +relay-list resolution — potentially a network round trip to fetch a NIP-65 +list — for events whose target set is already fully determined by the +repo's own `relays` tag or the bootstrap relay list, and where the extra +NIP-65 relays gossip adds are not places NIP-34 consumers are expected to +look. + +**Recommendation:** keep `.gossip(...)` configured (it's wanted for +whatever's next — NIP-17 DMs, NIP-65 profile/relay-list features, etc.), +but make the git-domain sends that already have a well-defined target +explicit about it, the same way `stage_event_on_relay` already is +(`.to([relay.clone()])`, `backend.rs:1768-1795`): + +- Repository-scoped events (announcements, state, issues, PRs, patches, + comments, statuses, deletions) know their target relays already (the + repo's `relays` tag, or `BOOTSTRAP_RELAYS` for repo-independent + discovery events) — send them with `.broadcast()` or `.to(relays)` so + they don't pay for gossip resolution and don't silently depend on the + sender's NIP-65 list being fresh. +- Anything that *should* use gossip once it exists (e.g. a future NIP-17 + DM, or explicit NIP-65 profile publishing) keeps the default routing, or + calls `.to_nip17()`/`.to_nip65()` explicitly. + +This is a small, additive change (one `.broadcast()`/`.to(...)` call per +send site as part of the §2 consolidation), not a removal — do it while +touching each call site for the send-path cleanup below, so gossip stays +fully available for the features that are meant to use it, while today's +repo/issue/PR/patch traffic stays deterministic about where it goes. + +--- + +## 5. `signed_git` vs `gix` — split verdict, not "throw it all out" + +`crates/signed_git/src/lib.rs` is 4118 lines. Checked the actual `gix` +version pinned (`gix = "0.87.1"`, feature set in the root `Cargo.toml`) and +its `gix-diff 0.67.1` dependency against what `signed_git` hand-rolls. + +### Already correct, idiomatic `gix` usage — keep as-is + +`tree_diff` (`signed_git/src/lib.rs:1468-1583`) generates commit-to-commit +diffs by calling `repo.diff_tree_to_tree(...)`, then +`gix::diff::blob::diff_with_slider_heuristics(...)`, then feeding the result +through `gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, ..)` +where `collector` implements gix's own `ConsumeHunk` trait +(`signed_git/src/lib.rs:1963-2027`, matching `gix-diff-0.67.1/src/blob/unified_diff/mod.rs:70-84` +exactly). This *is* the documented, intended way to consume `gix`'s diff +engine — there is no simpler API to fall back to, and no unnecessary +reimplementation here. Same for the porcelain wrappers around `gix::Repository` +for refs, branches, tags, worktree checkout, etc. — that's inherent surface +area for a git-porcelain layer, not bloat. + +### Real duplication — the `git format-patch` text parser + +The other ~700 lines (`parse_diff_section`, `parse_hunk`, `hunk_header`, +`header_paths`, `diff_line_path`, `take_quoted`, `unquote_path`, +`strip_patch_prefix`, `name_from_address`, `signed_git/src/lib.rs:1660-2027`) +are a hand-rolled parser for **already-rendered** `git format-patch`/unified +diff text — this is necessary because a NIP-34 patch event's content *is* +the raw text output of `git format-patch`, arriving over Nostr with no +backing git objects to hand to `gix`'s diff engine. `gix-diff` only +*generates* unified diffs from git objects; it has no facility to *parse* +unified-diff text back into structured hunks, so this isn't a case of +"gix already does this and we reimplemented it." + +However, a maintained crate already exists for exactly this parsing job: +[`diffy`](https://docs.rs/diffy)'s `PatchSet` module +(`diffy::patch_set::PatchSet::parse(text, ParseOptions::gitdiff())`) +explicitly parses "the output of `git diff` or `git format-patch`", +supporting `diff --git` headers, extended headers (`new file mode`, +`deleted file mode`, etc.), rename/copy detection via `rename from`/`rename +to`/`copy from`/`copy to`, and binary-file detection — i.e., the exact +feature list `signed_git`'s hand-rolled parser reimplements +(`FileDiff::status` has `Renamed`/`Copied`/`Added`/`Deleted`/`Modified` +variants, `signed_git/src/lib.rs:1373-1379`; binary detection at +`signed_git/src/lib.rs:1395`). + +**Recommendation:** spike replacing `patch_diffs`/`parse_diff_section`/ +`parse_hunk`/`unquote_path`/etc. with `diffy::patch_set::PatchSet`, mapping +its `FileOperation`/`Hunk` types onto this codebase's existing `FileDiff`/ +`DiffHunk` (which downstream UI code already depends on, so keep those +public types and only replace the parsing internals). This is the single +biggest concrete size reduction available in the whole backend — a ~700 +line hand-rolled parser (plus ~1300 lines of tests for it, +`signed_git/src/lib.rs:3681-4053` and surrounding) collapses to a thin +adapter over a well-tested crate. Budget a spike first: `diffy`'s renamed +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>` + `push_task` boilerplate — use `Task::detach()` + +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`). + +Six different stores carry the exact same field and method, copy-pasted: + +```rust +tasks: Vec>>, + +fn push_task(&mut self, task: Task>) { + self.tasks.retain(|task| !task.is_ready()); + self.tasks.push(task); +} +``` + +at `backend.rs:87-91,164-169`, `checkouts.rs:106-110,180-185`, +`local_repos.rs:13-23`, `profile.rs:72-80,136-141`, `repo.rs:82-86` (plus an +inlined copy of the same retain-then-push at `repo.rs:236-240` and +`repo.rs:373-377`), and `repo_list.rs:56-60,137-142`. + +`Task`'s own doc comment (`scheduler/src/executor.rs:375-380`) says exactly +what this boilerplate exists to avoid: "If you drop a task it will be +cancelled immediately. Calling `Task::detach` allows the task to continue +running, but with no way to return a value." `Task::detach(self)` +(`executor.rs:552-559`) does precisely that, and `TaskExt::detach_and_log_err` +(`gpui/src/executor.rs:35-61`, already referenced in this project's own +`.rules` file) additionally logs an `Err` without any manual `match`. None +of these stores' spawned tasks need cancel-on-drop semantics: every +continuation already does `this.update(cx, ...).ok()` or propagates through +`?`, so if the owning entity is gone by the time the task finishes, the +update is a harmless no-op — exactly the "tolerate the entity being gone" +pattern already used everywhere in this codebase (see the `.ok()` calls +throughout `backend.rs`). Storing the task and pruning it on every push +buys nothing here; `.detach()` (or `.detach_and_log_err(cx)` where the +continuation only logs on failure) replaces both the field and the method: + +```rust +// today +self.push_task(cx.spawn(async move |this, cx| { + if let Err(e) = task.await { + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string()))).ok(); + } + Ok(()) +})); + +// replacement — no field, no prune, no manual match +cx.spawn(async move |this, cx| { + if let Err(e) = task.await { + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string()))).ok(); + } +}) +.detach(); +``` + +Delete the `tasks` field and `push_task` method from all six stores, and +change every `self.push_task(cx.spawn(...))` call to `cx.spawn(...).detach()` +(or `.detach_and_log_err(cx)` when the closure's only job is to log the +error). The one place that must **not** just detach is `push_repo_from`'s +returned `Task>` (`backend.rs:815-902`) — that +task is deliberately returned to the UI caller (so the panel can `.await` +it and show a spinner) and already isn't stored in a `tasks` list today, so +it's unaffected by this cleanup. + +`crates/workspace` has the same pattern too, and there it's a real bug, not +just style — see §14. + +--- + +## 7. Render granularity / `cx.notify()` audit + +Checked every `cx.notify()` call in `signed_state` (18 call sites) and how +`workspace` views consume each store. Overall this is **already +well-partitioned**, not a smell: + +- Every panel (`IssuesView`, `PullRequestsView`, `IssueDetailView`, + `CommitDiffView`, `RepoDetailView`, `PullRequestDetailView`, + `NewPullRequestView`, `DiffPane`) is its own `Entity`/`Render` impl — + `cx.notify()` on a store only invalidates the views actually observing + that store's `Entity`, not a monolithic root view. +- `IssuesView`/`PullRequestsView` already memoize derived rows behind a + `(store.version(), filter)` cache key (`issues.rs:68-72`, `323-333`; + `pull_requests.rs:75-79`, `331-341`), and both use + `VirtualListScrollHandle` for virtualization — so a store `notify()` + doesn't force rebuilding or laying out off-screen rows. +- `sync_bootstrap`'s per-percent progress `cx.notify()` + (`backend.rs:1257-1264`) is already throttled to *distinct percentage + points* (`if progress.current > 0 && percent != last_percent`, + `backend.rs:1254`), and nothing in `workspace` reads `Backend::sync_progress()` + directly (`grep -rn "sync_progress()" crates/workspace` → no matches), so + this never drives a visible re-render on its own. + +### One real waste found: `RepoListStore` re-queries the DB on every sync tick + +`RepoListStore`'s backend subscription (`repo_list.rs:76-109`) treats +`BackendEvent::SyncProgress { .. }` as relevant on its own: + +```rust +BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true, +``` + +Every distinct percentage tick of the bootstrap announcements/deletions +sync calls `this.refresh(cx)`, which is debounced 300ms +(`REFRESH_DEBOUNCE`, `repo_list.rs:16`) and coalesced by `RefreshGate` — so +it's not literally one DB round-trip per percent, but it is several +(bounded by sync duration / 300ms) full re-scans of announcements + +deletions + state events + activity + counts (`run_refresh`, +`repo_list.rs:184-294`) while a single sync is still in flight, instead of +one at the end. This is a deliberate trade-off for progressive reveal (the +repo list fills in live instead of jumping once at 100%), so it's not a +bug, but if that progressive reveal isn't a feature you actually want, +dropping `SyncProgress` from the "relevant" match (keep only `Synced`) removes +several redundant background-thread DB scans per sync for free. Worth a +product decision, not just a code fix. + +No other store subscribes to `SyncProgress` (`RepoStore`, `CheckoutsStore` +do not — checked their subscription callbacks), so this is fully isolated +to `RepoListStore`. + +--- + +## 8. Relay add/connect: stop round-tripping through strings, stop reconnecting the whole pool + +Flagged example (`backend.rs:1071-1074`): + +```rust +for url in latest_grasp_list_servers(events) { + client.add_relay(url.as_str()).await.ok(); +} +client.connect().await; +``` + +Two separate problems, both verified against `nostr-sdk/src/client/url.rs:40-50` +and `nostr-sdk/src/client/api/connect.rs:1-49`: + +1. **`.as_str()` is a pointless round trip.** `latest_grasp_list_servers` + already returns `RelayUrl` values (parsed, validated). `RelayUrlArg` + (what `add_relay` actually accepts) has a direct `impl From` + and `impl From<&RelayUrl>` (`client/url.rs:40-50`) — passing the + `RelayUrl` itself skips a second `RelayUrl::parse` that `.as_str()` + forces (`client/url.rs:26,35`, the `String` variant of `RelayUrlArg` + re-parses on `try_into_relay_url`). Just pass `url`, not `url.as_str()`. +2. **`client.connect()` connects every relay in the pool, not just the one + you added.** Verified in `connect.rs:36-48`: `Client::connect()`'s + `IntoFuture` unconditionally calls `self.client.pool().connect()`, with + no target selection at all — it iterates every relay currently in the + pool. Calling it after adding 1-2 new relays re-issues a connect + attempt to *every* relay already connected too. The `AddRelay` builder + already has the right primitive: `.and_connect()` (`client/api/add.rs:127-132`), + which is threaded straight into `pool.add_relay(url, capabilities, connect, opts)`. + Verified in `pool/mod.rs:157-197` that this is correct **even when the + relay already exists** in the pool: the pool's `add_relay` checks for an + existing entry and, if `connect` is `true`, calls `relay.connect()` on + the existing relay too (`pool/mod.rs:191-194`) — so `.and_connect()` is + never wrong to use, whether the relay is new or already known. + +```rust +// replacement +for url in latest_grasp_list_servers(events) { + client.add_relay(url).and_connect().await.ok(); +} +``` + +The same two problems repeat at every other relay-add call site — fix all +of them the same way: + +- `Backend::bootstrap` (`backend.rs:178-187`): the `BOOTSTRAP_RELAYS` loop + and the `INDEXER_RELAYS` loop (which also sets `.capabilities(...)`, + chain `.and_connect()` onto the same builder) both currently defer to one + trailing `client.connect().await`. +- `connect_repo_relays` (`backend.rs:1446-1449`): today calls `client.add_relay(url).await?;` + then `client.connect_relay(url).await?;` as two separate round trips — + collapse to one `client.add_relay(url).and_connect().await?;`. +- `stage_event_on_relay` (`backend.rs:1772-1782`): same fix, and this one + currently calls the pool-wide `client.connect().await` just to connect + the single relay it's about to stage an event on. + +### Delete the `add_relays` wrapper (`Backend::add_relays`, `backend.rs:1149-1173`) + +Its only two callers (`create_repository`, `backend.rs:505-508`; +`publish_local_repo`, `backend.rs:652-655`) do this today: + +```rust +this.update(cx, |this, cx| { + let urls: Vec = servers.iter().map(ToString::to_string).collect(); + this.add_relays(urls, cx); +})?; +``` + +`servers` is already `Vec` at both call sites — stringifying it +only to have `add_relays` parse it straight back into `RelayUrl` inside +`client.add_relay(&url)` is pure waste, on top of the wrapper itself being +another `cx.spawn` + `push_task` + error-emit layer (§6) around what is, +with the fix above, a two-line loop. Both call sites are already inside a +`cx.spawn(async move |this, cx| ...)` with `client` reachable — inline it: + +```rust +let client = this.update(cx, |this, _cx| this.client.clone())?; +for relay in &servers { + client.add_relay(relay).and_connect().await.ok(); +} +``` + +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 + +This is a real business-logic flaw, not just a style issue. Today +(`backend.rs:437-501`): + +1. `signed_git::init_repository(&path, &name, &description)` — `path` is + `GitCache::repo_path(&addr)`, the app's **internal mirror cache** + location (`crates/signed_git/src/lib.rs:29-33`), not anywhere the user + asked for. This creates a full worktree with an initial commit *there*. +2. `signed_git::clone_repo(&[mirror_url], &destination)` — `destination` is + `folder.join(dir_name)`, the folder the user actually picked. This + clones the mirror just created in step 1 into the real target, via a + `file://` URL (`Url::from_file_path(&path)`, `backend.rs:481-483`). +3. The push (`push_staged_to_grasps`, called with `path` = the **mirror**, + not `destination`) pushes the mirror's objects to the grasp servers. +4. `origin` gets set on *both* the mirror (`backend.rs:463-466`) and the + destination (`backend.rs:492-495`). + +So a brand-new repository gets initialized twice and checked out twice for +what is, at that point, one README and one commit — and the thing that +actually gets pushed (the mirror) isn't the thing the user is left looking +at (the destination). + +Checked `signed_git::init_repository` itself (`signed_git/src/lib.rs:401-477`): +it already creates the target directory (`std::fs::create_dir_all(path)`), +runs `gix::init(path)`, and leaves a fully checked-out worktree with the +README written to disk and the index populated — i.e., it already produces +exactly what step 2's clone is redundantly reproducing. There is no reason +step 1 and step 2 are two different paths. + +Compare with `publish_local_repo` (`backend.rs:599-754`), the sibling flow +for an *existing* local repo: it operates on the user's real folder +directly (`signed_git::worktree_ref_state(&path)`, `root_commit(&path)`) — +no mirror, no extra clone. `create_repository` is the odd one out. + +**The mirror doesn't need to be pre-populated at creation time at all.** +`GitCache::ensure_clone(addr, clone_urls)` (`signed_git/src/lib.rs:45-63`) +already exists precisely to populate the mirror lazily — open it if it's +there, clone it from the announcement's `clone_urls` if it's not — and +it's already what `RepoDetailView::load_repo` calls for every repo, +including the user's own (`workspace/src/views/repo_detail/mod.rs:425-428`). +By the time the UI navigates to the new repo's detail view after +`create_repository` returns, the push has already succeeded, so +`ensure_clone` will clone straight from the just-pushed grasp server — +exactly the same lazy path every other repo already takes. No special +casing needed. + +I checked whether any `workspace` call site compounds this (e.g. by cloning +*again* right after `create_repository` returns) — it doesn't: +`sidebar/create_repo_dialog.rs`'s `create_repository` handler +(`create_repo_dialog.rs:193-222`) just calls `backend.create_repository(...)` +and applies the returned `Announcement`; the flaw is fully contained inside +`Backend::create_repository` itself. + +**Replacement:** initialize directly at `destination`, push from +`destination`, set `origin` once: + +```rust +let commit = signed_git::init_repository(&destination, &name, &description)?; +// ... build the announcement using `commit` as before ... +// push_staged_to_grasps(..., path = &destination, ...) instead of the mirror path +if let Some(base) = servers.first().and_then(grasp_base_url) { + signed_git::set_origin(&destination, &format!("{base}/{owner}/{repo_id}.git"))?; +} +``` + +Delete the mirror `init_repository` call, the `clone_repo` call, the +`Url::from_file_path` mirror-URL construction, and the mirror-side +`ensure_origin` call. This removes a full extra `gix::init` + checkout + +clone from repo creation, and makes `create_repository` consistent with +how `publish_local_repo` already treats the user's working copy as the one +source of truth. + +--- + +## 10. Bootstrap-on-construction should go through `cx.defer`, not run synchronously in `new` + +Verified against the pinned GPUI revision +(`crates/gpui/src/app.rs:1999-2005`, `crates/gpui/src/app/context.rs:296-315`). + +`App::defer(&mut self, f: impl FnOnce(&mut App) + 'static)` — "Schedules +the given function to be run at the end of the current effect cycle, +**allowing entities that are currently on the stack to be returned to the +app**." That's precisely the situation every one of these constructors is +in: `Self` is still being built inside the `cx.new(|cx| ...)` closure when +it reaches out and kicks off real work. `Context::defer_in` also exists +(`app/context.rs:296-315`) but takes a `&Window` — it's for window-bound +views, not the headless global stores below, none of which are constructed +with a `Window` in scope. For these, the applicable API is the window-less +`cx.defer(...)`, reached through `Context`'s `Deref` +(`app/context.rs:25-34`), capturing a `WeakEntity` to get back into +`Self` once deferred: + +```rust +// today, backend.rs:148-160 +let mut this = Self { /* ... */ }; +this.bootstrap(cx); +this + +// replacement +let mut this = Self { /* ... */ }; +let weak = cx.entity().downgrade(); +cx.defer(move |cx| { + weak.update(cx, |this, cx| this.bootstrap(cx)).ok(); +}); +this +``` + +The same pattern — a constructor that calls its own bootstrap-ish method, +or reaches into another entity, before returning `Self` — repeats in every +store: + +| Store | Constructor call site | What it kicks off synchronously | +|---|---|---| +| `Backend` | `backend.rs:159` | `bootstrap(cx)` — adds/connects `BOOTSTRAP_RELAYS`/`INDEXER_RELAYS`, restores the session | +| `RepoListStore` | `repo_list.rs:120-123` | `subscribe_remote(cx)` (negentropy sync against bootstrap relays) + `refresh_initial(cx)` | +| `RepoStore` | `repo.rs:154-159` | `subscribe_remote`, `connect_announced_relays`, `refresh` — each one reaches into the global `Backend` entity | +| `CheckoutsStore` | `checkouts.rs:172-174` | `refresh(cx)` | +| `LocalReposStore` | `local_repos.rs:44` | `rescan(cx)` | +| `ProfileStore` | `profile.rs:119-121` | spawns the batched profile-fetch loop | + +Wrap each of these the same way `Backend::new` is shown above. This isn't +about a currently-observed crash (nothing panics today, because everything +past the initial synchronous field assignment already goes through +`cx.spawn`/`cx.background_spawn`, which only runs later anyway) — it's +about not mixing "construct plain state" with "kick off side effects that +talk to other entities" in the same synchronous call, which is exactly what +`defer` exists to separate, per its own doc comment. + +--- + +## 11. Split independently-observed state into child entities + +`Backend::pushing_repos` (`backend.rs:88`) is `Arc>>` +— 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 +only poll a `Mutex` by hand, and any UI update requires some *other* +notify to happen to piggyback on. Meanwhile every view that only cares +about, say, `current_user` still gets re-invoked on `Backend::notify()` +fired for unrelated reasons (a `sync_progress` tick, a new relay connecting), +because the whole `Backend` is one entity and `cx.notify()` invalidates all +of its observers indiscriminately. + +GPUI's own model is built for exactly this split: an `Entity` works for +any `T: 'static`, not just `Render`-able view state (see the project's own +GPUI notes: "Whenever you need to store application state that +communicates between different parts of your application, you'll want to +use GPUI's entities"). Where a piece of a bigger store's state changes on +its own schedule and has its own, narrower set of observers, pull it out +into a child entity: + +```rust +pub struct Backend { + client: Client, + signer: UniversalSigner, + current_user: Option, + sync_progress: Option<(u64, u64)>, + passphrase_required: bool, + pushing_repos: Entity>, // was Arc>> +} +``` + +A view that only cares whether repo `X` is pushing does +`cx.observe(&backend.read(cx).pushing_repos, |this, pushing, cx| ...)` and +is left alone by every other `Backend` change. `PushGuard` +(`backend.rs:99-110`) becomes a guard that calls +`pushing_repos.update(cx, |set, cx| { set.remove(&addr); cx.notify(); })` +on drop instead of locking a raw `Mutex` — same RAII shape, but now it's a +real, observable GPUI entity instead of a side channel next to the entity +system. Apply the same split to any other `Backend`/store field where the +set of interested observers is a strict subset of the store's full +observer list. + +This principle is also the reason **not** to merge `LocalReposStore` and +`RepoListStore` into one entity — see §13. + +--- + +## 12. One debounce at the source, not one per store + +Flagged example — the notification pump (`backend.rs:126-146`): + +```rust +let mut notifications = pump_client.notifications(); +while let Some(notification) = notifications.next().await { + let ClientNotification::Event { event, .. } = notification else { continue }; + let update = Update::from_event(&event); + if this.update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(update))).is_err() { + break; + } +} +``` + +Every single relay-delivered event is emitted as its own +`BackendEvent::NostrUpdate`, immediately. During a negentropy sync +(exactly the bursty case §6/§7 already discuss), this can be hundreds of +emits in a short window. Four different stores (`RepoStore`, +`RepoListStore`, `CheckoutsStore`, and transitively `ProfileStore`) each +subscribe to `Backend` and independently run their own `RefreshGate` +debounce/coalesce dance in response — the same burst gets debounced four +times, once per listener, instead of once at the point it actually enters +the system. + +Centralize it: batch what the pump itself emits, and let each store react +to a batch instead of a stream of singles. The pump already owns the one +place where the burst originates, so it's the natural place to coalesce: + +```rust +let pump = cx.spawn(async move |this, cx| { + let mut notifications = pump_client.notifications(); + let mut pending: Vec = Vec::new(); + + loop { + let next = cx.background_executor().timer(PUMP_DEBOUNCE).fuse(); + futures::select_biased! { + notification = notifications.next() => { + let Some(notification) = notification else { break }; + let ClientNotification::Event { event, .. } = notification else { continue }; + pending.push(Update::from_event(&event)); + } + _ = next => { + if pending.is_empty() { continue; } + let batch = std::mem::take(&mut pending); + if this.update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(batch))).is_err() { + break; + } + } + } + } + Ok(()) +}); +``` + +(Sketch — the real version needs `BackendEvent::NostrUpdate` to carry +`Vec` instead of `Update`, and every subscriber's relevance check — +`RepoStore`, `RepoListStore`, `CheckoutsStore`, `ProfileStore` — to check +"does *any* update in the batch match" instead of one `Update`. That's a +mechanical change to four match arms.) + +This doesn't make each store's own `RefreshGate` fully redundant: +`Published`/`Synced`/`SyncProgress` events are emitted directly by +whichever method triggered them (a local `send`, a sync completing), not +through the pump, and can still arrive close together independently of +relay traffic. But those are one-off, user-triggered events, not the +hundred-events-in-a-burst case — so once the pump absorbs the dominant +source of bursts, each store's debounce window can likely shrink +significantly (or, for stores that only ever see one trigger at a time in +practice, be dropped in favor of "fold into the in-flight run" without a +timer at all). Worth measuring after the pump-side batching lands, rather +than speculatively resizing four timers up front. + +--- + +## 13. `local_repos.rs` + `repo_list.rs`: merge the files, not the entities + +These two are structurally near-identical: both hold an `Arc>` +snapshot, refresh it in the background on a trigger, swap it in with +`cx.notify()`, and carry their own `Global` wrapper + `global()`/`set_global()` +pair + `tasks`/`push_task` boilerplate (§6). That similarity is real and +worth collapsing — but checked who actually reads each one before deciding +*how*: + +``` +grep -rn "RepoListStore::global" crates/ → 9 call sites +grep -rn "LocalReposStore::global" crates/ → 5 call sites +``` + +Only **two** places read both together: `CheckoutsStore::new`/`run_refresh` +(`checkouts.rs:126-136`, `checkouts.rs:339-344`) and `SidebarPanel::new`/`refresh` +(`sidebar/mod.rs:55-65`, `sidebar/mod.rs:128-142`). Everywhere else reads +exactly one: + +- `RepoListStore` alone: `RepoStore::action_announcement` (`repo.rs:1071-1078`), + `RepoDetailView::open_upstream` (×2, `mod.rs:1009-1013`, `1034-1044`), + `RepoDetailView::fork_row` (`mod.rs:2596-2606`), + `NewPullRequestView::fork_candidates` (`new_pull_request.rs:569-577`), + `RepoListView::new` (`views/repo_list.rs:111-121`). +- `LocalReposStore` alone: `RepoDetailView::apply_announcement` + (`mod.rs:1875-1877`), `SidebarPanel::render_repos`'s rescan button + (`sidebar/mod.rs:306-309`). + +Given that, collapsing them into **one `Entity`** (one struct holding both +`Vec`s, one `cx.notify()` for both) would make every one of those ~12 +single-store readers pay for the other store's unrelated refreshes — +exactly what §11 says not to do. Wrapping them in a parent that holds two +child entities (`RepoDirectory { local: Entity, remote: +Entity }`) avoids that specific problem, but then every one of +those same ~12 call sites has to change from `RepoListStore::global(cx)` to +`RepoDirectory::global(cx).read(cx).remote` — an extra hop added everywhere, +in exchange for saving exactly one `Global` wrapper struct. Not a good +trade for a codebase this size. + +**Recommendation:** merge the two **files** into one module +(e.g. `repos.rs`), keeping `LocalReposStore` and `RepoListStore` as two +fully independent structs, each still its own `Entity`/`Global` exactly as +today — same public API, same `global()`/`set_global()` pairs, zero +call-site churn. The merge is justified purely as "these are the app's two +repo-listing stores, they belong next to each other," per the project's own +`.rules` guidance to avoid many small files for closely related logic — +not as a reason to share a notify cycle between two things with almost +entirely disjoint observers. + +--- + +## 14. `crates/workspace` has the same task-list pattern as §6 — and there it's an actual bug + +§6 covers `signed_state`'s 6 stores, where the unpruned-`Vec` pattern +is a style/complexity concern with no observed failure, because +`push_task` always pruned before pushing. `crates/workspace` has the exact +same field-and-push shape in 4 views, but **most of it never prunes**: + +``` +grep -rn "tasks.push(task)" crates/workspace/ → 17 call sites +grep -rn "tasks.retain" crates/workspace/ → 1 call site (pull_request_detail.rs:258) +``` + +- `RepoDetailView.tasks` (`mod.rs:178-179`, doc comment: "finished tasks are + pruned on every push" — **this is stale/incorrect**, no `.retain()` + precedes any of its 11 push sites: `mod.rs:384-388`, `532-536`, `650-654`, + `773-777`, `835-839`, `877-881`, `949-953`, `1061-1065`, `1132-1136`, + `1219-1223`, `1315-1319`). +- `NewPullRequestView.tasks` (`new_pull_request.rs:80-84`): 5 push sites, + none pruned (`445-449`, `475-479`, `697-701`, `894-898`, `997-1001`). +- `CommitDiffView` (`diff.rs:407-411`): 1 push site, not pruned. +- `PullRequestDetailView.tasks` (`pull_request_detail.rs:68-72`): the one + correct one — `load` (`pull_request_detail.rs:256-260`) does + `self.tasks.retain(|task| !task.is_ready()); self.tasks.push(task);`. + +So `RepoDetailView.tasks` and `NewPullRequestView.tasks` grow **unbounded** +for as long as the panel stays open: every file preview, ref switch, commit +load, worktree reload, or fork comparison appends one more `Task` that is +never removed. This is a real memory-growth bug, not just a style +preference — a repo detail panel left open through a long session +accumulates one `Task` per interaction, forever. + +Apply the same fix as §6: delete the `tasks` field from all four views and +`.detach()` (or `.detach_and_log_err(cx)`) at every one of the 17 call +sites. Every continuation already tolerates the view being gone +(`this.update_in(cx, ...).ok()`/`?`, same pattern as `signed_state`), so +nothing here needs cancel-on-drop semantics either. Worth noting +`repo_detail/init_dialog.rs`'s `init_repository` (`init_dialog.rs:187-206`) +already does exactly this — `cx.spawn(...).detach()`, no task list at all — +so the fix is bringing the other 4 views in line with a pattern that +already exists once in the same crate. + +--- + +## 15. `Vec` → `Vec` conversion sprawl — fix the 3 `signed_git` signatures, not the 8 call sites + +`Announcement::clone` is `Vec` (`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 +`signed_git` first stringifies them: + +``` +grep -rn "\.map(ToString::to_string)\.collect" crates/signed_state crates/workspace +``` + +finds it at `repo.rs:1005-1008` (`merge_pull_request`), `repo.rs:1227` +(`clone_to_folder`), `workspace/repo_detail/mod.rs:397` (`load_repo`), +`new_pull_request.rs:595` and `602` (`choose_fork`, twice — once for the +fork, once for the base), and `pull_request_detail.rs:146-149` and +`738-741` (`load`, `clone_urls_of`). Seven call sites, all producing a +`Vec` that gets handed straight to `signed_git::clone_repo`, +`GitCache::ensure_clone`, or `fetch_repo_refs`. + +The root cause is those three functions' signatures, not the call sites. +Verified in `signed_git/src/lib.rs`: + +```rust +pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> { ... } // lib.rs:125 +pub fn ensure_clone(&self, addr: &RepoAddr, clone_urls: &[String]) -> Result<...> // lib.rs:45 +pub fn fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) -> ... // lib.rs:701 +``` + +all three only ever read each URL as `&str` internally, through the shared +`try_each_url(urls: &[String], ...)` helper (`lib.rs:350`), which does +`attempt(url)` where `url: &String` auto-derefs. Checked whether `Url` could +be passed directly instead of allocating a `String` per URL: **yes** — +`url::Url` implements `AsRef` directly (verified in the pinned `url` +crate source, `url-2.5.8/src/lib.rs:2867`). Making the three functions +generic removes the conversion at every call site instead of patching each +one: + +```rust +fn try_each_url, F>(urls: &[U], verb: &str, mut attempt: F) -> Result<()> +where + F: FnMut(&str) -> Result<()>, +{ + for url in urls { + match attempt(url.as_ref()) { /* ... */ } + } + /* ... */ +} + +pub fn clone_repo>(clone_urls: &[U], path: &Path) -> Result<()> { ... } +pub fn ensure_clone>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result { ... } +pub fn fetch_repo_refs>(repo_path: &Path, urls: &[U], refspec: &str) -> Result<()> { ... } +``` + +After this, every one of the 7 call sites above passes `&announcement.clone` +directly (a `&[Url]`), deleting the `.iter().map(ToString::to_string).collect::>()` +line entirely — no allocation, no `Display`-then-reparse round trip, +7 fewer lines of boilerplate for free. (`about.rs`'s `url.to_string()` calls +for on-screen display, `about.rs:55-103`, are unrelated — that's genuine +`Url → SharedString` rendering, not a `signed_git` call, and stays as-is.) + +The `Vec → Vec` conversions for `add_relays`/`add_relay` +(§8) are a separate root cause (`RelayUrl` doesn't implement `AsRef`, +checked `nostr/src/types/url.rs`) and are already fixed by §8's move to +`RelayUrlArg`'s native `From`/`From<&RelayUrl>` — no further +change needed there. + +--- + +## 16. `.clone()` audit: the dense clusters in `backend.rs` are the correct idiom, not a flaw + +Went through every `.clone()` in `create_repository`, `publish_local_repo`, +and `push_repo_from` (the three functions with the highest clone density) +looking for copies that could be replaced by a reference. All of them are +`Client`/`UniversalSigner`/`PathBuf`/`String`/`RelayUrl` values being moved +into a separate `'static async move` block for `cx.background_spawn`, which +Rust's ownership rules require to own its captures — this is exactly the +shadowing-clone pattern the project's own `.rules` file endorses ("Use +variable shadowing to scope clones in async contexts for clarity, minimizing +the lifetime of borrowed references"). `Client` itself is a cheap `Arc` +handle clone (`Client(Arc)`, verified `nostr-sdk/src/client/mod.rs:74`), +so even the frequent `client.clone()`/`signer.clone()` pairs before each +`background_spawn` are not doing a deep copy. No changes recommended here — +noting this so it's clear the dense clone clusters were checked, not +skipped, and found to be inherent to the async-boundary structure rather +than avoidable duplication. + +--- + +## 17. Business logic that leaked into `crates/workspace` and should move to `signed_core`/`signed_state` + +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 +tell "fine to stay in the view" from "should move": read-only git/data +queries that only shape *what one specific view renders* (diffs, commit +lists, tree snapshots — already audited clean in §5/§7) are fine where they +are; anything that **parses a Nostr event's domain tags**, **decides what's +NIP-34-valid/eligible**, or **builds the payload of a mutating operation** +is domain logic and belongs in `signed_core`/`signed_state`, reusable and +testable without GPUI. + +### Confirmed duplicate: `current_commit_of` + +`signed_core/src/model.rs:183-190` (private, used internally by +`pull_request_patches`) and `workspace/repo_detail/pull_request_detail.rs:709-716` +are **the same function, byte-for-byte**: + +```rust +fn current_commit_of(event: &Event) -> Option { + event + .tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()), + _ => None, + }) +} +``` + +It was reimplemented in `workspace` because `signed_core`'s copy is private. +Fix: make `signed_core`'s `current_commit_of` `pub fn`, delete +`workspace`'s copy, import the shared one. + +### A whole cluster of NIP-34 tag parsing lives next to it, same shape, same problem + +Still in `pull_request_detail.rs`, zero GPUI/UI dependency in any of them: + +- `merge_base_of(event: &Event) -> Option` (`pull_request_detail.rs:721-729`) +- `clone_urls_of(event: &Event) -> Option>` (`pull_request_detail.rs:734-742`) +- `branch_name_of(event: &Event) -> Option` (`pull_request_detail.rs:745-753`) +- `latest_update<'a>(events: impl Iterator, root: &Event) -> Option<&'a Event>` (`pull_request_detail.rs:756-766`) — + walks a PR's `GitPullRequestUpdate` events to find the newest revision from + the root's author, the exact same *shape* of problem `signed_core::model::pull_request_patches` + already solves for patch series (`model.rs:95-135`, forward/backward + reply-chain walking). + +These all take a plain `&Event` (or an iterator of them) and return plain +data — nothing here needs `Context`/`Window`/`cx`. They belong next to +`Announcement::from_event`, `parse_state`, and `pull_request_patches` in +`signed_core`, as `pub fn`s with their own unit tests (this file's test +module, `pull_request_detail.rs:840+`, already builds fixture events with a +local `signed()`/`pr_root()` helper — `signed_core`'s test module has the +same fixture-building pattern already; the tests move with the functions, +no new test infrastructure needed). + +### A mutating flow split across the view/store boundary: patch generation in `submit` + +`NewPullRequestView::submit` (`new_pull_request.rs:900-1000`) does this +before calling into the store: + +```rust +let patch = cx.background_spawn({ + /* ... */ + async move { format_patch_between(Path::new(&repo_path), &merge_base, &compare_ref) } +}).await; + +let patch = match patch { + Ok(patch) if !patch.is_empty() => patch, + Ok(_) => { /* "No commits between the branches to propose" */ return Ok(()); } + Err(error) => { /* "Failed to generate the patch: {error}" */ return Ok(()); } +}; + +store.update(cx, |store, cx| { + store.open_pull_request(/* subject, description, branch_name, patch, ... */) +}); +``` + +`RepoStore::open_pull_request` (`repo.rs:554-558`) and `update_pull_request` +(`repo.rs:829-833`) both already take a ready-made `patch: String` — a +reasonable, uniform boundary in general (it's also exactly right for +`pull_request_detail.rs`'s "update PR" dialog, `pull_request_detail.rs:648-700`, +where the patch is literally pasted by the user into a textarea, no git +involved). But for the "compare two branches" flow, *generating* that patch +text — calling `signed_git::format_patch_between`, deciding empty-diff is +an error, and wording that error — is exactly the same kind of "turn git +state into the payload of a Nostr publish" work `Backend::create_repository`/ +`publish_local_repo` already do internally (`worktree_ref_state`, +`root_commit`), just for a different event kind. It shouldn't be the one +case where that responsibility sits in the view instead of the store. + +**Recommendation:** give `RepoStore` (or a free function in `signed_state` +it calls) a method that takes the two refs instead of a ready-made patch, +e.g. `RepoStore::open_pull_request_from_refs(repo_path, base_ref, compare_ref, +subject, description, draft, cx) -> Task>`, which does +the `format_patch_between` + empty-check + `open_pull_request` sequence +internally and returns one descriptive error on failure. `submit` shrinks to +gathering the text-field values and calling it, then closing the panel — +no `signed_git` import needed in `new_pull_request.rs` at all for this path. + +### Borderline, worth doing while touching the same file: `fork_candidates`/`fork_namespace` + +`fork_candidates` (`new_pull_request.rs:117-135`) filters/partitions +`&[Announcement]` into "own" vs. "others" fork sources using the +already-domain `Announcement::is_fork_of` predicate (`signed_core/src/model.rs:281-285`, +correctly reused, not reimplemented) — it's pure data transformation with no +GPUI dependency, and has its own private unit tests in `new_pull_request.rs` +building fixture announcements, again duplicating test-fixture machinery +`signed_core`'s own test module already has. `fork_namespace` +(`new_pull_request.rs:108-114`, formats the `refs/fork//` +namespace string) is the same shape — small, but it's the one place that +convention is decided, and it pairs naturally with `signed_git`'s ref-naming +conventions. Both are safe, low-risk moves to `signed_core`: unlike +`fork_display_name`/`shorten_owner`/`truncate_label`/the `*_source_item` +builders in the same file (genuine presentation logic — `SharedString` +truncation, `PopupMenuItem` construction — correctly left where they are), +these two don't touch a single GPUI type. + +### What's already thin and should stay exactly where it is + +For contrast, checked `RepoDetailView`'s git-touching methods +(`load_repo`, `load_commits`, `switch_ref`, `reload_worktree`, +`catch_up_worktree`, `push_unpushed_checkout`) and `NewPullRequestView::reload_compare` +(`new_pull_request.rs:808-897`, computing `merge_base`/commit +list/diff purely to populate the compare pane): these call `signed_git` +directly too, but only to compute **read-only data this one view renders** +— nothing here is parsed from a Nostr event, decides NIP-34 eligibility, or +builds a publish payload. Moving these into `signed_state` would just add +an indirection layer with no reuse benefit, contradicting "keep it simple." +Same verdict as `create_repo_dialog.rs`'s and `init_dialog.rs`'s handlers +(§9): they already do nothing but gather form input and call one `Backend` +method. + +--- + +## Outcome + +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. + +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>` + `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` (§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>`; 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)` 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. + +### Deviations, corrections, and findings worth keeping + +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: + +- **`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>` field and going generic + over `AsRef` both strip the anchor from call sites with untyped `&[]` + literals, fixed with explicit `Task>` 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. + +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 were left alone.