Generalize Git URL handling and fix repository creation flow

This commit is contained in:
2026-09-10 07:56:33 +07:00
parent 18433ec239
commit 0e9f0a33ac
7 changed files with 94 additions and 75 deletions
+15 -9
View File
@@ -44,7 +44,11 @@ impl GitCache {
} }
/// Open the existing clone, fetching it first. /// Open the existing clone, fetching it first.
pub fn ensure_clone(&self, addr: &RepoAddr, clone_urls: &[String]) -> Result<gix::Repository> { pub fn ensure_clone<U: AsRef<str>>(
&self,
addr: &RepoAddr,
clone_urls: &[U],
) -> Result<gix::Repository> {
let path = self.repo_path(addr); let path = self.repo_path(addr);
if let Some(repo) = self.open(addr)? { if let Some(repo) = self.open(addr)? {
@@ -122,7 +126,7 @@ pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
/// Clone into `path` from the first working URL in `clone_urls`. /// Clone into `path` from the first working URL in `clone_urls`.
/// ///
/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache. /// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache.
pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> { pub fn clone_repo<U: AsRef<str>>(clone_urls: &[U], path: &Path) -> Result<()> {
if path.exists() { if path.exists() {
bail!("destination {} already exists", path.display()); bail!("destination {} already exists", path.display());
} }
@@ -347,14 +351,14 @@ fn transport_url(url: &str) -> String {
/// ///
/// Returns the last error wrapped in `failed to {verb} from any mirror`, /// Returns the last error wrapped in `failed to {verb} from any mirror`,
/// or `no clone URLs provided` when the list is empty. /// or `no clone URLs provided` when the list is empty.
fn try_each_url<F>(urls: &[String], verb: &str, mut attempt: F) -> Result<()> fn try_each_url<U: AsRef<str>, F>(urls: &[U], verb: &str, mut attempt: F) -> Result<()>
where where
F: FnMut(&str) -> Result<()>, F: FnMut(&str) -> Result<()>,
{ {
let mut last_err: Option<anyhow::Error> = None; let mut last_err: Option<anyhow::Error> = None;
for url in urls { for url in urls {
match attempt(url) { match attempt(url.as_ref()) {
Ok(()) => return Ok(()), Ok(()) => return Ok(()),
Err(e) => last_err = Some(e), Err(e) => last_err = Some(e),
} }
@@ -698,7 +702,7 @@ fn edit_local_config(
/// When no URL works, the last error is returned. /// When no URL works, the last error is returned.
/// ///
/// Never touches the checked-out refs or the worktree. /// Never touches the checked-out refs or the worktree.
pub fn fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) -> Result<()> { pub fn fetch_repo_refs<U: AsRef<str>>(repo_path: &Path, urls: &[U], refspec: &str) -> Result<()> {
let repo = gix::open(repo_path)?; let repo = gix::open(repo_path)?;
let refspec = gix::refspec::parse( let refspec = gix::refspec::parse(
gix::bstr::BStr::new(refspec), gix::bstr::BStr::new(refspec),
@@ -2950,9 +2954,10 @@ mod tests {
#[test] #[test]
fn working_copy_cloned_from_the_mirror_matches_head_and_origin() { fn working_copy_cloned_from_the_mirror_matches_head_and_origin() {
// The mirror is a freshly initialized repository. // The mirror is a freshly initialized repository, standing in for
// Its `origin` points at the grasp server. // the grasp server. Its `origin` points at the (fake) grasp server.
// `Backend::create_repository` leaves it in the GitCache. // `GitCache::ensure_clone` lazily clones from a URL shaped like this
// the first time a repository is opened.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mirror = dir.path().join("mirror"); let mirror = dir.path().join("mirror");
let commit = init_repository(&mirror, "My Repo", "Does things.").expect("init"); let commit = init_repository(&mirror, "My Repo", "Does things.").expect("init");
@@ -3166,7 +3171,8 @@ mod tests {
assert!(err.to_string().contains("failed to fetch")); assert!(err.to_string().contains("failed to fetch"));
// Without any URL there is nothing to try. // Without any URL there is nothing to try.
let err = fetch_repo_refs(dir, &[], "+refs/heads/*:refs/fork/x/*").expect_err("no URLs"); let err = fetch_repo_refs(dir, &[] as &[String], "+refs/heads/*:refs/fork/x/*")
.expect_err("no URLs");
assert!(err.to_string().contains("no clone URLs")); assert!(err.to_string().contains("no clone URLs"));
} }
+25 -47
View File
@@ -5,14 +5,14 @@ use std::str::FromStr;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow, bail}; use anyhow::{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;
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, RepoAddr, build_state, filters, identifier_from_name, repo_addr}; use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_name};
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
use crate::git_store::GitStore; use crate::git_store::GitStore;
@@ -420,18 +420,27 @@ impl Backend {
))); )));
} }
let addr = repo_addr(public_key, repo_id.clone());
let cache = GitStore::global(cx).cache().clone();
let path = cache.repo_path(&addr);
let owner = public_key.to_bech32().unwrap(); let owner = public_key.to_bech32().unwrap();
let servers = grasp_servers.clone(); let servers = grasp_servers.clone();
let client = self.client.clone(); let client = self.client.clone();
// Initialize directly at the user's chosen destination.
// No mirror is pre-populated: `GitCache::ensure_clone` lazily clones
// from the grasp server the first time the repo detail view needs it,
// exactly like every other repository.
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)
};
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
// 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 destination = destination.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();
@@ -439,53 +448,22 @@ impl Backend {
let servers = servers.clone(); let servers = servers.clone();
async move { async move {
let parent = path if destination.exists() {
.parent() bail!("destination {} already exists", destination.display());
.ok_or_else(|| anyhow!("invalid repository path"))?;
std::fs::create_dir_all(parent)?;
let commit = signed_git::init_repository(&path, &name, &description)?;
// Point `origin` at the first grasp server.
// Later fetches and pushes have a target, like ngit.
if let Some(base) = servers.first().and_then(grasp_base_url) {
let url = format!("{base}/{owner}/{repo_id}.git");
signed_git::ensure_origin(&path, &url).ok();
} }
// A working copy at `<folder>/<name>`, like the header's Clone action. let commit = signed_git::init_repository(&destination, &name, &description)?;
// Cloned from the mirror above so it shares the announced history.
// `origin` is set to the first grasp server, not 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) { if let Some(base) = servers.first().and_then(grasp_base_url) {
let url = format!("{base}/{owner}/{repo_id}.git"); let url = format!("{base}/{owner}/{repo_id}.git");
signed_git::set_origin(&destination, &url)?; signed_git::set_origin(&destination, &url)?;
} }
Ok::<_, Error>((commit, destination)) Ok::<_, Error>(commit)
} }
}); });
let (commit, checkout_path) = work.await?; let commit = work.await?;
let commit_sha = Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid id"))?; let commit_sha = Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid id"))?;
// The nostr client queues events until each relay is connected. // The nostr client queues events until each relay is connected.
@@ -525,7 +503,7 @@ impl Backend {
let push = cx.background_spawn({ let push = cx.background_spawn({
let client = client.clone(); let client = client.clone();
let signer = signer.clone(); let signer = signer.clone();
let path = path.clone(); let destination = destination.clone();
let owner = owner.clone(); let owner = owner.clone();
let repo_id = repo_id.clone(); let repo_id = repo_id.clone();
let servers = servers.clone(); let servers = servers.clone();
@@ -537,7 +515,7 @@ impl Backend {
&repo_id, &repo_id,
&refs, &refs,
Some("main"), Some("main"),
&path, &destination,
&owner, &owner,
&servers, &servers,
signed_git::push_main, signed_git::push_main,
@@ -577,7 +555,7 @@ impl Backend {
let announcement = Announcement::from_event(&event) let announcement = Announcement::from_event(&event)
.ok_or_else(|| anyhow!("failed to parse announcement"))?; .ok_or_else(|| anyhow!("failed to parse announcement"))?;
Ok((announcement, checkout_path)) Ok((announcement, destination))
}) })
} }
+3 -3
View File
@@ -999,10 +999,10 @@ impl RepoStore {
let cache = GitStore::global(cx).cache().clone(); let cache = GitStore::global(cx).cache().clone();
let addr = self.addr.clone(); let addr = self.addr.clone();
let clone_urls: Vec<String> = self let clone_urls: Vec<Url> = self
.announcement .announcement
.as_ref() .as_ref()
.map(|a| a.clone.iter().map(ToString::to_string).collect()) .map(|a| a.clone.clone())
.unwrap_or_default(); .unwrap_or_default();
let patch = pull_request_patch(root, self.patches.iter()); let patch = pull_request_patch(root, self.patches.iter());
@@ -1222,7 +1222,7 @@ impl RepoStore {
return self.action_error("Repository announcement is not loaded yet", cx); return self.action_error("Repository announcement is not loaded yet", cx);
}; };
let clone_urls: Vec<String> = announcement.clone.iter().map(ToString::to_string).collect(); let clone_urls = announcement.clone.clone();
let addr = self.addr.clone(); let addr = self.addr.clone();
self.cloning = true; self.cloning = true;
@@ -24,7 +24,7 @@ use gpui_component::{
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled,
VirtualListScrollHandle, h_flex, v_flex, VirtualListScrollHandle, h_flex, v_flex,
}; };
use nostr::prelude::{RelayUrl, ToBech32}; use nostr::prelude::{RelayUrl, ToBech32, Url};
use signed_core::{Announcement, RepoAddr, RepoStatus, filters}; use signed_core::{Announcement, RepoAddr, RepoStatus, filters};
use signed_git::{CommitList, FileCommit}; use signed_git::{CommitList, FileCommit};
use signed_state::{ use signed_state::{
@@ -390,7 +390,7 @@ impl RepoDetailView {
let cache = GitStore::global(cx).cache().clone(); let cache = GitStore::global(cx).cache().clone();
let addr = initial.addr(); let addr = initial.addr();
let clone_urls: Vec<String> = initial.clone.iter().map(ToString::to_string).collect(); let clone_urls: Vec<Url> = initial.clone.clone();
// Captured before the loads start. // Captured before the loads start.
// A branch/tag switch bumps the generation, discarding the refresh below. // A branch/tag switch bumps the generation, discarding the refresh below.
@@ -591,14 +591,14 @@ impl NewPullRequestView {
let cache = GitStore::global(cx).cache().clone(); let cache = GitStore::global(cx).cache().clone();
let mirror_path = cache.repo_path(&base); let mirror_path = cache.repo_path(&base);
let namespace = fork_namespace(&announcement); let namespace = fork_namespace(&announcement);
let clone_urls: Vec<String> = announcement.clone.iter().map(ToString::to_string).collect(); let clone_urls = announcement.clone.clone();
let base_clone_urls: Vec<String> = self let base_clone_urls: Vec<Url> = self
.store .store
.read(cx) .read(cx)
.announcement .announcement
.as_ref() .as_ref()
.map(|a| a.clone.iter().map(ToString::to_string).collect()) .map(|a| a.clone.clone())
.unwrap_or_default(); .unwrap_or_default();
// Keep the current compare and base when the fork is already applied. // Keep the current compare and base when the fork is already applied.
@@ -20,7 +20,7 @@ use gpui_component::{
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex, ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
v_virtual_list, v_virtual_list,
}; };
use nostr::prelude::{Event, EventId, Kind, Nip34Tag}; use nostr::prelude::{Event, EventId, Kind, Nip34Tag, Url};
use signed_core::{activity_subject, pull_request_patch}; use signed_core::{activity_subject, pull_request_patch};
use signed_git::{FileCommit, patch_commits, patch_diffs}; use signed_git::{FileCommit, patch_commits, patch_diffs};
use signed_state::{Backend, GitStore, ProfileStore, RepoStore}; use signed_state::{Backend, GitStore, ProfileStore, RepoStore};
@@ -139,12 +139,8 @@ impl PullRequestDetailView {
.and_then(merge_base_of) .and_then(merge_base_of)
.or_else(|| merge_base_of(root)); .or_else(|| merge_base_of(root));
let clone_urls = clone_urls_of(root).or_else(|| { let clone_urls = clone_urls_of(root)
store .or_else(|| store.announcement.as_ref().map(|a| a.clone.clone()));
.announcement
.as_ref()
.map(|a| a.clone.iter().map(ToString::to_string).collect())
});
( (
root.content.clone(), root.content.clone(),
@@ -730,12 +726,12 @@ fn merge_base_of(event: &Event) -> Option<String> {
/// The `clone` tag of a PR event. /// The `clone` tag of a PR event.
/// ///
/// URLs where the proposed branch can be fetched, or `None` if the PR has none. /// URLs where the proposed branch can be fetched, or `None` if the PR has none.
fn clone_urls_of(event: &Event) -> Option<Vec<String>> { fn clone_urls_of(event: &Event) -> Option<Vec<Url>> {
event event
.tags .tags
.iter() .iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Clone(urls)) => Some(urls.iter().map(ToString::to_string).collect()), Ok(Nip34Tag::Clone(urls)) => Some(urls),
_ => None, _ => None,
}) })
} }
+41 -2
View File
@@ -691,6 +691,24 @@ Delete `Backend::add_relays` entirely once both call sites are inlined.
## 9. `create_repository`'s flow is backwards: it inits a mirror, then clones it into the real destination ## 9. `create_repository`'s flow is backwards: it inits a mirror, then clones it into the real destination
> **Status: done.** `Backend::create_repository` now computes `destination`
> (`folder.join(dir_name)`) up front and calls `signed_git::init_repository`
> directly on it — no mirror path, no `Url::from_file_path`, no `clone_repo`
> call, no double `origin` setup. An explicit `destination.exists()` check
> (mirroring what `clone_repo` used to guard for free) replaces the removed
> clone step's own guard. The push at the end now runs against `destination`
> instead of the mirror path, and the task returns `(announcement,
> destination)` as before — no caller-visible signature change. This also
> dropped `GitStore`/`GitCache::repo_path`/`repo_addr` usage from the
> function entirely, since no mirror is created there anymore; `repo_addr`
> and `Context as AnyhowContext` became unused imports in `backend.rs` and
> were removed. Updated a stale comment in `signed_git`'s
> `working_copy_cloned_from_the_mirror_matches_head_and_origin` test, which
> referenced this flow by name even though it's a generic `clone_repo`
> fixture unrelated to `Backend::create_repository`. `cargo check --workspace`,
> `cargo clippy --workspace`, and `cargo test --workspace` (signed_git 67,
> signed_state 24, workspace 14) all pass.
This is a real business-logic flaw, not just a style issue. Today This is a real business-logic flaw, not just a style issue. Today
(`backend.rs:437-501`): (`backend.rs:437-501`):
@@ -1046,6 +1064,21 @@ already exists once in the same crate.
## 15. `Vec<Url>``Vec<String>` conversion sprawl — fix the 3 `signed_git` signatures, not the 8 call sites ## 15. `Vec<Url>``Vec<String>` conversion sprawl — fix the 3 `signed_git` signatures, not the 8 call sites
> **Status: done.** `try_each_url`, `clone_repo`, `GitCache::ensure_clone`
> and `fetch_repo_refs` are now generic over `U: AsRef<str>`. All 7 call
> sites (`repo.rs::merge_pull_request`/`clone_to_folder`,
> `repo_detail/mod.rs::load_repo`, `new_pull_request.rs::choose_fork` x2,
> `pull_request_detail.rs::load`/`clone_urls_of`) now pass the
> `Vec<Url>`/`Vec<Url>`-derived value straight through with a plain
> `.clone()` of the field, no `.iter().map(ToString::to_string).collect()`
> anywhere left in non-test code. One test in `signed_git` passed an empty
> untyped `&[]` literal to `fetch_repo_refs`, which lost its type-inference
> anchor once the function went generic — fixed with an explicit
> `&[] as &[String]` annotation. `cargo check --workspace`,
> `cargo clippy -p signed_state -p workspace -p signed_git --all-targets`,
> and `cargo test --workspace` (signed_git 67, signed_state 24, workspace 14)
> all pass.
`Announcement::clone` is `Vec<Url>` (`signed_core/src/model.rs`, `Url` being `Announcement::clone` is `Vec<Url>` (`signed_core/src/model.rs`, `Url` being
`nostr`'s re-export of the `url` crate's `Url`, `nostr/src/types/url.rs:15`, `nostr`'s re-export of the `url` crate's `Url`, `nostr/src/types/url.rs:15`,
`pub use url::*;`). Every call site that needs to hand those URLs to `pub use url::*;`). Every call site that needs to hand those URLs to
@@ -1300,17 +1333,23 @@ method.
`latest_grasp_list_servers`. `latest_grasp_list_servers`.
Done: see §1. `fetch_events` no longer appears anywhere in the workspace. Done: see §1. `fetch_events` no longer appears anywhere in the workspace.
5. **Generalize the 3 `signed_git` URL-list signatures** to `&[impl AsRef<str>]` 5. **Generalize the 3 `signed_git` URL-list signatures** to `&[impl AsRef<str>]`
(§15), then delete the now-redundant `.map(ToString::to_string).collect()` (§15), then delete the now-redundant `.map(ToString::to_string).collect()`
at all 7 call sites. Self-contained to `signed_git`'s public API plus a at all 7 call sites. Self-contained to `signed_git`'s public API plus a
one-line change per call site; re-run `signed_git`'s existing tests one-line change per call site; re-run `signed_git`'s existing tests
(`clone_repo`/`fetch_repo_refs` already have coverage). (`clone_repo`/`fetch_repo_refs` already have coverage).
6. **Fix `create_repository`'s init/clone ordering** (§9): initialize and
Done: see §15 for the full list of call sites and verification notes.
6. ✅ **Fix `create_repository`'s init/clone ordering** (§9): initialize and
push directly at the user's chosen destination, drop the mirror push directly at the user's chosen destination, drop the mirror
pre-population entirely and let `ensure_clone` populate it lazily like pre-population entirely and let `ensure_clone` populate it lazily like
every other repo. Self-contained to one function; verify against this every other repo. Self-contained to one function; verify against this
crate's existing `init_repository`/push tests plus a manual crate's existing `init_repository`/push tests plus a manual
create-repository-then-open-detail-view pass. create-repository-then-open-detail-view pass.
Done: see §9. Manual create-repository-then-open-detail-view pass still
recommended before shipping, since it depends on the grasp push actually
succeeding end-to-end against a live server.
7. **Merge `local_repos.rs` and `repo_list.rs` into one file** (§13), 7. **Merge `local_repos.rs` and `repo_list.rs` into one file** (§13),
keeping both stores as independent entities. Purely organizational, zero keeping both stores as independent entities. Purely organizational, zero
call-site changes, safe to do any time. call-site changes, safe to do any time.