diff --git a/Cargo.lock b/Cargo.lock index 6a182e8..758a67a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -881,12 +881,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "btreecap" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6160c957d8aa33d0a8ba1dbab98e3cb57023ad9374c501441e88559f99e6c4c9" - [[package]] name = "built" version = "0.8.1" @@ -5741,17 +5735,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "nostr-memory" -version = "0.45.2" -source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336" -dependencies = [ - "btreecap", - "nostr", - "nostr-database", - "tokio", -] - [[package]] name = "nostr-sdk" version = "0.45.4" @@ -8178,7 +8161,6 @@ dependencies = [ "nostr-connect", "nostr-gossip-memory", "nostr-lmdb", - "nostr-memory", "nostr-sdk", "webbrowser", ] diff --git a/Cargo.toml b/Cargo.toml index 0a21411..d4287b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,6 @@ settings = { path = "crates/settings" } nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] } nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } -nostr-memory = { git = "https://github.com/rust-nostr/nostr" } nostr-blossom = { git = "https://github.com/rust-nostr/nostr" } nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" } nostr-connect = { git = "https://github.com/rust-nostr/nostr" } diff --git a/crates/paths/src/lib.rs b/crates/paths/src/lib.rs index c5b3da6..b3f82f4 100644 --- a/crates/paths/src/lib.rs +++ b/crates/paths/src/lib.rs @@ -23,7 +23,7 @@ static CURRENT_DATA_DIR: OnceLock = OnceLock::new(); /// On Windows, this is `%APPDATA%\Signed`. static CONFIG_DIR: OnceLock = OnceLock::new(); -pub fn home_dir() -> PathBuf { +fn home_dir() -> PathBuf { dirs::home_dir().expect("failed to determine home directory") } @@ -41,7 +41,7 @@ pub fn documents_dir() -> PathBuf { dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default()) } -pub fn config_dir() -> &'static PathBuf { +fn config_dir() -> &'static PathBuf { CONFIG_DIR.get_or_init(|| { if cfg!(target_os = "windows") { dirs::config_dir() @@ -60,7 +60,7 @@ pub fn config_dir() -> &'static PathBuf { }) } -pub fn data_dir() -> &'static PathBuf { +fn data_dir() -> &'static PathBuf { CURRENT_DATA_DIR.get_or_init(|| { if cfg!(target_os = "macos") { home_dir() diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index c8180a0..01dcaf0 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -18,7 +18,7 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [ ]; /// Kinds that notify a user when they tag them via their `p` tag. -pub const NOTIFICATION_KINDS: [Kind; 8] = [ +const NOTIFICATION_KINDS: [Kind; 8] = [ Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch, @@ -127,7 +127,7 @@ pub fn comments_for(roots: impl IntoIterator) -> Vec { /// NIP-22 comments on our issues, patches and pull requests. /// They are matched via the uppercase `P` and `K` tags, not authorship. -pub fn notification_comments(me: PublicKey) -> Filter { +fn notification_comments(me: PublicKey) -> Filter { Filter::new() .kind(Kind::Comment) .custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()]) diff --git a/crates/signed_core/src/inbox.rs b/crates/signed_core/src/inbox.rs index c848a29..9b88a36 100644 --- a/crates/signed_core/src/inbox.rs +++ b/crates/signed_core/src/inbox.rs @@ -122,7 +122,7 @@ impl InboxItem { /// - NIP-22 comment (1111): uppercase `E` root pointer /// - PR update (1619): uppercase `E` /// - statuses (1630-1633): NIP-10 root `e` -pub fn notification_root(event: &Event, lookup: &L) -> Option +fn notification_root(event: &Event, lookup: &L) -> Option where L: Fn(EventId) -> Option, { @@ -579,94 +579,4 @@ mod tests { vec![issue.id, mine.id, reply.id] ); } - - #[test] - fn mark_all_read_marks_known_recent_events() { - let me = keys(1); - let now = Timestamp::from_secs(1_000_000_000); - let recent = issue(&keys(2), now.as_secs() - 1000); - let old = issue(&keys(2), now.as_secs() - 5 * 24 * 60 * 60); - let ancient = issue(&keys(2), now.as_secs() - 20 * 24 * 60 * 60); - let mine = issue(&keys(1), now.as_secs() - 100); - - let mut state = InboxReadState::default(); - state.mark_all_read( - &[recent.clone(), old.clone(), ancient.clone(), mine.clone()], - me.public_key(), - now, - ); - - assert_eq!(state.read_before, now - MARK_ALL_WINDOW); - assert_eq!(state.read_ids, HashSet::from([recent.id, old.id])); - assert!(state.is_read(&recent)); - assert!(state.is_read(&ancient)); - assert!(!state.is_read(&mine)); - } - - #[test] - fn advance_read_never_moves_the_cutoff_backwards() { - let me = keys(1); - let unread = issue(&keys(2), 1_000); - let all = [unread]; - let now = Timestamp::from_secs(1_000_000_000); - - let mut state = InboxReadState { - read_before: Timestamp::from_secs(999_999_999), - ..Default::default() - }; - state.advance_read(&all, me.public_key(), now); - - assert_eq!(state.read_before, Timestamp::from_secs(999_999_999)); - } - - #[test] - fn advance_read_moves_before_the_oldest_unread_and_prunes_ids() { - let me = keys(1); - let now = Timestamp::from_secs(1_000_000_000); - let five_days = 5 * 24 * 60 * 60; - let old_unread = issue(&keys(2), now.as_secs() - five_days); - // Read ids that fall before and after the new cutoff. - let stale = signed( - &keys(2), - Kind::GitIssue, - Vec::new(), - now.as_secs() - five_days - 1000, - ); - let fresh = signed( - &keys(2), - Kind::GitIssue, - Vec::new(), - now.as_secs() - 100_000, - ); - - let mut state = InboxReadState { - read_ids: HashSet::from([stale.id, fresh.id]), - ..Default::default() - }; - state.advance_read( - &[old_unread.clone(), stale.clone(), fresh.clone()], - me.public_key(), - now, - ); - - assert_eq!(state.read_before, old_unread.created_at - 1); - assert_eq!(state.read_ids, HashSet::from([fresh.id])); - } - - #[test] - fn mark_archived_skips_events_at_or_before_the_cutoff() { - let now = Timestamp::from_secs(1_000_000_000); - let event = issue(&keys(2), now.as_secs() - 1000); - - let mut state = InboxReadState { - archived_before: now, - ..Default::default() - }; - state.mark_archived(&event); - assert!(state.archived_ids.is_empty()); - - let mut state = InboxReadState::default(); - state.mark_archived(&event); - assert_eq!(state.archived_ids, HashSet::from([event.id])); - } } diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index 7232086..645d04c 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -8,10 +8,7 @@ pub mod status; pub use addr::{RepoAddr, identifier_from_name, repo_addr}; pub use deletions::Deletions; -pub use filters::{ - NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications, -}; -pub use inbox::{InboxItem, InboxReadState, group, notification_root}; +pub use inbox::{InboxItem, InboxReadState}; 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, diff --git a/crates/signed_git/src/tests.rs b/crates/signed_git/src/tests.rs index 6f37456..189a708 100644 --- a/crates/signed_git/src/tests.rs +++ b/crates/signed_git/src/tests.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Command; @@ -13,50 +12,6 @@ fn blocks_parent_components() { assert_eq!(sanitize_path_component("a/../b"), "a_.._b"); } -#[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(); - - std::fs::create_dir_all(root.join("plain")).unwrap(); - - std::fs::write(root.join(".gitignore"), "node_modules/\n").unwrap(); - std::fs::create_dir_all(root.join("node_modules/pkg/.git")).unwrap(); - - std::fs::create_dir_all(root.join(".hidden/repo/.git")).unwrap(); - - // A repository inside another, like a submodule worktree, is 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: Vec = find_git_repos(root) - .into_iter() - .map(|repo| repo.path) - .collect(); - 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")]); @@ -80,14 +35,7 @@ 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 server_repo = bare_server(server.path(), "npub1test", "my-repo"); let (dir, repo) = fixture(&[("a.txt", b"one")]); commit_all(&repo, "initial"); @@ -111,15 +59,7 @@ fn push_all_mirrors_branches_and_tags() { #[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()); - + bare_server(server.path(), "npub1test", "my-repo"); let (dir, repo) = fixture(&[("a.txt", b"one")]); commit_all(&repo, "initial"); let dir = dir.path(); @@ -308,60 +248,6 @@ fn head_commit_and_commits_since_track_applied_commits() { ); } -#[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 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"); - - 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 @@ -396,14 +282,7 @@ fn working_copy_cloned_from_the_mirror_matches_head_and_origin() { 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()); + bare_server(dir.path(), "npub1test", "repo"); let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]); commit_all(&work_repo, "initial"); @@ -465,14 +344,7 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { // 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 base_server = bare_server(dir.path(), "npub1base", "base"); let (upstream_dir, upstream_repo) = fixture(&[("a.txt", b"one")]); commit_all(&upstream_repo, "initial"); @@ -505,14 +377,7 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { 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()); + let fork_server = bare_server(dir.path(), "npub1fork", "fork"); push_commit_ref( &fork_work, &format!("file://{}", fork_server.display()), @@ -565,9 +430,9 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { ); } -/// Run a git command in `dir`, asserting success. -fn git_run(dir: &Path, args: &[&str]) { - let status = Command::new("git") +/// Run a git command in `dir` with a fixed test identity, asserting success. +fn git_run(dir: &Path, args: &[&str]) -> std::process::Output { + let output = Command::new("git") .current_dir(dir) .env("GIT_AUTHOR_NAME", "Test Author") .env("GIT_AUTHOR_EMAIL", "test@example.com") @@ -575,324 +440,21 @@ fn git_run(dir: &Path, args: &[&str]) { .env("GIT_COMMITTER_EMAIL", "test@example.com") .env("GIT_EDITOR", "true") .args(args) - .status() + .output() .expect("spawn git"); - assert!(status.success(), "git {args:?} failed"); + assert!(output.status.success(), "git {args:?} failed"); + output } -#[test] -fn worktree_last_commits_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 = worktree_last_commits(dir.path(), &[PathBuf::from("a.txt")]) - .expect("lookup") - .into_iter() - .next() - .expect("found") - .1; - 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) +/// Create a bare `{base}/{owner}/{name}.git` repository, like a grasp server. +fn bare_server(base: &Path, owner: &str, name: &str) -> PathBuf { + let repo = base.join(owner).join(format!("{name}.git")); + std::fs::create_dir_all(repo.parent().expect("parent")).expect("mkdir"); + git_run( + base, + &["init", "--bare", "-q", repo.to_str().expect("utf8 path")], ); -} - -#[test] -fn worktree_last_commits_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 = worktree_last_commits(dir.path(), &[PathBuf::from("a.txt")]) - .expect("lookup") - .into_iter() - .next() - .expect("found") - .1; - assert_eq!( - commit.id, - repo.head_id().expect("head").shorten_or_id().to_string() - ); - assert!(commit.summary.starts_with("Merge branch")); -} - -#[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 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") - ); - - git_run(dir, &["tag", "v1.0"]); - worktree_checkout_tag(dir, "v1.0").expect("checkout tag"); - assert_eq!(current_branch(&repo).expect("branch"), None); - - 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_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_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); + repo } #[test] @@ -971,16 +533,7 @@ fn parses_real_format_patch_output() { 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 output = git_run(dir.path(), &["format-patch", "-1", "--stdout"]); let patch = String::from_utf8(output.stdout).expect("patch is utf-8"); let diff = patch_diffs(&patch).expect("parse real format-patch output"); @@ -1018,48 +571,6 @@ fn parses_real_format_patch_output() { 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)); - - 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)); - - 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_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); + assert_eq!(file.insertions, 0); + assert_eq!(file.deletions, 0); } diff --git a/crates/signed_nostr/Cargo.toml b/crates/signed_nostr/Cargo.toml index 5308c8e..dc6b3f3 100644 --- a/crates/signed_nostr/Cargo.toml +++ b/crates/signed_nostr/Cargo.toml @@ -8,12 +8,7 @@ publish.workspace = true nostr-sdk.workspace = true nostr-connect.workspace = true nostr-gossip-memory.workspace = true +nostr-lmdb.workspace = true anyhow.workspace = true webbrowser.workspace = true - -[target.'cfg(target_arch = "wasm32")'.dependencies] -nostr-memory.workspace = true - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -nostr-lmdb.workspace = true diff --git a/crates/signed_nostr/src/backend.rs b/crates/signed_nostr/src/backend.rs index ee66bfa..5a1f805 100644 --- a/crates/signed_nostr/src/backend.rs +++ b/crates/signed_nostr/src/backend.rs @@ -1,18 +1,13 @@ -#[cfg(not(target_arch = "wasm32"))] use std::path::Path; use std::time::Duration; use anyhow::{Context, Result}; use nostr_gossip_memory::prelude::*; -#[cfg(not(target_arch = "wasm32"))] use nostr_lmdb::prelude::*; -#[cfg(target_arch = "wasm32")] -use nostr_memory::prelude::*; use nostr_sdk::prelude::*; use crate::signer::UniversalSigner; -#[cfg(not(target_arch = "wasm32"))] pub async fn new_backend(db_path: impl AsRef) -> Result<(Client, UniversalSigner)> { let signer = UniversalSigner::new(Keys::generate()); let database = NostrLmdb::open(db_path) @@ -21,13 +16,6 @@ pub async fn new_backend(db_path: impl AsRef) -> Result<(Client, Universal Ok(with_database(signer, database)) } -/// In-memory database on wasm, LMDB is unavailable there. -#[cfg(target_arch = "wasm32")] -pub fn new_backend() -> Result<(Client, UniversalSigner)> { - let signer = UniversalSigner::new(Keys::generate()); - Ok(with_database(signer, MemoryDatabase::unbounded())) -} - fn with_database(signer: UniversalSigner, database: D) -> (Client, UniversalSigner) where D: IntoNostrDatabase, diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 6ef045e..23b85d8 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -40,10 +40,6 @@ pub enum BackendEvent { /// Repository events arrived: announcements, states, activity and deletions. RepoUpdates(Vec), Synced, - SyncProgress { - total: u64, - current: u64, - }, Error(String), } @@ -61,7 +57,6 @@ pub struct Backend { signer: UniversalSigner, current_user: Option, inbox: Entity, - sync_progress: Option<(u64, u64)>, /// True when the stored credential is NIP-49 encrypted. passphrase_required: bool, pushing_repos: Entity>, @@ -160,7 +155,6 @@ impl Backend { signer, current_user: None, inbox: cx.new(|_| Inbox::default()), - sync_progress: None, passphrase_required: false, pushing_repos: cx.new(|_| HashSet::new()), } @@ -170,7 +164,7 @@ impl Backend { /// /// - Emits [`BackendEvent::SignerRequired`] when no credential is stored. /// - Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity. - pub fn restore_session(&mut self, cx: &mut Context) { + fn restore_session(&mut self, cx: &mut Context) { if cfg!(target_arch = "wasm32") { cx.emit(BackendEvent::SignerRequired); return; @@ -913,117 +907,6 @@ impl Backend { }) } - /// Login with an `nsec1...` key or a `bunker://...` URI. - pub fn login(&mut self, credential: &str, cx: &mut Context) { - let credential = credential.trim(); - - if credential.starts_with("nsec1") { - self.login_with_nsec(credential, cx); - } else if credential.starts_with("bunker://") { - self.login_with_bunker(credential, cx); - } else { - cx.emit(BackendEvent::error("Unsupported credential.")); - } - } - - pub fn login_with_new_identity(&mut self, cx: &mut Context) { - let nsec = Keys::generate() - .secret_key() - .to_bech32() - .expect("infallible"); - self.login_with_nsec(&nsec, cx); - } - - pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context) { - let keys = match SecretKey::parse(nsec) { - Ok(secret) => Keys::new(secret), - Err(e) => { - cx.emit(BackendEvent::error(e.to_string())); - return; - } - }; - - let nsec = nsec.trim().to_owned(); - let pubkey = keys.public_key().to_hex(); - let write = cx.write_credentials(USER_KEYRING, &pubkey, nsec.as_bytes()); - - let task: Task> = cx.spawn(async move |this, cx| { - if let Err(e) = write.await { - this.update(cx, |_this, 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. - pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context) { - let uri_string = uri.trim().to_owned(); - - let connect_uri = match NostrConnectUri::parse(&uri_string) { - Ok(uri) => uri, - Err(e) => { - cx.emit(BackendEvent::error(e.to_string())); - return; - } - }; - - let keys = Keys::generate(); - let credential = with_master_key(&uri_string, &keys); - let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes()); - - let task: Task> = cx.spawn(async move |this, cx| { - let result = async { - let mut signer = NostrConnect::new( - connect_uri, - keys, - Duration::from_secs(NOSTR_CONNECT_TIMEOUT), - None, - )?; - signer.auth_url_handler(SignedAuthUrlHandler); - - // Verify the signer before persisting the credential. - signer.get_public_key_async().await?; - write.await?; - - this.update(cx, |this, cx| this.set_signer(signer, cx))?; - - Ok::<_, Error>(()) - } - .await; - - if let Err(e) = result { - this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; - } - - Ok(()) - }); - task.detach(); - } - - pub fn logout(&mut self, cx: &mut Context) { - let delete = cx.delete_credentials(USER_KEYRING); - - let task: Task> = cx.spawn(async move |this, cx| { - delete.await.ok(); - - this.update(cx, |this, cx| { - this.signer.swap_inner(Keys::generate()); - this.current_user = None; - this.passphrase_required = false; - cx.emit(BackendEvent::SignerChanged); - cx.emit(BackendEvent::SignerRequired); - this.sync_inbox(cx); - cx.notify(); - })?; - - Ok(()) - }); - task.detach(); - } - fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context) { let client = self.client.clone(); @@ -1101,11 +984,7 @@ impl Backend { }); } - pub fn sync_progress(&self) -> Option<(u64, u64)> { - self.sync_progress - } - - pub fn set_signer(&mut self, new_signer: T, cx: &mut Context) + fn set_signer(&mut self, new_signer: T, cx: &mut Context) where T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static, ::Error: std::error::Error + Send + Sync + 'static, @@ -1190,46 +1069,14 @@ impl Backend { /// Sync several bootstrap filters in order, within a single task. pub fn sync_bootstraps(&mut self, filters: Vec, cx: &mut Context) { let client = self.client.clone(); - let (tx, mut rx) = SyncProgress::channel(); - - self.sync_progress = Some((0, 0)); - cx.notify(); - - cx.spawn(async move |this, cx| { - let mut last_percent: u64 = 0; - - while rx.changed().await.is_ok() { - let progress = *rx.borrow_and_update(); - let percent = (progress.percentage() * 100.0) as u64; - - if progress.current > 0 && percent != last_percent { - last_percent = percent; - - let alive = this.update(cx, |this, cx| { - this.sync_progress = Some((progress.total, progress.current)); - cx.emit(BackendEvent::SyncProgress { - total: progress.total, - current: progress.current, - }); - cx.notify(); - }); - - if alive.is_err() { - break; - } - } - } - - Ok::<(), anyhow::Error>(()) - }) - .detach(); let sync = cx.background_spawn(async move { let mut first_error = None; for filter in filters { - let opts = SyncOptions::default().progress(tx.clone()); - if let Err(error) = sync_bootstrap_only(&client, filter, opts).await { + if let Err(error) = + sync_bootstrap_only(&client, filter, SyncOptions::default()).await + { first_error.get_or_insert(error); } } @@ -1243,17 +1090,13 @@ impl Backend { cx.spawn(async move |this, cx| { match sync.await { Ok(_) => { - this.update(cx, |this, cx| { - this.sync_progress = None; + this.update(cx, |_this, cx| { cx.emit(BackendEvent::Synced); cx.notify(); })?; } Err(e) => { - this.update(cx, |this, cx| { - this.sync_progress = None; - cx.emit(BackendEvent::error(e.to_string())) - })?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } } @@ -1447,13 +1290,6 @@ pub(crate) async fn sync_bootstrap_only( Ok(output.value) } -/// Embed a NIP-46 session key into a bunker URI as `?master=`. -fn with_master_key(uri: &str, keys: &Keys) -> String { - let separator = if uri.contains('?') { '&' } else { '?' }; - let nsec = keys.secret_key().to_bech32().expect("infallible"); - format!("{uri}{separator}master={nsec}") -} - /// Base URL of a grasp server, `https://`. /// /// `ws://` grasp servers use `http://`, like ngit. @@ -1991,30 +1827,4 @@ mod tests { " ! [rejected] main -> main (non-fast-forward)" )); } - - #[test] - fn push_outcome_reports_partial_failures() { - let outcome = PushOutcome { - servers: vec![ - GraspServerResult::ok(RelayUrl::parse("wss://gitnostr.com").expect("url")), - GraspServerResult::failed( - RelayUrl::parse("wss://relay.ngit.dev").expect("url"), - "remote: ERR authorisation failed: No state events in purgatory\nfatal: ...", - ), - ], - state_event: None, - }; - - assert_eq!(outcome.accepted(), 1); - assert_eq!( - outcome.failure_summary(), - "wss://relay.ngit.dev: remote: ERR authorisation failed: No state events in \ - purgatory fatal: ..." - ); - let warning = outcome.partial_warning().expect("partial push warning"); - assert!(warning.starts_with("Pushed to 1 of 2 grasp servers")); - assert!(warning.contains("Republish to sync")); - // The multi-line server reason is a single display line. - assert_eq!(warning.lines().count(), 1); - } } diff --git a/crates/signed_state/src/checkouts.rs b/crates/signed_state/src/checkouts.rs index 7822c01..0544492 100644 --- a/crates/signed_state/src/checkouts.rs +++ b/crates/signed_state/src/checkouts.rs @@ -715,8 +715,6 @@ pub fn pr_proposes_checkout( mod tests { use std::process::Command; - use signed_core::{RepoAddr, repo_addr}; - use super::*; #[test] @@ -744,103 +742,6 @@ mod tests { assert!(!same_repo_url("/local/path", "/local/other")); } - fn remembered(path: &str, id: &str, last_used: u64) -> Remembered { - Remembered { - path: PathBuf::from(path), - addr: addr(id), - last_used, - } - } - - fn scanned( - path: &str, - origin: Option<&str>, - root: Option<&str>, - ) -> (PathBuf, Option, Option) { - ( - PathBuf::from(path), - origin.map(str::to_owned), - root.map(str::to_owned), - ) - } - - const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; - - fn owner() -> PublicKey { - Keys::new(SecretKey::from_hex(KEY).expect("secret")).public_key() - } - - fn addr(id: &str) -> RepoAddr { - repo_addr(owner(), id) - } - - /// Build one announcement by the fixed test owner. - /// Takes `clone` URLs and an EUC. - fn announcement(id: &str, clones: &[&str], euc: Option<&str>) -> Announcement { - let keys = Keys::new(SecretKey::from_hex(KEY).expect("secret")); - let mut tags = vec![Tag::parse(vec!["d", id]).expect("tag")]; - for url in clones { - tags.push(Tag::parse(vec!["clone", *url]).expect("tag")); - } - if let Some(euc) = euc { - tags.push(Tag::parse(vec!["r", euc, "euc"]).expect("tag")); - } - let event = EventBuilder::new(Kind::GitRepoAnnouncement, "") - .tags(tags) - .finalize(&keys) - .expect("signed"); - Announcement::from_event(&event).expect("parsed") - } - - #[test] - fn resolve_orders_remembered_freshest_first() { - let announcements = vec![announcement("repo", &[], None)]; - let base = addr("repo"); - - let resolved = resolve_associations( - &[ - remembered("/old", "repo", 100), - remembered("/fresh", "repo", 200), - remembered("/other", "unrelated", 300), - ], - &[], - &announcements, - ); - - let paths = resolved.get(&base).expect("associations"); - assert_eq!(paths, &vec![PathBuf::from("/fresh"), PathBuf::from("/old")]); - // Records for repositories without announcements stay inert. - assert_eq!(resolved.len(), 2); - } - - #[test] - fn resolve_deduplicates_paths_remembering_first() { - let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; - let announcements = vec![announcement( - "repo", - &["https://host/npub1x/repo.git"], - Some(euc), - )]; - let base = addr("repo"); - - // The same path is both remembered and scanned, its origin matches. - // The remembered occurrence wins and the path is listed once. - let resolved = resolve_associations( - &[remembered("/shared", "repo", 100)], - &[ - scanned("/shared", Some("https://host/npub1x/repo"), None), - scanned("/scanned-only", Some("https://host/npub1x/repo.git"), None), - ], - &announcements, - ); - - let paths = resolved.get(&base).expect("associations"); - assert_eq!( - paths, - &vec![PathBuf::from("/shared"), PathBuf::from("/scanned-only")] - ); - } - #[test] fn checkout_status_reports_ahead_branches_only() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/signed_state/src/git_store.rs b/crates/signed_state/src/git_store.rs index 00cc6da..141a127 100644 --- a/crates/signed_state/src/git_store.rs +++ b/crates/signed_state/src/git_store.rs @@ -3,6 +3,7 @@ use std::sync::OnceLock; use anyhow::Result; use gix::Repository; +use nostr::prelude::Url; use signed_core::RepoAddr; use signed_git::GitCache; @@ -30,7 +31,7 @@ pub fn open_repo_mirror(addr: &RepoAddr) -> Result> { } /// Open the mirror of `addr`, cloning it first when it does not exist yet. -pub fn ensure_repo_mirror>(addr: &RepoAddr, clone_urls: &[U]) -> Result { +pub fn ensure_repo_mirror(addr: &RepoAddr, clone_urls: &[Url]) -> Result { git_cache().ensure_clone(addr, clone_urls) } diff --git a/crates/signed_state/src/inbox.rs b/crates/signed_state/src/inbox.rs index aed4baa..52839cf 100644 --- a/crates/signed_state/src/inbox.rs +++ b/crates/signed_state/src/inbox.rs @@ -24,41 +24,6 @@ impl Inbox { self.loaded } - pub fn mark_read( - &mut self, - group: &[Event], - all: &[Event], - me: PublicKey, - cx: &mut Context, - ) { - for event in group { - self.state.mark_read(event); - } - self.state.advance_read(all, me, Timestamp::now()); - self.persist(cx); - cx.notify(); - } - - /// Archived events are always read too. - pub fn mark_archived( - &mut self, - group: &[Event], - all: &[Event], - me: PublicKey, - cx: &mut Context, - ) { - for event in group { - self.state.mark_archived(event); - self.state.mark_read(event); - } - - let now = Timestamp::now(); - self.state.advance_archived(all, me, now); - self.state.advance_read(all, me, now); - self.persist(cx); - cx.notify(); - } - pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context) { self.state.mark_all_read(all, me, Timestamp::now()); self.persist(cx); diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 323577f..c8fadb5 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -16,13 +16,13 @@ use git_store::set_git_cache; pub use git_store::{ensure_repo_mirror, open_repo_mirror, repo_mirror_path}; use gpui::{App, AppContext}; pub use inbox::{Inbox, query_inbox}; -pub use local_repos::{LocalReposStore, ResolvedLocalRepo, local_repo_addr, resolve_local_repos}; +pub use local_repos::{LocalReposStore, ResolvedLocalRepo, resolve_local_repos}; pub use nostr_sdk::prelude::Timestamp; pub use profile::{Profile, ProfileStore}; pub use refresh::{RefreshGate, RefreshRequest}; pub use repo::RepoStore; -pub use repos::{RepoActivityCounts, RepoListStore}; -pub use signed_git::{GraspSignals, LocalRepo, Nip34Binding, Nip34Kind}; +pub use repos::RepoListStore; +pub use signed_git::{Nip34Binding, Nip34Kind}; use signed_nostr::new_backend; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/signed_state/src/local_repos.rs b/crates/signed_state/src/local_repos.rs index 2a072e3..19bfbdd 100644 --- a/crates/signed_state/src/local_repos.rs +++ b/crates/signed_state/src/local_repos.rs @@ -235,56 +235,4 @@ mod tests { assert_eq!(resolved.len(), 1); assert_eq!(resolved[0].announcement.as_ref(), Some(&known)); } - - #[test] - fn an_unmatched_repository_keeps_its_binding() { - let repo = bound(KEY, "unlisted"); - - let resolved = resolve_local_repos(&[repo], &[], &[]); - - assert_eq!(resolved.len(), 1); - assert!(resolved[0].announcement.is_none()); - assert_eq!( - resolved[0].nip34.as_ref().map(|binding| binding.kind), - Some(Nip34Kind::Initialized) - ); - } - - #[test] - fn a_plain_repository_is_kept_without_a_binding() { - let repo = LocalRepo { - path: PathBuf::from("plain"), - nip34: None, - }; - - let resolved = resolve_local_repos(&[repo], &[], &[]); - - assert_eq!(resolved.len(), 1); - assert!(resolved[0].nip34.is_none()); - assert!(resolved[0].announcement.is_none()); - } - - #[test] - fn the_name_is_the_directory_name() { - let repo = LocalRepo { - path: PathBuf::from("/tmp/my-repo"), - nip34: None, - }; - - let resolved = resolve_local_repos(&[repo], &[], &[]); - - assert_eq!(resolved[0].name(), SharedString::from("my-repo")); - } - - #[test] - fn a_path_without_a_directory_name_is_untitled() { - let repo = LocalRepo { - path: PathBuf::from("/"), - nip34: None, - }; - - let resolved = resolve_local_repos(&[repo], &[], &[]); - - assert_eq!(resolved[0].name(), SharedString::from("Untitled")); - } } diff --git a/crates/signed_state/src/refresh.rs b/crates/signed_state/src/refresh.rs index 695cb7c..e296d72 100644 --- a/crates/signed_state/src/refresh.rs +++ b/crates/signed_state/src/refresh.rs @@ -42,48 +42,3 @@ impl RefreshGate { self.running = false; } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_request_while_running_folds_into_a_follow_up() { - let mut gate = RefreshGate::default(); - gate.begin(); - - assert_eq!(gate.request(), RefreshRequest::Fold); - assert!(gate.finish()); - } - - #[test] - fn a_request_without_a_run_schedules() { - let mut gate = RefreshGate::default(); - - assert_eq!(gate.request(), RefreshRequest::Schedule); - assert!(!gate.running()); - } - - #[test] - fn a_request_after_a_run_schedules_again() { - let mut gate = RefreshGate::default(); - gate.begin(); - assert_eq!(gate.request(), RefreshRequest::Fold); - assert!(gate.finish()); - - assert_eq!(gate.request(), RefreshRequest::Schedule); - } - - #[test] - fn abort_keeps_the_pending_request() { - let mut gate = RefreshGate::default(); - gate.begin(); - assert_eq!(gate.request(), RefreshRequest::Fold); - - gate.abort(); - assert!(!gate.running()); - - gate.begin(); - assert!(gate.finish()); - } -} diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index ffd9623..0174abd 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -9,8 +9,7 @@ use nostr::event::IntoEventBuilder; use nostr_sdk::prelude::*; use settings::{EventFetchingStrategy, SettingsStore}; use signed_core::{ - Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch, - pull_request_patches, + Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patches, }; use signed_git::Nip34Binding; use signed_nostr::UniversalSigner; @@ -20,7 +19,6 @@ use crate::backend::{ user_grasp_list_servers, }; use crate::checkouts::CheckoutsStore; -use crate::git_store::ensure_repo_mirror; use crate::repos::RepoListStore; /// Maximum size of one patch event. @@ -657,7 +655,7 @@ impl RepoStore { /// Reply to `parent`, a comment on `root`, with a NIP-22 threaded comment. /// /// `None` publishes a top-level comment on the root itself. - pub fn reply( + fn reply( &mut self, root: &Event, parent: Option<&Event>, @@ -1151,7 +1149,7 @@ impl RepoStore { /// Set the status of a root event. /// /// Only the root author or a maintainer may set it, per NIP-34. - pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context) { + fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context) { self.last_error = None; let Some(addr) = self.addr.clone() else { @@ -1192,88 +1190,6 @@ impl RepoStore { self.publish(builder, cx); } - pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context) { - self.last_error = None; - self.last_warning = None; - - let Some(addr) = self.addr.clone() else { - self.not_announced(cx); - return; - }; - - let is_author = Backend::global(cx) - .read(cx) - .current_user() - .is_some_and(|user| self.is_author(&user)); - if !is_author { - self.last_error = Some("Only the repository author can merge pull requests".into()); - return; - } - - let clone_urls: Vec = self - .announcement - .as_ref() - .map(|a| a.clone.clone()) - .unwrap_or_default(); - - let patch = pull_request_patch(root, self.patches.iter()); - - // The applied patch events, for the status tags below. - let patches: Vec = pull_request_patches(root, self.patches.iter()) - .into_iter() - .cloned() - .collect(); - - let relay_hint = self - .announcement - .as_ref() - .and_then(|a| a.relays.first()) - .map(ToString::to_string) - .unwrap_or_default(); - - let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); - let root = root.clone(); - - let apply = cx.background_spawn(async move { - let repo = ensure_repo_mirror(&addr, &clone_urls)?; - let workdir = repo - .workdir() - .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? - .to_path_buf(); - // The commits the apply created. - // Everything between the previous HEAD and the new one, oldest first. - let previous = signed_git::head_commit_id(&workdir)?; - signed_git::apply_patch(&workdir, &patch)?; - let applied = signed_git::commits_since(&workdir, previous.as_deref())?; - Ok::<_, Error>(applied) - }); - - let task: Task> = cx.spawn(async move |this, cx| { - match apply.await { - Ok(applied) => { - this.update(cx, |this, cx| { - this.publish_applied_status( - &root, - &patches, - &applied, - &relay_hint, - euc.as_deref(), - cx, - ); - })?; - } - Err(e) => { - this.update(cx, |this, cx| { - this.last_error = Some(e.to_string()); - cx.notify(); - })?; - } - } - Ok(()) - }); - self.tasks.push(task); - } - /// The latest announcement of this repository, /// for operations that need its clone URLs and relays. fn action_announcement(&self, cx: &App) -> Option { @@ -1495,66 +1411,6 @@ impl RepoStore { Task::ready(Err(anyhow::anyhow!("{message}"))) } - /// Publish a kind-1631 Applied status event for `root` after a merge. - fn publish_applied_status( - &mut self, - root: &Event, - patches: &[Event], - applied: &[String], - relay_hint: &str, - euc: Option<&str>, - cx: &mut Context, - ) { - let Some(addr) = self.addr.clone() else { - return; - }; - - let mut tags = vec![ - Tag::parse(["e", &root.id.to_hex(), "", "root"]).expect("valid root tag"), - Tag::public_key(addr.public_key), - Tag::public_key(root.pubkey), - Tag::coordinate(addr, None), - ]; - - if let Some(euc) = euc - && let Ok(tag) = Tag::parse(["r", euc]) - { - tags.push(tag); - } - - // Tag each applied patch event. - // `q` per event, `e` reply for events beyond the root, chain parts and revisions. - // Their statuses then resolve to Applied too. - for (ix, patch) in patches.iter().enumerate() { - if let Ok(tag) = - Tag::parse(["q", &patch.id.to_hex(), relay_hint, &patch.pubkey.to_hex()]) - { - tags.push(tag); - } - if ix > 0 - && let Ok(tag) = Tag::parse(["e", &patch.id.to_hex(), "", "reply"]) - { - tags.push(tag); - } - } - - // The commits `git am` created on top of the previous HEAD. - if !applied.is_empty() { - let mut applied_tag = vec!["applied-as-commits".to_string()]; - applied_tag.extend(applied.iter().cloned()); - if let Ok(tag) = Tag::parse(applied_tag) { - tags.push(tag); - } - for commit in applied { - if let Ok(tag) = Tag::parse(["r", commit]) { - tags.push(tag); - } - } - } - - self.publish(EventBuilder::new(Kind::GitStatusApplied, "").tags(tags), cx); - } - /// Sign `builder`, broadcast it and track the outcome in [`Self::last_error`]. /// /// Every one-shot repository event (issue, comment, status) goes through diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 825b873..f0485dd 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,7 +1,8 @@ use dock::{DockArea, DockEvent, DockLayout, DockPlacement, SignedDockSkin, panel_handle}; use gpui::prelude::*; use gpui::{Context, Entity, Render, Subscription, Window, div, px}; -use gpui_component::{Root, StyledExt, Theme}; +use gpui_component::notification::NotificationType; +use gpui_component::{Root, StyledExt, Theme, WindowExt}; use settings::{AppearanceMode, SettingsStore}; use signed_state::{Backend, BackendEvent}; @@ -66,6 +67,10 @@ impl Workspace { if matches!(event, BackendEvent::PassphraseRequired) { passphrase_dialog::open(window, cx); } + + if let BackendEvent::Error(message) = event { + window.push_notification((NotificationType::Error, message.clone()), cx); + } }, ));