fix init
This commit is contained in:
@@ -2,12 +2,38 @@ use nostr::prelude::*;
|
|||||||
|
|
||||||
/// Address of a NIP-34 repository announcement: `30617:<owner-pubkey>:<repo-id>`.
|
/// Address of a NIP-34 repository announcement: `30617:<owner-pubkey>:<repo-id>`.
|
||||||
///
|
///
|
||||||
/// The Rust Nostr SDK's [`Coordinate`] already provides parsing, formatting
|
/// The Rust Nostr SDK's [`Coordinate`] already provides parsing, formatting and hashing for this.
|
||||||
/// and hashing for this; the alias keeps the repository-specific vocabulary
|
/// The alias keeps the repository-specific vocabulary while reusing the SDK type.
|
||||||
/// while reusing the SDK type.
|
|
||||||
pub type RepoAddr = Coordinate;
|
pub type RepoAddr = Coordinate;
|
||||||
|
|
||||||
/// Build the address of a NIP-34 repository announcement.
|
/// Build the address of a NIP-34 repository announcement.
|
||||||
pub fn repo_addr(owner: PublicKey, id: impl Into<String>) -> RepoAddr {
|
pub fn repo_addr(owner: PublicKey, id: impl Into<String>) -> RepoAddr {
|
||||||
Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(id)
|
Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Derive a repository identifier from a display name
|
||||||
|
pub fn identifier_from_name(name: &str) -> String {
|
||||||
|
name.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '/' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identifier_from_name_slugs_like_gitworkshop() {
|
||||||
|
assert_eq!(identifier_from_name("My Repo"), "My-Repo");
|
||||||
|
assert_eq!(identifier_from_name("my-repo"), "my-repo");
|
||||||
|
assert_eq!(identifier_from_name("Foo_Bar!"), "Foo-Bar-");
|
||||||
|
assert_eq!(identifier_from_name("a/b"), "a/b");
|
||||||
|
assert_eq!(identifier_from_name("Café"), "Caf-");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ pub mod model;
|
|||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
|
|
||||||
pub use addr::{RepoAddr, repo_addr};
|
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
||||||
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
|
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
|
||||||
pub use clone_url::{CloneTarget, parse_clone_url};
|
pub use clone_url::{CloneTarget, parse_clone_url};
|
||||||
pub use comments::{CommentThread, comment_threads};
|
pub use comments::{CommentThread, comment_threads};
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
//! Blocking local git operations against GRASP servers.
|
|
||||||
//!
|
|
||||||
//! All functions may block; call them inside `cx.background_spawn`.
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -256,16 +252,15 @@ pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<Str
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
let commit = git_in(path, &["rev-parse", "HEAD"])?;
|
let commit = git_in(path, &["rev-parse", "HEAD"])?;
|
||||||
|
|
||||||
if commit.len() != 40 {
|
if commit.len() != 40 {
|
||||||
bail!("unexpected initial commit id: {commit}");
|
bail!("unexpected initial commit id: {commit}");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(commit)
|
Ok(commit)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push the `main` branch of the repository at `repo_path` to a grasp
|
/// Push the `main` branch of the repository at `repo_path` to a grasp server.
|
||||||
/// server. Grasp servers speak git smart HTTP; the repository lives at
|
|
||||||
/// `{base_url}/{owner}/{repo-id}.git` (the same path their `clone` URLs
|
|
||||||
/// announce, per the GRASP protocol).
|
|
||||||
pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||||
let url = format!("{base_url}/{owner}/{repo_id}.git");
|
let url = format!("{base_url}/{owner}/{repo_id}.git");
|
||||||
|
|
||||||
@@ -289,17 +284,17 @@ pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push every local branch and tag of the repository at `repo_path` to a
|
/// Push every local branch and tag of the repository at `repo_path` to a grasp server,
|
||||||
/// grasp server (like `git push <url> --all --tags`), so an initialized
|
/// so an initialized repository's whole history is mirrored.
|
||||||
/// repository's whole history is mirrored, not just `main`.
|
|
||||||
pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||||
let url = format!("{base_url}/{owner}/{repo_id}.git");
|
let url = format!("{base_url}/{owner}/{repo_id}.git");
|
||||||
|
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(repo_path)
|
.arg(repo_path)
|
||||||
.args(["push", "--all", "--tags"])
|
.args(["push"])
|
||||||
.arg(&url)
|
.arg(&url)
|
||||||
|
.args(["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"])
|
||||||
.env("GIT_TERMINAL_PROMPT", "0")
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.output()
|
.output()
|
||||||
@@ -311,6 +306,7 @@ pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) ->
|
|||||||
String::from_utf8_lossy(&output.stderr).trim()
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1729,6 +1725,68 @@ mod tests {
|
|||||||
assert_eq!(root_commit(workdir).expect("root"), None);
|
assert_eq!(root_commit(workdir).expect("root"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn push_all_mirrors_branches_and_tags() {
|
||||||
|
// A bare "server" repository reachable via a `file://` URL, like a
|
||||||
|
// grasp server's `{base}/{owner}/{repo-id}.git` layout.
|
||||||
|
let server = tempfile::tempdir().unwrap();
|
||||||
|
let server_repo = server.path().join("npub1test").join("my-repo.git");
|
||||||
|
std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap();
|
||||||
|
let init_status = Command::new("git")
|
||||||
|
.args(["init", "--bare", "-q"])
|
||||||
|
.arg(&server_repo)
|
||||||
|
.status()
|
||||||
|
.expect("spawn git init --bare");
|
||||||
|
assert!(init_status.success());
|
||||||
|
|
||||||
|
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||||
|
commit_all(&repo, "initial");
|
||||||
|
let dir = dir.path();
|
||||||
|
|
||||||
|
// Two branches plus a tag are all mirrored.
|
||||||
|
git_run(dir, &["checkout", "-b", "feature"]);
|
||||||
|
std::fs::write(dir.join("b.txt"), b"two").expect("write");
|
||||||
|
commit_all(&repo, "feature work");
|
||||||
|
git_run(dir, &["checkout", "-"]);
|
||||||
|
git_run(dir, &["tag", "v1.0"]);
|
||||||
|
|
||||||
|
let base_url = format!("file://{}", server.path().display());
|
||||||
|
push_all(dir, &base_url, "npub1test", "my-repo").expect("push");
|
||||||
|
|
||||||
|
let refs = git_in(&server_repo, &["show-ref"]).expect("server refs");
|
||||||
|
assert!(refs.contains("refs/heads/main"));
|
||||||
|
assert!(refs.contains("refs/heads/feature"));
|
||||||
|
assert!(refs.contains("refs/tags/v1.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn push_all_tolerates_a_missing_ref_kind() {
|
||||||
|
// A repository with only tags (no branches) still pushes: wildcard
|
||||||
|
// refspecs without a local match are ignored.
|
||||||
|
let server = tempfile::tempdir().unwrap();
|
||||||
|
let server_repo = server.path().join("npub1test").join("my-repo.git");
|
||||||
|
std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap();
|
||||||
|
let init_status = Command::new("git")
|
||||||
|
.args(["init", "--bare", "-q"])
|
||||||
|
.arg(&server_repo)
|
||||||
|
.status()
|
||||||
|
.expect("spawn git init --bare");
|
||||||
|
assert!(init_status.success());
|
||||||
|
|
||||||
|
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||||
|
commit_all(&repo, "initial");
|
||||||
|
let dir = dir.path();
|
||||||
|
git_run(dir, &["tag", "v1.0"]);
|
||||||
|
git_run(dir, &["update-ref", "-d", "refs/heads/main"]);
|
||||||
|
|
||||||
|
let base_url = format!("file://{}", server.path().display());
|
||||||
|
push_all(dir, &base_url, "npub1test", "my-repo").expect("push");
|
||||||
|
|
||||||
|
let refs = git_in(&server_repo, &["show-ref"]).expect("server refs");
|
||||||
|
assert!(refs.contains("refs/tags/v1.0"));
|
||||||
|
assert!(!refs.contains("refs/heads/"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repo_ref_state_lists_branches_tags_and_head() {
|
fn repo_ref_state_lists_branches_tags_and_head() {
|
||||||
let (_dir, repo) = fixture(&[("a.txt", b"hello")]);
|
let (_dir, repo) = fixture(&[("a.txt", b"hello")]);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use nostr::event::IntoEventBuilder;
|
|||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use nostr_sdk::client::SyncSummary;
|
use nostr_sdk::client::SyncSummary;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use signed_core::{Announcement, build_state, filters, repo_addr};
|
use signed_core::{Announcement, build_state, filters, identifier_from_name, repo_addr};
|
||||||
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
|
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
|
||||||
|
|
||||||
use crate::git_store::GitStore;
|
use crate::git_store::GitStore;
|
||||||
@@ -1237,21 +1237,6 @@ fn with_master_key(uri: &str, keys: &Keys) -> String {
|
|||||||
format!("{uri}{separator}master={nsec}")
|
format!("{uri}{separator}master={nsec}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derive a repository identifier (d-tag) from the repo name, matching ngit
|
|
||||||
/// and gitworkshop: spaces become hyphens, other non-alphanumeric characters
|
|
||||||
/// (except `/`) become hyphens, case is preserved.
|
|
||||||
fn identifier_from_name(name: &str) -> String {
|
|
||||||
name.chars()
|
|
||||||
.map(|c| {
|
|
||||||
if c.is_ascii_alphanumeric() || c == '/' {
|
|
||||||
c
|
|
||||||
} else {
|
|
||||||
'-'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `https://<host>` (or `http://<host>` for `ws://` grasp servers, like
|
/// A `https://<host>` (or `http://<host>` for `ws://` grasp servers, like
|
||||||
/// ngit) base URL for a grasp server. The repository then lives at
|
/// ngit) base URL for a grasp server. The repository then lives at
|
||||||
/// `{base}/{npub}/{repo-id}.git`.
|
/// `{base}/{npub}/{repo-id}.git`.
|
||||||
@@ -1336,15 +1321,6 @@ fn extract_master_key(credential: &str) -> (&str, Keys) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn identifier_from_name_slugs_like_gitworkshop() {
|
|
||||||
assert_eq!(identifier_from_name("My Repo"), "My-Repo");
|
|
||||||
assert_eq!(identifier_from_name("my-repo"), "my-repo");
|
|
||||||
assert_eq!(identifier_from_name("Foo_Bar!"), "Foo-Bar-");
|
|
||||||
assert_eq!(identifier_from_name("a/b"), "a/b");
|
|
||||||
assert_eq!(identifier_from_name("Café"), "Caf-");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn grasp_base_url_maps_schemes_like_ngit() {
|
fn grasp_base_url_maps_schemes_like_ngit() {
|
||||||
let wss = RelayUrl::parse("wss://relay.ngit.dev").expect("url");
|
let wss = RelayUrl::parse("wss://relay.ngit.dev").expect("url");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
@@ -52,6 +52,21 @@ impl LocalReposStore {
|
|||||||
store
|
store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forget a repository that has just been published to NIP-34, so it
|
||||||
|
/// leaves the local list immediately. A later rescan re-discovers it
|
||||||
|
/// from disk; the sidebar additionally hides published repositories by
|
||||||
|
/// identifier.
|
||||||
|
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
|
||||||
|
self.repos = Arc::new(
|
||||||
|
self.repos
|
||||||
|
.iter()
|
||||||
|
.filter(|repo| repo.as_path() != path)
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-run the scan. Requests that arrive while a scan is running are
|
/// Re-run the scan. Requests that arrive while a scan is running are
|
||||||
/// folded into one follow-up scan; the results replace the list atomically.
|
/// folded into one follow-up scan; the results replace the list atomically.
|
||||||
pub fn rescan(&mut self, cx: &mut Context<Self>) {
|
pub fn rescan(&mut self, cx: &mut Context<Self>) {
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ pub fn open(
|
|||||||
.label("Repository name")
|
.label("Repository name")
|
||||||
.description("Max 100 characters")
|
.description("Max 100 characters")
|
||||||
.required(true)
|
.required(true)
|
||||||
.child(Input::new(&name_input)),
|
.child(Input::new(&name_input).readonly(true)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
field()
|
field()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ use gpui_component::{
|
|||||||
use nostr::prelude::{RelayUrl, ToBech32};
|
use nostr::prelude::{RelayUrl, ToBech32};
|
||||||
use signed_core::Announcement;
|
use signed_core::Announcement;
|
||||||
use signed_git::{CommitList, FileCommit};
|
use signed_git::{CommitList, FileCommit};
|
||||||
use signed_state::{GitStore, ProfileStore, RepoStore};
|
use signed_state::{GitStore, LocalReposStore, ProfileStore, RepoStore};
|
||||||
|
|
||||||
use crate::image_cache::{MAX_IMAGES, image_cache};
|
use crate::image_cache::{MAX_IMAGES, image_cache};
|
||||||
use crate::pixel_avatar::PixelAvatar;
|
use crate::pixel_avatar::PixelAvatar;
|
||||||
@@ -1530,6 +1530,11 @@ impl RepoDetailView {
|
|||||||
announcement: Announcement,
|
announcement: Announcement,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
|
// The repository is no longer a bare local repo: drop it from the
|
||||||
|
// scan results so it leaves the sidebar's local section immediately.
|
||||||
|
if let Some(path) = self.local_path.take() {
|
||||||
|
LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx));
|
||||||
|
}
|
||||||
let store =
|
let store =
|
||||||
cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx));
|
cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx));
|
||||||
// Re-render when the store refreshes (issues, PRs, statuses).
|
// Re-render when the store refreshes (issues, PRs, statuses).
|
||||||
@@ -1537,7 +1542,6 @@ impl RepoDetailView {
|
|||||||
.push(cx.observe(&store, |_this, _store, cx| cx.notify()));
|
.push(cx.observe(&store, |_this, _store, cx| cx.notify()));
|
||||||
self.store = Some(store);
|
self.store = Some(store);
|
||||||
self.initial = Some(announcement);
|
self.initial = Some(announcement);
|
||||||
self.local_path = None;
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -18,7 +19,7 @@ use gpui_component::avatar::Avatar;
|
|||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
use gpui_component::input::InputState;
|
use gpui_component::input::InputState;
|
||||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
||||||
use signed_core::Announcement;
|
use signed_core::{Announcement, identifier_from_name};
|
||||||
use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore, RepoListStore};
|
use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore, RepoListStore};
|
||||||
|
|
||||||
use super::{RepoDetailView, RepoListView};
|
use super::{RepoDetailView, RepoListView};
|
||||||
@@ -250,6 +251,23 @@ impl SidebarPanel {
|
|||||||
)
|
)
|
||||||
.when_some(store, |builder, store| {
|
.when_some(store, |builder, store| {
|
||||||
let announcements = store.read(cx).announcements.clone();
|
let announcements = store.read(cx).announcements.clone();
|
||||||
|
// Local repositories that have already been published to
|
||||||
|
// NIP-34 are listed among the user's repositories above;
|
||||||
|
// hide them from the local section (matched by the
|
||||||
|
// identifier derived from the directory name, like the
|
||||||
|
// init dialog's default name).
|
||||||
|
let announced_ids: HashSet<String> =
|
||||||
|
announcements.iter().map(|a| a.id.clone()).collect();
|
||||||
|
let local_repos: Vec<PathBuf> = local_repos
|
||||||
|
.iter()
|
||||||
|
.filter(|path| {
|
||||||
|
let Some(name) = path.file_name() else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
!announced_ids.contains(&identifier_from_name(&name.to_string_lossy()))
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
// One merged list: the user's NIP-34 repositories first,
|
// One merged list: the user's NIP-34 repositories first,
|
||||||
// then the local repositories discovered by the scan.
|
// then the local repositories discovered by the scan.
|
||||||
let total = announcements.len() + local_repos.len();
|
let total = announcements.len() + local_repos.len();
|
||||||
|
|||||||
Reference in New Issue
Block a user