remove wasm support and drop unused code paths
This commit is contained in:
@@ -23,7 +23,7 @@ static CURRENT_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
/// On Windows, this is `%APPDATA%\Signed`.
|
||||
static CONFIG_DIR: OnceLock<PathBuf> = 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()
|
||||
|
||||
@@ -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<Item = EventId>) -> Vec<Filter> {
|
||||
|
||||
/// 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()])
|
||||
|
||||
@@ -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<L>(event: &Event, lookup: &L) -> Option<EventId>
|
||||
fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
|
||||
where
|
||||
L: Fn(EventId) -> Option<Event>,
|
||||
{
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+22
-511
@@ -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<PathBuf> = 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<String> = 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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Path>) -> 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<Path>) -> 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<D>(signer: UniversalSigner, database: D) -> (Client, UniversalSigner)
|
||||
where
|
||||
D: IntoNostrDatabase,
|
||||
|
||||
@@ -40,10 +40,6 @@ pub enum BackendEvent {
|
||||
/// Repository events arrived: announcements, states, activity and deletions.
|
||||
RepoUpdates(Vec<Update>),
|
||||
Synced,
|
||||
SyncProgress {
|
||||
total: u64,
|
||||
current: u64,
|
||||
},
|
||||
Error(String),
|
||||
}
|
||||
|
||||
@@ -61,7 +57,6 @@ pub struct Backend {
|
||||
signer: UniversalSigner,
|
||||
current_user: Option<PublicKey>,
|
||||
inbox: Entity<Inbox>,
|
||||
sync_progress: Option<(u64, u64)>,
|
||||
/// True when the stored credential is NIP-49 encrypted.
|
||||
passphrase_required: bool,
|
||||
pushing_repos: Entity<HashSet<RepoAddr>>,
|
||||
@@ -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<Self>) {
|
||||
fn restore_session(&mut self, cx: &mut Context<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Result<(), Error>> = 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<Self>) {
|
||||
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<Result<(), Error>> = 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<Self>) {
|
||||
let delete = cx.delete_credentials(USER_KEYRING);
|
||||
|
||||
let task: Task<Result<(), Error>> = 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<Self>) {
|
||||
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<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
||||
fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
||||
where
|
||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||
<T as AsyncGetPublicKey>::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<Filter>, cx: &mut Context<Self>) {
|
||||
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=<nsec>`.
|
||||
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://<host>`.
|
||||
///
|
||||
/// `ws://` grasp servers use `http://<host>`, 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>, Option<String>) {
|
||||
(
|
||||
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");
|
||||
|
||||
@@ -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<Option<Repository>> {
|
||||
}
|
||||
|
||||
/// Open the mirror of `addr`, cloning it first when it does not exist yet.
|
||||
pub fn ensure_repo_mirror<U: AsRef<str>>(addr: &RepoAddr, clone_urls: &[U]) -> Result<Repository> {
|
||||
pub fn ensure_repo_mirror(addr: &RepoAddr, clone_urls: &[Url]) -> Result<Repository> {
|
||||
git_cache().ensure_clone(addr, clone_urls)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,41 +24,6 @@ impl Inbox {
|
||||
self.loaded
|
||||
}
|
||||
|
||||
pub fn mark_read(
|
||||
&mut self,
|
||||
group: &[Event],
|
||||
all: &[Event],
|
||||
me: PublicKey,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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>) {
|
||||
self.state.mark_all_read(all, me, Timestamp::now());
|
||||
self.persist(cx);
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Self>) {
|
||||
fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context<Self>) {
|
||||
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>) {
|
||||
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<Url> = 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<Event> = 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<Result<(), Error>> = 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<Announcement> {
|
||||
@@ -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<Self>,
|
||||
) {
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user