From c0f06d095e5876bd26fa56f6ac63e64d89d49e6e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 3 Sep 2026 13:59:54 +0700 Subject: [PATCH] create local repo --- crates/signed_git/src/lib.rs | 65 +++++++++++++++++++ crates/signed_state/src/backend.rs | 65 ++++++++++++++++--- .../src/views/sidebar/create_repo_dialog.rs | 26 ++++++-- docs/PLAN.md | 14 ++++ 4 files changed, 155 insertions(+), 15 deletions(-) diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 54958b9..c091450 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -528,6 +528,19 @@ pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> { Ok(()) } +/// Point `origin` at `url`, replacing an existing remote. Used after a +/// clone whose `origin` points at the cloned-from path (e.g. a working +/// copy cloned from a local mirror), to re-target it at the grasp server. +pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> { + // `git remote get-url origin` exits non-zero when the remote is absent. + if git_in(repo_path, &["remote", "get-url", "origin"]).is_ok() { + git_in(repo_path, &["remote", "set-url", "origin", url])?; + } else { + git_in(repo_path, &["remote", "add", "origin", url])?; + } + Ok(()) +} + /// Fetch `refspec` (e.g. `+refs/heads/*:refs/fork///*`) into the /// repository at `repo_path` from the first working URL in `urls`, like /// [`clone_repo`]: `grasp://` URLs are rewritten to `https://`, the @@ -2468,6 +2481,58 @@ mod tests { ); } + #[test] + fn set_origin_creates_or_replaces_the_remote() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("my-repo"); + init_repository(&path, "My Repo", "").expect("init"); + + // No origin yet: added. + set_origin(&path, "https://gitnostr.com/npub1test/repo.git").expect("add"); + assert_eq!( + origin_url(&path).expect("url").as_deref(), + Some("https://gitnostr.com/npub1test/repo.git") + ); + + // An existing origin is replaced, not duplicated (a clone's origin + // points at the cloned-from path; it is re-targeted at the grasp + // server). + set_origin(&path, "https://grasp.example/npub1test/repo.git").expect("replace"); + assert_eq!( + origin_url(&path).expect("url").as_deref(), + Some("https://grasp.example/npub1test/repo.git") + ); + } + + #[test] + fn working_copy_cloned_from_the_mirror_matches_head_and_origin() { + // The mirror: a freshly initialized repository whose `origin` + // points at the grasp server, like `Backend::create_repository` + // leaves it in the GitCache. + let dir = tempfile::tempdir().expect("tempdir"); + let mirror = dir.path().join("mirror"); + let commit = init_repository(&mirror, "My Repo", "Does things.").expect("init"); + ensure_origin(&mirror, "https://gitnostr.com/npub1test/my-repo.git").expect("origin"); + + // The working copy: cloned from the mirror (so it shares the + // announced history exactly), then `origin` re-pointed at the grasp + // server instead of the mirror path. + let destination = dir.path().join("folder").join("My_Repo"); + std::fs::create_dir_all(destination.parent().unwrap()).expect("parent"); + clone_repo(&[format!("file://{}", mirror.display())], &destination).expect("clone"); + set_origin(&destination, "https://gitnostr.com/npub1test/my-repo.git").expect("set origin"); + + assert_eq!( + origin_url(&destination).expect("url").as_deref(), + Some("https://gitnostr.com/npub1test/my-repo.git") + ); + assert_eq!( + head_commit_id(&destination).expect("head").as_deref(), + Some(commit.as_str()) + ); + assert!(destination.join("README.md").is_file()); + } + #[test] fn fetch_repo_refs_imports_heads_under_a_prefix() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index c7d2655..307a69c 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::{Duration, Instant}; -use anyhow::{Error, anyhow, bail}; +use anyhow::{Context as AnyhowContext, Error, anyhow, bail}; use bitcoin_hashes::sha1::Hash as Sha1Hash; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; use nostr::event::IntoEventBuilder; @@ -390,7 +390,10 @@ impl Backend { /// Create a new repository: initialize a local clone with a `main` /// branch and a `README.md`, publish the NIP-34 announcement and the /// repository state to the grasp relays, then push the initial commit - /// to each grasp server. + /// to each grasp server. A working copy of the repository is also + /// created at `/` (named like the repo header's Clone + /// action), with `origin` pointing at the first grasp server, so the + /// new project exists in the chosen folder right away. /// /// The events must reach the grasp servers *before* the push: GRASP /// servers hold the signed state event in "purgatory" and only accept @@ -398,23 +401,30 @@ impl Backend { /// pending (it expires after 30 minutes), like gitworkshop and ngit. /// /// The git work runs on background threads; the task yields the - /// published announcement. + /// published announcement and the path of the created working copy. pub fn create_repository( &mut self, name: &str, description: &str, + folder: PathBuf, grasp_servers: Vec, cx: &mut Context, - ) -> Task> { + ) -> Task> { let name = name.trim().to_owned(); let description = description.trim().to_owned(); if name.is_empty() { return Task::ready(Err(anyhow!("Repository name is required"))); } + if grasp_servers.is_empty() { return Task::ready(Err(anyhow!("Add at least one grasp server"))); } + + if !folder.is_dir() { + return Task::ready(Err(anyhow!("Choose a folder for the repository"))); + } + let Some(public_key) = self.current_user else { return Task::ready(Err(anyhow!("Sign in to create a repository"))); }; @@ -443,9 +453,10 @@ impl Backend { let servers = grasp_servers.clone(); cx.spawn(async move |this, cx| { - // Initialize the local clone (main branch + README + initial commit). + // Initialize the local clone and create the user's working copy from it. let work = cx.background_spawn({ let path = path.clone(); + let folder = folder.clone(); let name = name.clone(); let description = description.clone(); let owner = owner.clone(); @@ -466,13 +477,43 @@ impl Backend { signed_git::ensure_origin(&path, &url).ok(); } - Ok::<_, Error>(commit) + // A working copy at `/` (the same naming + // as the header's Clone action), cloned from the mirror + // above so it shares the announced history exactly; + // `origin` is re-pointed at the first grasp server + // instead of the mirror path. + let destination = { + let dir_name = signed_git::sanitize_path_component(&name); + let dir_name = if dir_name.is_empty() { + "repository".to_owned() + } else { + dir_name + }; + folder.join(dir_name) + }; + + let mirror_url = Url::from_file_path(&path) + .map_err(|_| anyhow!("invalid mirror path"))? + .to_string(); + + signed_git::clone_repo(&[mirror_url], &destination).with_context(|| { + format!( + "failed to create the working copy at {}", + destination.display() + ) + })?; + + if let Some(base) = servers.first().and_then(grasp_base_url) { + let url = format!("{base}/{owner}/{repo_id}.git"); + signed_git::set_origin(&destination, &url)?; + } + + Ok::<_, Error>((commit, destination)) } }); - let commit = work.await?; - let commit_sha = - Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid initial commit id"))?; + let (commit, checkout_path) = work.await?; + let commit_sha = Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid id"))?; // The nostr client queues events until each relay is connected. this.update(cx, |this, cx| { @@ -536,6 +577,7 @@ impl Backend { let servers = servers.clone(); push_to_grasp_servers(path, owner, repo_id, servers, signed_git::push_main) }); + if let Err(e) = push.await { // The events are already published; retract them so the // repository doesn't remain announced without content. @@ -550,7 +592,10 @@ impl Backend { )); } - Announcement::from_event(&event).ok_or_else(|| anyhow!("failed to parse announcement")) + let announcement = Announcement::from_event(&event) + .ok_or_else(|| anyhow!("failed to parse announcement"))?; + + Ok((announcement, checkout_path)) }) } diff --git a/crates/workspace/src/views/sidebar/create_repo_dialog.rs b/crates/workspace/src/views/sidebar/create_repo_dialog.rs index bad974f..32ed2d8 100644 --- a/crates/workspace/src/views/sidebar/create_repo_dialog.rs +++ b/crates/workspace/src/views/sidebar/create_repo_dialog.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use dock::DockArea; use gpui::prelude::*; use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px}; @@ -9,7 +11,7 @@ use gpui_component::input::{Input, InputState, Textarea}; use gpui_component::{ActiveTheme, Disableable, IconName, WindowExt, h_flex}; use settings::SettingsStore; use signed_core::Announcement; -use signed_state::Backend; +use signed_state::{Backend, CheckoutsStore}; use super::super::open_repo_panel; use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers}; @@ -56,7 +58,7 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) window.open_dialog(cx, move |dialog, _window, _cx| { const DESC: &str = "Publish a new repository to your grasp servers."; - const FOLDER_NOTE: &str = "Where the repository is stored."; + const FOLDER_NOTE: &str = "Where the repository's working copy is created."; let name_input = name_input.clone(); let desc_input = desc_input.clone(); @@ -134,6 +136,7 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) .on_click({ let name_input = name_input.clone(); let desc_input = desc_input.clone(); + let folder_input = folder_input.clone(); let state = state.clone(); let grasp_state = grasp_state.clone(); let dock_area = dock_area.clone(); @@ -142,6 +145,7 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) create_repository( name_input.clone(), desc_input.clone(), + folder_input.clone(), state.clone(), grasp_state.clone(), dock_area.clone(), @@ -194,10 +198,13 @@ fn choose_folder(folder_input: &Entity, window: &mut Window, cx: &mu .detach(); } -/// Run the create-repository flow; closes the dialog and opens the new repository on success. +/// Run the create-repository flow, +/// opens the new working copy and the repository panel on success. +#[allow(clippy::too_many_arguments)] fn create_repository( name_input: Entity, desc_input: Entity, + folder_input: Entity, state: Entity, grasp_state: Entity, dock_area: WeakEntity, @@ -206,6 +213,7 @@ fn create_repository( ) { let name = name_input.read(cx).value().trim().to_owned(); let description = desc_input.read(cx).value().trim().to_owned(); + let folder = PathBuf::from(folder_input.read(cx).value().trim()); let servers = grasp_state.read(cx).grasp_servers.clone(); if name.is_empty() { @@ -228,16 +236,24 @@ fn create_repository( let backend = Backend::global(cx); let task = backend.update(cx, |backend, cx| { - backend.create_repository(&name, &description, servers, cx) + backend.create_repository(&name, &description, folder, servers, cx) }); + let handle = window.window_handle(); let state = state.clone(); let dock_area = dock_area.clone(); cx.spawn(async move |cx| match task.await { - Ok(announcement) => { + Ok((announcement, local_path)) => { cx.update_window(handle, |_, window, cx| { window.close_dialog(cx); + // Remember the new working copy as a checkout of this + // repository, so the New PR panel pre-fills it. + let checkouts = CheckoutsStore::global(cx); + checkouts.update(cx, |store, cx| { + store.record(local_path.clone(), announcement.addr(), cx); + }); + cx.open_with_system(&local_path); open_repo(dock_area, announcement, window, cx); }) .ok(); diff --git a/docs/PLAN.md b/docs/PLAN.md index bfd7d76..ee98bfd 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -582,3 +582,17 @@ The manual e2e checklist above still needs a real GRASP-06 server run. (fork import, GRASP-06 hosting, suggestions, deferred items explicit); `docs/TODO.md` updated; this log added. Manual e2e (§9) not yet run against a live GRASP-06 server. +- **Fix (after e2e, user report)** — creating a repository left the + project only inside the app's GitCache mirror: the announcement, state + event and push happened, but the folder chosen in the Create Repository + dialog was just remembered as a settings default. `Backend:: + create_repository` now also materializes a working copy at + `/` (cloned from the mirror via a `file://` URL + so it shares the announced history exactly, then `origin` re-pointed at + the first grasp server through the new `signed_git::set_origin`), and + the dialog records it as a checkout (`CheckoutsStore`, so the New PR + panel pre-fills it), opens it in the system file manager and opens the + repository panel. Materialization runs before any event is published, + so a failure aborts creation cleanly with nothing announced. Two new + `signed_git` tests (`set_origin_creates_or_replaces_the_remote`, + `working_copy_cloned_from_the_mirror_matches_head_and_origin`).