feat: push checkout #14
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
Reference in New Issue
Block a user