create local repo

This commit is contained in:
2026-09-03 13:59:54 +07:00
parent 01f0540726
commit c0f06d095e
4 changed files with 155 additions and 15 deletions
+65
View File
@@ -528,6 +528,19 @@ pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
Ok(()) 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/<owner>/<id>/*`) into the /// Fetch `refspec` (e.g. `+refs/heads/*:refs/fork/<owner>/<id>/*`) into the
/// repository at `repo_path` from the first working URL in `urls`, like /// repository at `repo_path` from the first working URL in `urls`, like
/// [`clone_repo`]: `grasp://` URLs are rewritten to `https://`, the /// [`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] #[test]
fn fetch_repo_refs_imports_heads_under_a_prefix() { fn fetch_repo_refs_imports_heads_under_a_prefix() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
+55 -10
View File
@@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
use std::str::FromStr; use std::str::FromStr;
use std::time::{Duration, Instant}; 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 bitcoin_hashes::sha1::Hash as Sha1Hash;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
use nostr::event::IntoEventBuilder; use nostr::event::IntoEventBuilder;
@@ -390,7 +390,10 @@ impl Backend {
/// Create a new repository: initialize a local clone with a `main` /// Create a new repository: initialize a local clone with a `main`
/// branch and a `README.md`, publish the NIP-34 announcement and the /// branch and a `README.md`, publish the NIP-34 announcement and the
/// repository state to the grasp relays, then push the initial commit /// 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 `<folder>/<name>` (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 /// The events must reach the grasp servers *before* the push: GRASP
/// servers hold the signed state event in "purgatory" and only accept /// 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. /// pending (it expires after 30 minutes), like gitworkshop and ngit.
/// ///
/// The git work runs on background threads; the task yields the /// 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( pub fn create_repository(
&mut self, &mut self,
name: &str, name: &str,
description: &str, description: &str,
folder: PathBuf,
grasp_servers: Vec<RelayUrl>, grasp_servers: Vec<RelayUrl>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Task<Result<Announcement, Error>> { ) -> Task<Result<(Announcement, PathBuf), Error>> {
let name = name.trim().to_owned(); let name = name.trim().to_owned();
let description = description.trim().to_owned(); let description = description.trim().to_owned();
if name.is_empty() { if name.is_empty() {
return Task::ready(Err(anyhow!("Repository name is required"))); return Task::ready(Err(anyhow!("Repository name is required")));
} }
if grasp_servers.is_empty() { if grasp_servers.is_empty() {
return Task::ready(Err(anyhow!("Add at least one grasp server"))); 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 { let Some(public_key) = self.current_user else {
return Task::ready(Err(anyhow!("Sign in to create a repository"))); return Task::ready(Err(anyhow!("Sign in to create a repository")));
}; };
@@ -443,9 +453,10 @@ impl Backend {
let servers = grasp_servers.clone(); let servers = grasp_servers.clone();
cx.spawn(async move |this, cx| { 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 work = cx.background_spawn({
let path = path.clone(); let path = path.clone();
let folder = folder.clone();
let name = name.clone(); let name = name.clone();
let description = description.clone(); let description = description.clone();
let owner = owner.clone(); let owner = owner.clone();
@@ -466,13 +477,43 @@ impl Backend {
signed_git::ensure_origin(&path, &url).ok(); signed_git::ensure_origin(&path, &url).ok();
} }
Ok::<_, Error>(commit) // A working copy at `<folder>/<name>` (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, checkout_path) = work.await?;
let commit_sha = let commit_sha = Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid id"))?;
Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid initial commit id"))?;
// The nostr client queues events until each relay is connected. // The nostr client queues events until each relay is connected.
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
@@ -536,6 +577,7 @@ impl Backend {
let servers = servers.clone(); let servers = servers.clone();
push_to_grasp_servers(path, owner, repo_id, servers, signed_git::push_main) push_to_grasp_servers(path, owner, repo_id, servers, signed_git::push_main)
}); });
if let Err(e) = push.await { if let Err(e) = push.await {
// The events are already published; retract them so the // The events are already published; retract them so the
// repository doesn't remain announced without content. // 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))
}) })
} }
@@ -1,3 +1,5 @@
use std::path::PathBuf;
use dock::DockArea; use dock::DockArea;
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px}; 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 gpui_component::{ActiveTheme, Disableable, IconName, WindowExt, h_flex};
use settings::SettingsStore; use settings::SettingsStore;
use signed_core::Announcement; use signed_core::Announcement;
use signed_state::Backend; use signed_state::{Backend, CheckoutsStore};
use super::super::open_repo_panel; use super::super::open_repo_panel;
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers}; use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
@@ -56,7 +58,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
window.open_dialog(cx, move |dialog, _window, _cx| { window.open_dialog(cx, move |dialog, _window, _cx| {
const DESC: &str = "Publish a new repository to your grasp servers."; 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 name_input = name_input.clone();
let desc_input = desc_input.clone(); let desc_input = desc_input.clone();
@@ -134,6 +136,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
.on_click({ .on_click({
let name_input = name_input.clone(); let name_input = name_input.clone();
let desc_input = desc_input.clone(); let desc_input = desc_input.clone();
let folder_input = folder_input.clone();
let state = state.clone(); let state = state.clone();
let grasp_state = grasp_state.clone(); let grasp_state = grasp_state.clone();
let dock_area = dock_area.clone(); let dock_area = dock_area.clone();
@@ -142,6 +145,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
create_repository( create_repository(
name_input.clone(), name_input.clone(),
desc_input.clone(), desc_input.clone(),
folder_input.clone(),
state.clone(), state.clone(),
grasp_state.clone(), grasp_state.clone(),
dock_area.clone(), dock_area.clone(),
@@ -194,10 +198,13 @@ fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mu
.detach(); .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( fn create_repository(
name_input: Entity<InputState>, name_input: Entity<InputState>,
desc_input: Entity<TextareaState>, desc_input: Entity<TextareaState>,
folder_input: Entity<InputState>,
state: Entity<CreateRepoState>, state: Entity<CreateRepoState>,
grasp_state: Entity<GraspServersState>, grasp_state: Entity<GraspServersState>,
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
@@ -206,6 +213,7 @@ fn create_repository(
) { ) {
let name = name_input.read(cx).value().trim().to_owned(); let name = name_input.read(cx).value().trim().to_owned();
let description = desc_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(); let servers = grasp_state.read(cx).grasp_servers.clone();
if name.is_empty() { if name.is_empty() {
@@ -228,16 +236,24 @@ fn create_repository(
let backend = Backend::global(cx); let backend = Backend::global(cx);
let task = backend.update(cx, |backend, 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 handle = window.window_handle();
let state = state.clone(); let state = state.clone();
let dock_area = dock_area.clone(); let dock_area = dock_area.clone();
cx.spawn(async move |cx| match task.await { cx.spawn(async move |cx| match task.await {
Ok(announcement) => { Ok((announcement, local_path)) => {
cx.update_window(handle, |_, window, cx| { cx.update_window(handle, |_, window, cx| {
window.close_dialog(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); open_repo(dock_area, announcement, window, cx);
}) })
.ok(); .ok();
+14
View File
@@ -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); (fork import, GRASP-06 hosting, suggestions, deferred items explicit);
`docs/TODO.md` updated; this log added. Manual e2e (§9) not yet run `docs/TODO.md` updated; this log added. Manual e2e (§9) not yet run
against a live GRASP-06 server. 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
`<folder>/<sanitized-name>` (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`).