From a2ef6bb1a353509fd51405c0a95bc5f502c9ee81 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 31 Aug 2026 13:37:02 +0700 Subject: [PATCH] fix init --- crates/signed_core/src/addr.rs | 32 +++++++- crates/signed_core/src/lib.rs | 2 +- crates/signed_git/src/lib.rs | 82 ++++++++++++++++--- crates/signed_state/src/backend.rs | 26 +----- crates/signed_state/src/local_repos.rs | 17 +++- .../src/views/repo_detail/init_dialog.rs | 2 +- crates/workspace/src/views/repo_detail/mod.rs | 8 +- crates/workspace/src/views/sidebar/mod.rs | 20 ++++- 8 files changed, 143 insertions(+), 46 deletions(-) diff --git a/crates/signed_core/src/addr.rs b/crates/signed_core/src/addr.rs index 0a7bc51..8617de8 100644 --- a/crates/signed_core/src/addr.rs +++ b/crates/signed_core/src/addr.rs @@ -2,12 +2,38 @@ use nostr::prelude::*; /// Address of a NIP-34 repository announcement: `30617::`. /// -/// The Rust Nostr SDK's [`Coordinate`] already provides parsing, formatting -/// and hashing for this; the alias keeps the repository-specific vocabulary -/// while reusing the SDK type. +/// The Rust Nostr SDK's [`Coordinate`] already provides parsing, formatting and hashing for this. +/// The alias keeps the repository-specific vocabulary while reusing the SDK type. pub type RepoAddr = Coordinate; /// Build the address of a NIP-34 repository announcement. pub fn repo_addr(owner: PublicKey, id: impl Into) -> RepoAddr { 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-"); + } +} diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index a19d715..b784804 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -8,7 +8,7 @@ pub mod model; pub mod state; 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 clone_url::{CloneTarget, parse_clone_url}; pub use comments::{CommentThread, comment_threads}; diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 6d73636..33a2ab2 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -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::io::Write; use std::path::{Path, PathBuf}; @@ -256,16 +252,15 @@ pub fn init_repository(path: &Path, name: &str, description: &str) -> Result Result<()> { 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(()) } -/// Push every local branch and tag of the repository at `repo_path` to a -/// grasp server (like `git push --all --tags`), so an initialized -/// repository's whole history is mirrored, not just `main`. +/// Push every local branch and tag of the repository at `repo_path` to a grasp server, +/// so an initialized repository's whole history is mirrored. 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 output = Command::new("git") .arg("-C") .arg(repo_path) - .args(["push", "--all", "--tags"]) + .args(["push"]) .arg(&url) + .args(["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"]) .env("GIT_TERMINAL_PROMPT", "0") .stderr(Stdio::piped()) .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() ); } + Ok(()) } @@ -1729,6 +1725,68 @@ mod tests { 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] fn repo_ref_state_lists_branches_tags_and_head() { let (_dir, repo) = fixture(&[("a.txt", b"hello")]); diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 46eb9a3..4deedb5 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -10,7 +10,7 @@ use nostr::event::IntoEventBuilder; use nostr_connect::prelude::*; use nostr_sdk::client::SyncSummary; 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 crate::git_store::GitStore; @@ -1237,21 +1237,6 @@ fn with_master_key(uri: &str, keys: &Keys) -> String { 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://` (or `http://` for `ws://` grasp servers, like /// ngit) base URL for a grasp server. The repository then lives at /// `{base}/{npub}/{repo-id}.git`. @@ -1336,15 +1321,6 @@ fn extract_master_key(credential: &str) -> (&str, Keys) { 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-"); - } - #[test] fn grasp_base_url_maps_schemes_like_ngit() { let wss = RelayUrl::parse("wss://relay.ngit.dev").expect("url"); diff --git a/crates/signed_state/src/local_repos.rs b/crates/signed_state/src/local_repos.rs index 23c42ab..934420a 100644 --- a/crates/signed_state/src/local_repos.rs +++ b/crates/signed_state/src/local_repos.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Error; @@ -52,6 +52,21 @@ impl LocalReposStore { 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.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 /// folded into one follow-up scan; the results replace the list atomically. pub fn rescan(&mut self, cx: &mut Context) { diff --git a/crates/workspace/src/views/repo_detail/init_dialog.rs b/crates/workspace/src/views/repo_detail/init_dialog.rs index e1c420c..09c9f09 100644 --- a/crates/workspace/src/views/repo_detail/init_dialog.rs +++ b/crates/workspace/src/views/repo_detail/init_dialog.rs @@ -84,7 +84,7 @@ pub fn open( .label("Repository name") .description("Max 100 characters") .required(true) - .child(Input::new(&name_input)), + .child(Input::new(&name_input).readonly(true)), ) .child( field() diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index a39b251..3bbaeb8 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -28,7 +28,7 @@ use gpui_component::{ use nostr::prelude::{RelayUrl, ToBech32}; use signed_core::Announcement; 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::pixel_avatar::PixelAvatar; @@ -1530,6 +1530,11 @@ impl RepoDetailView { announcement: Announcement, cx: &mut Context, ) { + // 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 = cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)); // Re-render when the store refreshes (issues, PRs, statuses). @@ -1537,7 +1542,6 @@ impl RepoDetailView { .push(cx.observe(&store, |_this, _store, cx| cx.notify())); self.store = Some(store); self.initial = Some(announcement); - self.local_path = None; cx.notify(); } diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 8d08dd6..98886a5 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::ops::Range; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -18,7 +19,7 @@ use gpui_component::avatar::Avatar; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::InputState; 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 super::{RepoDetailView, RepoListView}; @@ -250,6 +251,23 @@ impl SidebarPanel { ) .when_some(store, |builder, store| { 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 = + announcements.iter().map(|a| a.id.clone()).collect(); + let local_repos: Vec = 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, // then the local repositories discovered by the scan. let total = announcements.len() + local_repos.len();