update pull request

This commit is contained in:
2026-09-03 10:44:38 +07:00
parent 33cbe42551
commit 01f0540726
16 changed files with 3211 additions and 248 deletions
+36
View File
@@ -107,6 +107,40 @@ impl Default for LocalReposSettings {
}
}
/// A remembered association between a local checkout folder and an
/// announced repository. Recorded when the user clones a repository from
/// the app or picks a folder in the New PR panel, so the panel can prefill
/// the folder later without asking again.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CheckoutRecord {
/// Local folder of the checkout.
pub path: PathBuf,
/// Repository address (`30617:<pubkey>:<id>`) as a string.
pub addr: String,
/// Unix seconds of the last use, for freshest-first ordering.
pub last_used: u64,
}
impl Default for CheckoutRecord {
fn default() -> Self {
Self {
path: PathBuf::new(),
addr: String::new(),
last_used: 0,
}
}
}
/// Remembered local checkouts (see [`CheckoutRecord`]).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CheckoutsSettings {
/// The remembered records; the latest use of a path+repo pair replaces
/// the older record.
pub records: Vec<CheckoutRecord>,
}
/// The create-repository dialog.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -128,6 +162,8 @@ pub struct Settings {
pub grasp_servers: GraspServersSettings,
/// Local repository scanning.
pub local_repos: LocalReposSettings,
/// Remembered local checkouts.
pub checkouts: CheckoutsSettings,
/// The create-repository dialog.
pub create_repository: CreateRepositorySettings,
}
+87
View File
@@ -284,6 +284,21 @@ impl Announcement {
crate::repo_addr(self.owner, self.id.clone())
}
/// Whether this announcement is a fork of the repository at `base`:
/// its `u` tag points at `base` (also covers permanent forks whose EUC
/// diverged), or it shares `base`'s earliest unique commit (EUC) and is
/// not the base repository itself. Read-only discovery input: nothing
/// here is published back to nostr.
pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool {
if self.addr() == *base {
return false;
}
if self.upstream.as_ref().and_then(|u| u.addr.as_ref()) == Some(base) {
return true;
}
base_euc.is_some_and(|euc| self.euc.as_deref() == Some(euc))
}
/// The description of the repository, or a default if none is provided.
pub fn description(&self) -> SharedString {
self.description
@@ -491,6 +506,78 @@ mod tests {
);
}
#[test]
fn is_fork_of_matches_the_u_tag_coordinate() {
// The base repository (announced by the `u`-tag's owner).
let base = crate::repo_addr(
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
"upstream",
);
let event = announcement_event(&[&["d", "my-fork"], &["u", &base.to_string()]]);
let fork = Announcement::from_event(&event).expect("parses");
// A `u` tag pointing at the base address marks a fork even when
// neither side announces an EUC.
assert!(fork.is_fork_of(&base, None));
}
#[test]
fn is_fork_of_matches_a_shared_euc() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
// The base repo has no `u` tag; it announces the family EUC.
let base_event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]);
let base = Announcement::from_event(&base_event).expect("parses");
let base_addr = base.addr();
// A fork (no `u` tag; a pure mirror or cross-hosted clone) shares
// the EUC, so clients of the family can find it.
let fork_event = announcement_event(&[&["d", "mirror"], &["r", euc, "euc"]]);
let fork = Announcement::from_event(&fork_event).expect("parses");
assert!(fork.is_fork_of(&base_addr, base.euc.as_deref()));
// An unrelated repository with a different EUC is not a fork.
let other_event = announcement_event(&[
&["d", "other"],
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
]);
let other = Announcement::from_event(&other_event).expect("parses");
assert!(!other.is_fork_of(&base_addr, base.euc.as_deref()));
// Without a base EUC there is nothing to compare against.
assert!(!fork.is_fork_of(&base_addr, None));
}
#[test]
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
// A permanent fork re-announces its EUC (first commit after the
// fork); only the `u` tag still relates it to the base.
let base = crate::repo_addr(
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
"upstream",
);
let base_euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let event = announcement_event(&[
&["d", "my-fork"],
&["u", &base.to_string()],
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
]);
let fork = Announcement::from_event(&event).expect("parses");
assert!(fork.is_fork_of(&base, Some(base_euc)));
}
#[test]
fn is_fork_of_excludes_the_base_itself() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]);
let base = Announcement::from_event(&event).expect("parses");
let base_addr = base.addr();
// The base announcement matches its own EUC, but is not a fork of
// itself.
assert!(!base.is_fork_of(&base_addr, base.euc.as_deref()));
}
#[test]
fn effective_maintainers_include_owner_for_primary_repos() {
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
+279
View File
@@ -20,6 +20,11 @@ impl GitCache {
Self { root }
}
/// The root directory holding the mirror clones.
pub fn root(&self) -> &Path {
&self.root
}
/// Local path of the clone for a repository.
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
self.root
@@ -523,6 +528,132 @@ pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
Ok(())
}
/// Fetch `refspec` (e.g. `+refs/heads/*:refs/fork/<owner>/<id>/*`) into the
/// repository at `repo_path` from the first working URL in `urls`, like
/// [`clone_repo`]: `grasp://` URLs are rewritten to `https://`, the
/// terminal prompt is disabled, and when no URL works the last error is
/// returned. Never touches the checked-out refs or the worktree.
pub fn fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) -> Result<()> {
let mut last_err: Option<anyhow::Error> = None;
for url in urls {
let url = url
.strip_prefix("grasp://")
.map(|rest| format!("https://{rest}"))
.unwrap_or_else(|| url.to_owned());
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["fetch"])
.arg(&url)
.arg(refspec)
.env("GIT_TERMINAL_PROMPT", "0")
.stderr(Stdio::piped())
.output()
.context("failed to spawn `git fetch`")?;
if output.status.success() {
return Ok(());
}
last_err = Some(anyhow::anyhow!(
"git fetch from {url} failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
match last_err {
Some(e) => Err(e).context("failed to fetch from any mirror"),
None => bail!("no clone URLs provided"),
}
}
/// Full ref names under `prefix` (e.g. `refs/fork/<owner>/<id>`), sorted
/// lexicographically, like `git for-each-ref`. An empty list when nothing
/// matches.
pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
// `for-each-ref` patterns match whole path components, so a trailing
// slash would silently change what is matched.
let pattern = prefix.trim_end_matches('/');
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["for-each-ref", "--format=%(refname)", pattern])
.env("GIT_TERMINAL_PROMPT", "0")
.stderr(Stdio::piped())
.output()
.context("failed to spawn `git for-each-ref`")?;
if !output.status.success() {
bail!(
"git for-each-ref failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::to_owned)
.filter(|name| !name.is_empty())
.collect())
}
/// Delete every ref under `prefix` (e.g. `refs/fork/<owner>/<id>`) of the
/// repository at `repo_path`, so a stale import can be pruned before a
/// re-import. No-op when nothing matches.
pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> {
let refs = refs_with_prefix(repo_path, prefix)?;
if refs.is_empty() {
return Ok(());
}
let mut child = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["update-ref", "--stdin"])
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to spawn `git update-ref --stdin`")?;
for name in refs {
child
.stdin
.as_mut()
.expect("stdin piped")
.write_all(format!("delete {name}\n").as_bytes())?;
}
let output = child.wait_with_output()?;
if !output.status.success() {
bail!(
"git update-ref failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
/// The URL of the `origin` remote of the repository at `workdir`, or `None`
/// when it has no `origin` yet.
pub fn origin_url(workdir: &Path) -> Result<Option<String>> {
let output = Command::new("git")
.arg("-C")
.arg(workdir)
.args(["remote", "get-url", "origin"])
.env("GIT_TERMINAL_PROMPT", "0")
.stderr(Stdio::piped())
.output()
.context("failed to spawn `git remote get-url`")?;
if !output.status.success() {
return Ok(None);
}
let url = String::from_utf8_lossy(&output.stdout);
Ok((!url.trim().is_empty()).then(|| url.trim().to_owned()))
}
/// Run a git command in `dir`, returning trimmed stdout. The terminal prompt
/// is disabled so a credential request fails instead of hanging.
fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
@@ -2321,6 +2452,154 @@ mod tests {
);
}
#[test]
fn origin_url_reads_the_remote_or_reports_none() {
let (dir, _repo) = fixture(&[("a.txt", b"one")]);
commit_all(&_repo, "initial");
let dir = dir.path();
// No remote configured yet.
assert_eq!(origin_url(dir).expect("read"), None);
ensure_origin(dir, "https://gitnostr.com/npub1test/repo.git").expect("add");
assert_eq!(
origin_url(dir).expect("read").as_deref(),
Some("https://gitnostr.com/npub1test/repo.git")
);
}
#[test]
fn fetch_repo_refs_imports_heads_under_a_prefix() {
let dir = tempfile::tempdir().expect("tempdir");
// A bare "base" server holding the initial commit, like a grasp
// server's `{base}/{owner}/{repo-id}.git` layout.
let base_server = dir.path().join("npub1base").join("base.git");
std::fs::create_dir_all(base_server.parent().unwrap()).unwrap();
let init_status = Command::new("git")
.args(["init", "--bare", "-q"])
.arg(&base_server)
.status()
.expect("spawn git init --bare");
assert!(init_status.success());
let (upstream_dir, upstream_repo) = fixture(&[("a.txt", b"one")]);
commit_all(&upstream_repo, "initial");
let upstream_path = upstream_dir.path();
let initial = git_in(upstream_path, &["rev-parse", "HEAD"]).expect("initial");
push_all(
upstream_path,
&format!("file://{}", dir.path().display()),
"npub1base",
"base",
)
.expect("push");
// The base mirror: a plain clone of the base server.
let base_url = format!("file://{}", base_server.display());
let mirror = dir.path().join("mirror");
git_run(
dir.path(),
&["clone", "-q", &base_url, mirror.to_str().unwrap()],
);
// The fork server: the same initial commit plus a feature commit on
// its own `feature` branch.
let fork_work = dir.path().join("fork-work");
git_run(
dir.path(),
&["clone", "-q", &base_url, fork_work.to_str().unwrap()],
);
git_run(&fork_work, &["checkout", "-b", "feature"]);
std::fs::write(fork_work.join("feature.txt"), "feature\n").expect("write");
commit_all(&gix::open(&fork_work).expect("open"), "feature commit");
let tip = git_in(&fork_work, &["rev-parse", "HEAD"]).expect("tip");
let fork_server = dir.path().join("npub1fork").join("fork.git");
std::fs::create_dir_all(fork_server.parent().unwrap()).unwrap();
let init_status = Command::new("git")
.args(["init", "--bare", "-q"])
.arg(&fork_server)
.status()
.expect("spawn git init --bare");
assert!(init_status.success());
push_commit_ref(
&fork_work,
&format!("file://{}", fork_server.display()),
&tip,
"refs/heads/feature",
)
.expect("push");
// Import the fork's heads into the mirror under a private prefix;
// the first (dead) URL is skipped, the second works.
let dead = format!("file://{}/missing.git", dir.path().display());
fetch_repo_refs(
&mirror,
&[dead, format!("file://{}", fork_server.display())],
"+refs/heads/*:refs/fork/npub1fork/fork/*",
)
.expect("fetch");
// The imported refs are listed under the prefix only.
assert_eq!(
refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"),
vec!["refs/fork/npub1fork/fork/feature"]
);
// Nothing leaked into the normal ref namespaces.
assert_eq!(
refs_with_prefix(&mirror, "refs/heads/fork").expect("refs"),
Vec::<String>::new()
);
// The mirror can now range across both histories: the fork point is
// the shared initial commit, and the proposal covers the fork commit.
assert_eq!(
merge_base(
&mirror,
"refs/remotes/origin/main",
"refs/fork/npub1fork/fork/feature",
)
.expect("merge base")
.as_deref(),
Some(initial.as_str())
);
let patch = format_patch_between(&mirror, &initial, "refs/fork/npub1fork/fork/feature")
.expect("patch");
assert!(patch.contains("Subject: [PATCH] feature commit"));
assert!(patch.contains("feature.txt"));
// Pruning the prefix removes the import again.
delete_refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("delete");
assert_eq!(
refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"),
Vec::<String>::new()
);
}
#[test]
fn fetch_repo_refs_fails_when_every_url_fails() {
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
let dir = _dir.path();
let dead = format!("file://{}/missing.git", dir.display());
let err = fetch_repo_refs(dir, &[dead], "+refs/heads/*:refs/fork/x/*")
.expect_err("all URLs fail");
assert!(err.to_string().contains("failed to fetch"));
// Without any URL there is nothing to try.
let err = fetch_repo_refs(dir, &[], "+refs/heads/*:refs/fork/x/*").expect_err("no URLs");
assert!(err.to_string().contains("no clone URLs"));
}
#[test]
fn delete_refs_with_prefix_is_a_noop_without_matches() {
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
delete_refs_with_prefix(_dir.path(), "refs/fork/nothing").expect("noop");
}
/// Run a git command in `dir`, asserting success.
fn git_run(dir: &Path, args: &[&str]) {
let status = Command::new("git")
+4
View File
@@ -8,6 +8,7 @@ publish.workspace = true
signed_core = { path = "../signed_core" }
signed_git = { path = "../signed_git" }
signed_nostr = { path = "../signed_nostr" }
settings = { path = "../settings" }
utils = { path = "../utils" }
nostr.workspace = true
@@ -24,3 +25,6 @@ log.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rustls = "0.23"
[dev-dependencies]
tempfile = "3"
+150
View File
@@ -1546,6 +1546,70 @@ fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url>
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
}
/// The GRASP-06 contributor namespace URL of a pull request tip on the
/// author's grasp server: `{base}/prs/<author-npub>/<repo-id>.git` (npub in
/// the URL; the server stores it under the hex form). Anyone may push there;
/// no announcement or maintainer rights are involved.
pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String {
format!("{base_url}/prs/{npub}/{repo_id}.git")
}
/// Assemble the `clone` URLs of a pull request: the author's GRASP-06
/// `/prs/` URLs first (author-controlled, most likely to accept the tip
/// push), then the base announcement's clone URLs, deduplicated while
/// preserving that order.
pub(crate) fn pr_clone_urls(prs_urls: Vec<Url>, base_clone_urls: Vec<Url>) -> Vec<Url> {
let mut seen = std::collections::HashSet::new();
let mut urls = Vec::new();
for url in prs_urls.into_iter().chain(base_clone_urls) {
if seen.insert(url.to_string()) {
urls.push(url);
}
}
urls
}
/// The `g` tag servers of one kind-10317 grasp list event, in tag order.
/// Unparseable URLs are dropped (the UI only writes well-formed servers).
fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
event
.tags
.iter()
.filter(|tag| tag.kind() == "g")
.filter_map(|tag| tag.content())
.filter_map(|url| RelayUrl::parse(url).ok())
.collect()
}
/// The grasp servers of the newest kind-10317 grasp list among `events`
/// (latest event wins, like every other latest-wins resolution in the app);
/// empty when there is no list, so the caller falls back to the settings
/// defaults.
fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
events
.into_iter()
.max_by_key(|event| event.created_at)
.map(|event| grasp_list_servers(&event))
.unwrap_or_default()
}
/// Resolve the user's published grasp servers: the `g` tags (in order) of
/// their latest kind-10317 grasp list in the local database. Returns an
/// empty list when the user has no published list, so the caller can fall
/// back to the settings defaults.
pub(crate) async fn user_grasp_list_servers(
client: Client,
user: PublicKey,
) -> Result<Vec<RelayUrl>, Error> {
let events: Vec<Event> = client
.database()
.query(filters::grasp_list(user))
.await?
.into_iter()
.collect();
Ok(latest_grasp_list_servers(events))
}
/// Push the repository at `path` to every grasp server: a server that
/// rejects the push is logged, but the push only fails when no server
/// accepted it. `push` performs the single-server push (e.g.
@@ -1628,4 +1692,90 @@ mod tests {
"https://gitnostr.com/npub1test/my-repo.git"
);
}
#[test]
fn grasp06_prs_url_matches_ngit_format() {
assert_eq!(
grasp06_prs_url("https://relay.ngit.dev", "npub1author", "my-repo"),
"https://relay.ngit.dev/prs/npub1author/my-repo.git"
);
// `ws://` grasp servers (local dev) keep their plain-HTTP base.
assert_eq!(
grasp06_prs_url("http://localhost:8080", "npub1author", "my-repo"),
"http://localhost:8080/prs/npub1author/my-repo.git"
);
}
#[test]
fn pr_clone_urls_orders_author_first_and_deduplicates() {
let prs = vec![
Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"),
Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"),
];
let base = vec![
Url::parse("https://a.example/npub1owner/repo.git").expect("url"),
Url::parse("https://b.example/npub1owner/repo.git").expect("url"),
Url::parse("https://b.example/npub1owner/repo.git").expect("url"),
];
let urls = pr_clone_urls(prs, base);
assert_eq!(
urls.iter().map(ToString::to_string).collect::<Vec<_>>(),
vec![
"https://a.example/prs/npub1me/repo.git",
"https://a.example/npub1owner/repo.git",
"https://b.example/npub1owner/repo.git",
]
);
}
fn grasp_list_event(servers: &[&str], created_at: u64) -> Event {
let keys = Keys::generate();
let tags: Vec<Tag> = servers
.iter()
.map(|url| Tag::parse(vec!["g", *url]).expect("valid tag"))
.collect();
EventBuilder::new(Kind::GitUserGraspList, "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys)
.expect("signed event")
}
#[test]
fn grasp_list_servers_reads_g_tags_in_order() {
let event = grasp_list_event(
&["wss://first.example", "wss://second.example", "not a url"],
1000,
);
let servers = grasp_list_servers(&event);
assert_eq!(
servers.iter().map(ToString::to_string).collect::<Vec<_>>(),
vec!["wss://first.example", "wss://second.example"]
);
}
#[test]
fn latest_grasp_list_servers_takes_the_newest_list_and_falls_back_empty() {
let old = grasp_list_event(&["wss://old.example"], 1000);
let fresh = grasp_list_event(&["wss://fresh.example", "wss://also.example"], 2000);
// The newest list wins, its `g` order preserved.
let servers = latest_grasp_list_servers(vec![old.clone(), fresh.clone()]);
assert_eq!(
servers.iter().map(ToString::to_string).collect::<Vec<_>>(),
vec!["wss://fresh.example", "wss://also.example"]
);
// The order of the input events does not matter.
let servers = latest_grasp_list_servers(vec![fresh, old]);
assert_eq!(
servers.iter().map(ToString::to_string).collect::<Vec<_>>(),
vec!["wss://fresh.example", "wss://also.example"]
);
// No list at all: empty, so the caller falls back to the defaults.
assert!(latest_grasp_list_servers(Vec::new()).is_empty());
}
}
+792
View File
@@ -0,0 +1,792 @@
//! Local checkout associations ("remember" tier of the PR suggestions):
//! which local folders are checkouts of which announced repositories.
//!
//! Two sources feed the resolution:
//!
//! - **Remembered records** (settings, [`settings::CheckoutRecord`]):
//! recorded when the user clones a repository from the app or picks a
//! folder in the New PR panel.
//! - **Implicit matches** over the local scan ([`LocalReposStore`]): a
//! scanned repository whose `origin` URL matches an announcement `clone`
//! URL (scheme-insensitive), or whose root commit equals an announcement
//! EUC, is a checkout of that announced repository.
//!
//! The store also computes per-checkout "ready to contribute" statuses
//! (branch, base and commits ahead) for the pull-request list banner.
//! Everything is resolved on background threads and swapped in as
//! [`Arc`]s; the UI never waits for git.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::Error;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use nostr::prelude::*;
use settings::{CheckoutRecord, SettingsStore};
use signed_core::{Announcement, RepoAddr};
use crate::git_store::GitStore;
use crate::local_repos::LocalReposStore;
use crate::repo_list::RepoListStore;
/// Delay between a refresh request and the actual re-computation, so bursts
/// of notifications (settings edits, rescan ticks) collapse into one pass.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// How often the statuses of open repository panels are refreshed, so a
/// checkout committed to or pulled in external git surfaces in the banner
/// without reopening the panel.
const STATUS_POLL: Duration = Duration::from_secs(15);
/// Maximum checkouts considered per repository when computing statuses.
const MAX_STATUS_CHECKOUTS: usize = 8;
struct GlobalCheckoutsStore(Entity<CheckoutsStore>);
impl Global for GlobalCheckoutsStore {}
/// One associated local checkout of a repository, with the git facts needed
/// to suggest a pull request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckoutStatus {
/// The checkout folder.
pub path: PathBuf,
/// The branch checked out (`None`-less: detached checkouts are idle).
pub branch: String,
/// Commit the branch points at, for tip-based PR dedupe.
pub head: String,
/// The branch this checkout is compared against (announced HEAD branch,
/// else `main`, else the first local branch).
pub base: String,
/// Commits in `base..branch`; always > 0 (even checkouts are dropped).
pub ahead: u32,
}
/// A remembered record, with the address already parsed.
struct Remembered {
path: PathBuf,
addr: RepoAddr,
last_used: u64,
}
/// Global store of local-checkout associations and per-checkout statuses.
pub struct CheckoutsStore {
/// Checkout paths per announced repository: remembered records
/// (freshest first) plus scanned repos matched implicitly, deduplicated
/// by path. Missing directories are dropped before publishing.
by_repo: Arc<HashMap<RepoAddr, Vec<PathBuf>>>,
/// Ready-to-contribute statuses of the requested repositories.
statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
/// Repositories whose statuses are recomputed whenever the inputs
/// change (the repository detail panels currently open).
status_requested: HashSet<RepoAddr>,
/// Announced head branch last provided per requested repository, so a
/// recompute defaults the base the same way.
requested_head: HashMap<RepoAddr, Option<String>>,
refreshing: bool,
refresh_dirty: bool,
/// A refresh is waiting out [`REFRESH_DEBOUNCE`].
debouncing: bool,
_subscriptions: Vec<Subscription>,
tasks: Vec<Task<Result<(), Error>>>,
}
impl CheckoutsStore {
/// Retrieve the global checkouts store.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalCheckoutsStore>().0.clone()
}
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalCheckoutsStore(entity));
}
/// Create the store: observe the inputs (settings records, the local
/// scan, the announcement list) and resolve the associations right away.
pub fn new(cx: &mut Context<Self>) -> Self {
let mut subscriptions = Vec::new();
if !cfg!(target_arch = "wasm32") {
let settings = SettingsStore::global(cx);
let local = LocalReposStore::global(cx);
let repos = RepoListStore::global(cx);
subscriptions.push(cx.observe(&settings, |this, _settings, cx| {
this.refresh(cx);
}));
subscriptions.push(cx.observe(&local, |this, _local, cx| {
this.refresh(cx);
}));
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
this.refresh(cx);
}));
}
let mut store = Self {
by_repo: Arc::new(HashMap::new()),
statuses: Arc::new(HashMap::new()),
status_requested: HashSet::new(),
requested_head: HashMap::new(),
refreshing: false,
refresh_dirty: false,
debouncing: false,
_subscriptions: subscriptions,
tasks: Vec::new(),
};
if !cfg!(target_arch = "wasm32") {
store.refresh(cx);
}
store
}
/// Remember a successful local-checkout use: (re)insert the record with
/// a fresh timestamp, so freshest-first ordering follows actual use.
pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context<Self>) {
if cfg!(target_arch = "wasm32") {
return;
}
let last_used = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let addr_str = addr.to_string();
let settings = SettingsStore::global(cx);
settings.update(cx, |settings, cx| {
settings.edit(
|s| {
s.checkouts
.records
.retain(|r| !(r.path == path && r.addr == addr_str));
s.checkouts.records.push(CheckoutRecord {
path,
addr: addr_str,
last_used,
});
},
cx,
);
});
}
/// The associated checkouts of `addr`, freshest first. Empty when none
/// are known (or the resolution has not run yet).
pub fn associations_of(&self, addr: &RepoAddr) -> Vec<PathBuf> {
self.by_repo.get(addr).cloned().unwrap_or_default()
}
/// Ask for the "ready to contribute" statuses of `addr` to be kept
/// current (called while the repository's detail panel is open).
/// `announced_head` is the announced HEAD branch of the repository
/// (from its state announcement), used to default the base.
pub fn request_statuses(
&mut self,
addr: &RepoAddr,
announced_head: Option<String>,
cx: &mut Context<Self>,
) {
self.status_requested.insert(addr.clone());
if announced_head != self.requested_head.get(addr).cloned().flatten() {
self.requested_head.insert(addr.clone(), announced_head);
}
self.refresh(cx);
}
/// The ready-to-contribute statuses of `addr`; empty while none are
/// known or nothing is ahead.
pub fn statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
self.statuses.get(addr).cloned().unwrap_or_default()
}
/// Re-resolve associations (and the requested statuses). Debounced:
/// bursts of notifications collapse into one pass; requests arriving
/// while a pass runs are folded into a follow-up.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refreshing {
self.refresh_dirty = true;
return;
}
if self.debouncing {
return;
}
self.debouncing = true;
let task = cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| {
this.debouncing = false;
this.run_refresh(cx);
})
});
self.tasks.push(task);
}
/// One resolve + apply cycle (debounced entry point).
fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.refreshing = true;
// Inputs snapshot, all cheap shared reads.
let records = {
let settings = SettingsStore::global(cx);
settings.read(cx).settings().checkouts.records.clone()
};
let remembered: Vec<Remembered> = records
.into_iter()
.filter_map(|record| {
let addr = record.addr.parse::<RepoAddr>().ok()?;
Some(Remembered {
path: record.path,
addr,
last_used: record.last_used,
})
})
.collect();
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
let scanned = LocalReposStore::global(cx).read(cx).repos.clone();
let cache_root = GitStore::global(cx).cache().root().canonicalize().ok();
let requested: Vec<(RepoAddr, Option<String>)> = self
.status_requested
.iter()
.map(|addr| {
(
addr.clone(),
self.requested_head.get(addr).cloned().flatten(),
)
})
.collect();
let work = cx.background_spawn(async move {
// Read the git facts of every scanned repository off the main
// thread: origin URL and root commit (both CLI reads).
let mut facts: Vec<(PathBuf, Option<String>, Option<String>)> = Vec::new();
for path in scanned.iter() {
// The browser's mirror clones share the announce URLs and
// EUCs; they are not user checkouts.
if cache_root
.as_ref()
.is_some_and(|root| path.starts_with(root))
{
continue;
}
let origin = signed_git::origin_url(path).ok().flatten();
let root = signed_git::root_commit(path).ok().flatten();
facts.push((path.clone(), origin, root));
}
let associations = resolve_associations(&remembered, &facts, announcements.iter());
// Missing directories are stale records; drop them.
let associations: HashMap<RepoAddr, Vec<PathBuf>> = associations
.into_iter()
.map(|(addr, paths)| (addr, paths.into_iter().filter(|p| p.is_dir()).collect()))
.collect();
let mut statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
for (addr, announced_head) in &requested {
let Some(paths) = associations.get(addr) else {
continue;
};
let list: Vec<CheckoutStatus> = paths
.iter()
.take(MAX_STATUS_CHECKOUTS)
.filter_map(|path| checkout_status(path, announced_head.as_deref()))
.collect();
if !list.is_empty() {
statuses.insert(addr.clone(), list);
}
}
Ok::<_, Error>((associations, statuses))
});
self.tasks.push(cx.spawn(async move |this, cx| {
let (associations, statuses) = match work.await {
Ok(results) => results,
Err(_) => {
// Git reads are best-effort; keep the last results.
return this.update(cx, |this, _cx| {
this.refreshing = false;
});
}
};
let again = this.update(cx, |this, cx| {
this.by_repo = Arc::new(associations);
this.statuses = Arc::new(statuses);
cx.notify();
this.refreshing = false;
if this.refresh_dirty {
this.refresh_dirty = false;
true
} else {
false
}
})?;
if again {
this.update(cx, |this, cx| this.refresh(cx))?;
}
// While any repository panel is open, keep its statuses
// current: local commits, pulls and branch switches happen
// outside the app and are not otherwise observable.
this.update(cx, |this, cx| {
if !this.status_requested.is_empty() && !this.debouncing && !this.refreshing {
this.debouncing = true;
let task = cx.spawn(async move |this, cx| {
cx.background_executor().timer(STATUS_POLL).await;
this.update(cx, |this, cx| {
this.debouncing = false;
this.run_refresh(cx);
})
});
this.tasks.push(task);
}
})?;
Ok(())
}));
}
}
/// The identity of a repository URL: host, explicit port and path with a
/// trailing `.git` (and slashes) stripped. Scheme-insensitive, so
/// `ws`/`wss`/`http`/`https`/`grasp` are equivalent transports of the same
/// grasp server. `None` for URLs that cannot be parsed (e.g. `git@`-style
/// or plain paths), which then compare by raw string.
fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
let parsed = Url::parse(url).ok()?;
let host = parsed.host_str()?.to_ascii_lowercase();
let mut path = parsed.path().trim_matches('/').to_owned();
if let Some(stripped) = path.strip_suffix(".git") {
path = stripped.to_owned();
}
Some((host, parsed.port(), path))
}
/// Whether two repository URLs point at the same repository, ignoring the
/// transport scheme (see [`url_identity`]).
fn same_repo_url(a: &str, b: &str) -> bool {
match (url_identity(a), url_identity(b)) {
(Some(a), Some(b)) => a == b,
_ => a == b,
}
}
/// Resolve the associations between local checkouts and announced
/// repositories: remembered records (freshest first per repository),
/// followed by scanned repositories matched by origin URL or EUC.
/// Deduplicated by path, keeping the first (remembered) occurrence.
fn resolve_associations<'a>(
remembered: &[Remembered],
scanned: &[(PathBuf, Option<String>, Option<String>)],
announcements: impl IntoIterator<Item = &'a Announcement>,
) -> HashMap<RepoAddr, Vec<PathBuf>> {
let announcements: Vec<&Announcement> = announcements.into_iter().collect();
let mut out: HashMap<RepoAddr, Vec<PathBuf>> = HashMap::new();
let mut sorted: Vec<&Remembered> = remembered.iter().collect();
sorted.sort_by_key(|record| std::cmp::Reverse(record.last_used));
for record in sorted {
let paths = out.entry(record.addr.clone()).or_default();
if !paths.contains(&record.path) {
paths.push(record.path.clone());
}
}
for (path, origin, root) in scanned {
for announcement in &announcements {
let url_match = origin.as_deref().is_some_and(|origin| {
announcement
.clone
.iter()
.any(|url| same_repo_url(origin, url.as_str()))
});
let euc_match = root
.as_deref()
.is_some_and(|root| announcement.euc.as_deref() == Some(root));
if url_match || euc_match {
let paths = out.entry(announcement.addr()).or_default();
if !paths.contains(path) {
paths.push(path.clone());
}
}
}
}
out
}
/// Whether the worktree of `path` has uncommitted changes (a dirty
/// checkout is never suggested: the proposal should cover committed work).
fn worktree_dirty(path: &Path) -> bool {
let output = Command::new("git")
.arg("-C")
.arg(path)
.args(["status", "--porcelain"])
.env("GIT_TERMINAL_PROMPT", "0")
.output();
match output {
Ok(output) => !String::from_utf8_lossy(&output.stdout).trim().is_empty(),
Err(_) => false,
}
}
/// Commits in `base..branch` of the checkout at `path` (`git rev-list
/// --count`); `0` when the range is empty or cannot be computed.
fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 {
let output = Command::new("git")
.arg("-C")
.arg(path)
.args(["rev-list", "--count", &format!("{base}..{branch}")])
.env("GIT_TERMINAL_PROMPT", "0")
.output();
match output {
Ok(output) => String::from_utf8_lossy(&output.stdout)
.trim()
.parse()
.unwrap_or(0),
Err(_) => 0,
}
}
/// The branch checked out at `path` (`git branch --show-current`), `None`
/// when detached.
fn current_branch_of(path: &Path) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(path)
.args(["branch", "--show-current"])
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.ok()?;
let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
(!branch.is_empty()).then_some(branch)
}
/// The ready-to-contribute status of one checkout, or `None` when it is
/// idle: detached HEAD, no branches, a dirty worktree, or nothing ahead of
/// its base. The base defaults like the New PR panel: the announced HEAD
/// branch when the checkout has it, else `main`, else the first branch.
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
let branches = signed_git::worktree_branches(path).ok()?;
if branches.is_empty() || worktree_dirty(path) {
return None;
}
let branch = current_branch_of(path)?;
let head = signed_git::head_commit_id(path).ok().flatten()?;
let base = announced_head
.filter(|name| branches.iter().any(|b| b == name))
.map(str::to_owned)
.or_else(|| branches.iter().find(|b| *b == "main").cloned())
.or_else(|| branches.first().cloned())?;
if base == branch {
return None;
}
let ahead = commits_ahead(path, &base, &branch);
(ahead > 0).then_some(CheckoutStatus {
path: path.to_path_buf(),
branch,
head,
base,
ahead,
})
}
/// Whether the pull request `pr` (a kind-1618 root, resolved `open` by the
/// caller) already proposes the same change as `checkout`: authored by
/// `user`, with a matching `branch-name` tag, or — for renamed branches — a
/// `c` tip tag matching the checkout's HEAD commit.
pub fn pr_proposes_checkout(
pr: &Event,
open: bool,
user: PublicKey,
checkout: &CheckoutStatus,
) -> bool {
if pr.kind != Kind::GitPullRequest || !open || pr.pubkey != user {
return false;
}
let branch_matches = pr
.tags
.iter()
.find(|t| t.kind() == "branch-name")
.and_then(|t| t.content())
.is_some_and(|name| name == checkout.branch);
// A renamed branch falls back to the proposed tip commit.
let tip_matches = pr
.tags
.iter()
.find(|t| t.kind() == "c")
.and_then(|t| t.content())
.is_some_and(|tip| tip == checkout.head);
branch_matches || tip_matches
}
#[cfg(test)]
mod tests {
use signed_core::{RepoAddr, repo_addr};
use super::*;
#[test]
fn same_repo_url_ignores_the_transport_scheme() {
// grasp announce vs https origin, with and without `.git`.
assert!(same_repo_url(
"grasp://relay.ngit.dev/npub1test/repo",
"https://relay.ngit.dev/npub1test/repo.git"
));
assert!(same_repo_url(
"ws://localhost:8080/npub1test/repo",
"http://localhost:8080/npub1test/repo"
));
// The port and the path matter.
assert!(!same_repo_url(
"wss://localhost:8081/npub1test/repo",
"wss://localhost:8080/npub1test/repo"
));
assert!(!same_repo_url(
"wss://host/npub1test/repo",
"wss://host/npub1other/repo"
));
// Unparseable URLs compare literally.
assert!(same_repo_url("/local/path", "/local/path"));
assert!(!same_repo_url("/local/path", "/local/other"));
}
fn remembered(path: &str, id: &str, last_used: u64) -> Remembered {
Remembered {
path: PathBuf::from(path),
addr: addr(id),
last_used,
}
}
fn scanned(
path: &str,
origin: Option<&str>,
root: Option<&str>,
) -> (PathBuf, Option<String>, Option<String>) {
(
PathBuf::from(path),
origin.map(str::to_owned),
root.map(str::to_owned),
)
}
const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001";
fn owner() -> PublicKey {
Keys::new(SecretKey::from_hex(KEY).expect("secret")).public_key()
}
fn addr(id: &str) -> RepoAddr {
repo_addr(owner(), id)
}
/// Build one announcement by the fixed test owner with `clone` URLs and
/// an EUC.
fn announcement(id: &str, clones: &[&str], euc: Option<&str>) -> Announcement {
let keys = Keys::new(SecretKey::from_hex(KEY).expect("secret"));
let mut tags = vec![Tag::parse(vec!["d", id]).expect("tag")];
for url in clones {
tags.push(Tag::parse(vec!["clone", *url]).expect("tag"));
}
if let Some(euc) = euc {
tags.push(Tag::parse(vec!["r", euc, "euc"]).expect("tag"));
}
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags(tags)
.finalize(&keys)
.expect("signed");
Announcement::from_event(&event).expect("parsed")
}
#[test]
fn resolve_orders_remembered_freshest_first() {
let announcements = vec![announcement("repo", &[], None)];
let base = addr("repo");
let resolved = resolve_associations(
&[
remembered("/old", "repo", 100),
remembered("/fresh", "repo", 200),
remembered("/other", "unrelated", 300),
],
&[],
&announcements,
);
let paths = resolved.get(&base).expect("associations");
assert_eq!(paths, &vec![PathBuf::from("/fresh"), PathBuf::from("/old")]);
// Records for repositories without announcements stay inert.
assert_eq!(resolved.len(), 2);
}
#[test]
fn resolve_matches_scanned_repos_by_origin_and_euc() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let announcements = vec![
announcement("repo", &["grasp://host/npub1x/repo"], None),
announcement("family", &[], Some(euc)),
];
let repo = addr("repo");
let family = addr("family");
let resolved = resolve_associations(
&[],
&[
// Origin matches modulo scheme and the `.git` suffix.
scanned("/clone", Some("https://host/npub1x/repo.git"), None),
// Root commit matches the family EUC.
scanned("/family-checkout", None, Some(euc)),
// Neither matches anything.
scanned("/unrelated", Some("https://elsewhere/x.git"), None),
],
&announcements,
);
assert_eq!(
resolved.get(&repo).expect("repo matches"),
&vec![PathBuf::from("/clone")]
);
assert_eq!(
resolved.get(&family).expect("family matches"),
&vec![PathBuf::from("/family-checkout")]
);
assert_eq!(resolved.len(), 2);
}
#[test]
fn resolve_deduplicates_paths_remembering_first() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let announcements = vec![announcement(
"repo",
&["https://host/npub1x/repo.git"],
Some(euc),
)];
let base = addr("repo");
// The same path is both remembered and scanned (its origin matches);
// the remembered occurrence wins and it is listed once.
let resolved = resolve_associations(
&[remembered("/shared", "repo", 100)],
&[
scanned("/shared", Some("https://host/npub1x/repo"), None),
scanned("/scanned-only", Some("https://host/npub1x/repo.git"), None),
],
&announcements,
);
let paths = resolved.get(&base).expect("associations");
assert_eq!(
paths,
&vec![PathBuf::from("/shared"), PathBuf::from("/scanned-only")]
);
}
#[test]
fn checkout_status_reports_ahead_branches_only() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("repo");
let _initial = signed_git::init_repository(&path, "My Repo", "").expect("init");
let run = |args: &[&str]| {
let status = Command::new("git")
.current_dir(&path)
.env("GIT_AUTHOR_NAME", "Test Author")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test Author")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.env("GIT_EDITOR", "true")
.args(args)
.status()
.expect("git");
assert!(status.success(), "git {args:?} failed");
};
let commit = |message: &str| {
run(&["add", "-A"]);
run(&["commit", "-m", message]);
};
// A feature branch ahead of main: ready to contribute.
run(&["checkout", "-b", "feature"]);
std::fs::write(path.join("feature.txt"), "x\n").expect("write");
commit("feature work");
let status = checkout_status(&path, Some("main")).expect("status");
assert_eq!(status.branch, "feature");
assert_eq!(status.base, "main");
assert_eq!(status.ahead, 1);
assert_eq!(status.head.len(), 40);
// Dirty worktrees are never suggested.
std::fs::write(path.join("uncommitted.txt"), "y\n").expect("write");
assert!(checkout_status(&path, Some("main")).is_none());
run(&["checkout", "--", "."]);
// Even with main: nothing to propose.
run(&["checkout", "main"]);
assert_eq!(checkout_status(&path, Some("main")), None);
}
fn pr_event(author: &str, tags: &[&[&str]]) -> Event {
let keys = Keys::new(SecretKey::from_hex(author).expect("secret"));
let tags: Vec<Tag> = tags
.iter()
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
.collect();
EventBuilder::new(Kind::GitPullRequest, "")
.tags(tags)
.finalize(&keys)
.expect("signed event")
}
fn status(branch: &str, head: &str) -> CheckoutStatus {
CheckoutStatus {
path: PathBuf::from("/checkout"),
branch: branch.to_owned(),
head: head.to_owned(),
base: "main".to_owned(),
ahead: 1,
}
}
#[test]
fn pr_proposes_checkout_matches_branch_or_tip() {
let author = "0000000000000000000000000000000000000000000000000000000000000002";
let tip = "aa231c4c6a5777dc89b42207b499891a344add5c";
// A matching `branch-name` covers the proposal.
let pr = pr_event(author, &[&["branch-name", "feature"], &["c", tip]]);
let status = status("feature", "bb231c4c6a5777dc89b42207b499891a344add5c");
assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status));
// Without the branch name (renamed), the `c` tip still matches.
let pr = pr_event(
author,
&[&["c", "bb231c4c6a5777dc89b42207b499891a344add5c"]],
);
assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status));
// Someone else's PR, a closed PR, a different branch and a missing
// tip all leave the checkout uncovered.
let pr = pr_event(author, &[&["branch-name", "feature"]]);
assert!(!pr_proposes_checkout(&pr, false, pr.pubkey, &status));
let other = pr_event(
"0000000000000000000000000000000000000000000000000000000000000003",
&[&["branch-name", "feature"]],
);
assert!(!pr_proposes_checkout(&pr, true, other.pubkey, &status));
let other_branch = pr_event(author, &[&["branch-name", "other"]]);
assert!(!pr_proposes_checkout(
&other_branch,
true,
other_branch.pubkey,
&status
));
}
}
+5 -9
View File
@@ -1,4 +1,5 @@
mod backend;
mod checkouts;
mod git_store;
mod local_repos;
mod profile;
@@ -8,6 +9,7 @@ mod repo_list;
use std::path::{Path, PathBuf};
pub use backend::{Backend, BackendEvent};
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
pub use git_store::GitStore;
use gpui::{App, AppContext, Entity};
pub use local_repos::LocalReposStore;
@@ -22,7 +24,7 @@ pub use utils::shorten_pubkey;
/// Call once at startup, before opening any window that uses the stores.
#[cfg(not(target_arch = "wasm32"))]
pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -> Entity<Backend> {
// rustls uses the `aws_lc_rs` provider by default; ignore if already installed.
// rustls uses the `aws_lc_rs` provider by default.
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.ok();
@@ -38,15 +40,10 @@ pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -
Backend::set_global(entity.clone(), cx);
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
// Seed the explore list from the local database; relay syncs continue
// in the background.
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
// The clone cache is native-only; wasm registers an empty store so
// `GitStore::global` still works.
GitStore::set_global(PathBuf::new(), cx);
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
entity
}
@@ -55,13 +52,12 @@ pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -
#[cfg(target_arch = "wasm32")]
pub fn init(cx: &mut App) -> Entity<Backend> {
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
let entity = cx.new(|cx| Backend::new(client, signer, cx));
Backend::set_global(entity.clone(), cx);
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
GitStore::set_global(PathBuf::new(), cx);
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
entity
}
+112 -33
View File
@@ -14,7 +14,9 @@ use signed_core::{
subject_override,
};
use crate::backend::{Backend, BackendEvent, grasp_base_url};
use crate::backend::{
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, user_grasp_list_servers,
};
use crate::git_store::GitStore;
/// Delay between a refresh request and the actual re-query, so bursts of
@@ -645,11 +647,15 @@ impl RepoStore {
/// parsed from the series' last `From <commit>` header (the tip); without
/// one publishing is refused, because the PR's `c` tag must carry a real
/// commit id for other NIP-34 clients to verify and apply the proposal.
/// The `clone` tag carries the announced mirror URLs, and when
/// `push_from` is set the tip is pushed to those servers under
/// `refs/nostr/<event-id>` (best-effort) before the PR is published, so
/// the commit is actually downloadable there; the linked patch stays the
/// source of truth either way.
///
/// The `clone` tag carries the author's GRASP-06 `/prs/` URLs first
/// (resolved from their kind-10317 grasp list, falling back to the
/// settings defaults) plus the announced mirror URLs, so the tip is
/// downloadable on the author's own hosting even when the base project
/// accepts nothing. When `push_from` is set, the tip is pushed to those
/// servers under `refs/nostr/<event-id>` (best-effort, author servers
/// first) before the PR is published; the linked patch stays the source
/// of truth either way.
///
/// `branch_name` lands in the PR's `branch-name` tag (NIP-34); `draft`
/// publishes a kind-1633 status right after the PR event. `merge_base`
@@ -704,28 +710,44 @@ impl RepoStore {
let backend = Backend::global(cx);
let signer = backend.read(cx).signer();
if backend.read(cx).current_user().is_none() {
let Some(user) = backend.read(cx).current_user() else {
self.last_error = Some("Sign in to open a pull request".into());
cx.notify();
return;
}
};
// The author's npub names their GRASP-06 namespace (`/prs/...`).
let author_npub = user.to_bech32().unwrap_or_else(|_| user.to_hex());
let addr = self.addr.clone();
let owner = self.addr.public_key;
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
let (push_owner, push_repo_id, push_relays) = self
let repo_id = addr.identifier.clone();
let base_npub = owner.to_bech32().unwrap_or_else(|_| owner.to_hex());
let push_relays = self
.announcement
.as_ref()
.map(|a| {
let owner = a.owner.to_bech32().unwrap_or_else(|_| a.owner.to_hex());
(owner, a.id.clone(), a.relays.clone())
})
.map(|a| a.relays.clone())
.unwrap_or_default();
// GRASP-06 hosting falls back to the settings defaults when
// the author has no published grasp list
let defaults: Vec<RelayUrl> = {
let settings = settings::SettingsStore::global(cx).read(cx).settings();
let urls: Vec<String> = if settings.grasp_servers.default_servers.is_empty() {
settings::DEFAULT_GRASP_SERVERS
.iter()
.map(|url| (*url).to_owned())
.collect()
} else {
settings.grasp_servers.default_servers.clone()
};
urls.iter()
.filter_map(|url| RelayUrl::parse(url).ok())
.collect()
};
self.tasks.push(cx.spawn(async move |this, cx| {
// The PR references the root patch event so viewers can find
// the patch without carrying it inline.
// The PR references the root patch event so viewers can find the patch without carrying it inline.
let root_patch = match publish_patch_series(
&this,
cx,
@@ -747,20 +769,76 @@ impl RepoStore {
}
};
// GRASP-06: the tip is pushed to the author's own grasp servers
// under `/prs/<author-npub>/<repo-id>.git`, so contributing to
// someone else's project never depends on their servers
// accepting the push. Resolve them from the author's latest
// kind-10317 grasp list; the settings defaults stand in when no
// list is published (or the query fails).
let author_servers = {
let query = this.update(cx, |_this, cx| {
let client = Backend::global(cx).read(cx).client();
user_grasp_list_servers(client, user)
})?;
match cx.background_spawn(query).await {
Ok(published) if !published.is_empty() => published,
_ => defaults,
}
};
let author_targets: Vec<(String, String)> = {
let mut targets = Vec::new();
for server in &author_servers {
let Some(base) = grasp_base_url(server) else {
continue;
};
let url = grasp06_prs_url(&base, &author_npub, &repo_id);
if !targets.iter().any(|(existing, _)| existing == &url) {
targets.push((url, server.to_string()));
}
}
targets
};
let base_targets: Vec<(String, String)> = {
let mut targets = Vec::new();
for relay in &push_relays {
let Some(base) = grasp_base_url(relay) else {
continue;
};
let url = format!("{base}/{base_npub}/{repo_id}.git");
if !targets.iter().any(|(existing, _)| existing == &url) {
targets.push((url, relay.to_string()));
}
}
targets
};
let builder = this.update(cx, |this, _cx| {
// NIP-34: PRs carry at least one clone URL where the tip
// commit can be downloaded. The author's `/prs/` URLs come
// first (author-controlled, most likely alive), then the
// announced mirrors. The list is fixed before signing: the
// pushed ref name embeds the event id, so every candidate
// URL is listed up front; dead URLs are inert, the linked
// patch stays the source of truth.
let prs_urls: Vec<Url> = author_targets
.iter()
.filter_map(|(url, _)| Url::parse(url).ok())
.collect();
let base_clone = this
.announcement
.as_ref()
.map(|a| a.clone.clone())
.unwrap_or_default();
let clone = pr_clone_urls(prs_urls, base_clone);
let builder = GitPullRequest {
repository: this.addr.clone(),
content: description,
subject,
labels: Vec::new(),
branch_name,
// NIP-34: PRs carry at least one clone URL where the tip commit can be downloaded,
// the announced mirrors are also the servers the tip is pushed to below.
clone: this
.announcement
.as_ref()
.map(|a| a.clone.clone())
.unwrap_or_default(),
clone,
current_commit,
root_patch_event: Some(root_patch.id),
merge_base: merge_base
@@ -793,20 +871,21 @@ impl RepoStore {
let path = path.clone();
let tip = tip.clone();
let reference = reference.clone();
let owner = push_owner.clone();
let repo_id = push_repo_id.clone();
let relays = push_relays.clone();
// Author servers first, then the base repository's
// announced grasp servers (best-effort redundancy).
let targets: Vec<(String, String)> = author_targets
.into_iter()
.chain(base_targets)
.collect();
async move {
let mut failures = Vec::new();
let mut pushed = 0;
for relay in &relays {
let Some(base) = grasp_base_url(relay) else {
continue;
};
let url = format!("{base}/{owner}/{repo_id}.git");
match signed_git::push_commit_ref(&path, &url, &tip, &reference) {
for (url, label) in &targets {
match signed_git::push_commit_ref(
&path, url, &tip, &reference,
) {
Ok(()) => pushed += 1,
Err(e) => failures.push(format!("{relay}: {e}")),
Err(e) => failures.push(format!("{label}: {e}")),
}
}
(pushed, failures)
@@ -82,10 +82,11 @@ impl IssuesView {
pub fn new(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
repo_name: SharedString,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let repo_name = store.read(cx).name();
Self {
focus_handle: cx.focus_handle(),
dock_area,
+165 -28
View File
@@ -27,9 +27,12 @@ use gpui_component::{
VirtualListScrollHandle, h_flex, v_flex,
};
use nostr::prelude::{EventId, RelayUrl, ToBech32};
use signed_core::{Announcement, RepoAddr, filters};
use signed_core::{Announcement, RepoAddr, RepoStatus, filters};
use signed_git::{CommitList, FileCommit};
use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoListStore, RepoStore};
use signed_state::{
Backend, CheckoutStatus, CheckoutsStore, GitStore, LocalReposStore, ProfileStore,
RepoListStore, RepoStore, pr_proposes_checkout,
};
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{DropdownButton, PixelAvatar, UserAvatar, copy_row};
@@ -198,6 +201,17 @@ pub struct RepoDetailView {
tasks: Vec<Task<Result<(), Error>>>,
/// Subscriptions keeping the selectors' confirm events alive.
_subscriptions: Vec<Subscription>,
/// Observes the checkouts store, whose statuses feed the "ready to
/// contribute" banner of the repository panel.
_checkouts_subscription: Subscription,
/// `(path, branch)` ready-suggestions dismissed by the user, per panel.
banner_dismissed: HashSet<(PathBuf, String)>,
/// The announced HEAD the ready-statuses were last requested with, and
/// whether they were requested at all (re-requested only when the HEAD
/// — the base default — changes, e.g. when the store's first refresh
/// lands).
ready_requested: bool,
ready_head: Option<String>,
/// Upstream repository (from this fork's `u` tag) the user asked to
/// open, while its announcement is still being fetched.
pending_upstream: Option<RepoAddr>,
@@ -219,7 +233,16 @@ impl RepoDetailView {
let relays = initial.relays.clone();
let store = cx.new(|cx| RepoStore::new(addr, relays, cx));
Self::new_common(dock_area, Some(initial), Some(store), None, window, cx)
let mut view = Self::new_common(
dock_area,
Some(initial),
Some(store.clone()),
None,
window,
cx,
);
view.attach_store(&store, cx);
view
}
/// Open a local repository discovered by the scan. There is no
@@ -328,6 +351,11 @@ impl RepoDetailView {
focus_handle: cx.focus_handle(),
tasks: Vec::new(),
_subscriptions: subscriptions,
_checkouts_subscription: cx
.observe(&CheckoutsStore::global(cx), |_this, _store, cx| cx.notify()),
banner_dismissed: HashSet::new(),
ready_requested: false,
ready_head: None,
pending_upstream: None,
}
}
@@ -548,7 +576,7 @@ impl RepoDetailView {
return;
}
let (clone_urls, name) = {
let (clone_urls, name, addr) = {
let Some(announcement) = self.announcement(cx) else {
return;
};
@@ -569,7 +597,7 @@ impl RepoDetailView {
} else {
name
};
(clone_urls, name)
(clone_urls, name, addr)
};
self.cloning = true;
@@ -599,14 +627,23 @@ impl RepoDetailView {
let destination = folder.join(&name);
let destination_for_open = destination.clone();
let clone_target = destination_for_open.clone();
let result = cx
.background_spawn(async move { signed_git::clone_repo(&clone_urls, &destination) })
.background_spawn(async move { signed_git::clone_repo(&clone_urls, &clone_target) })
.await;
this.update_in(cx, |this, _window, cx| {
this.cloning = false;
match result {
Ok(_) => cx.open_with_system(&destination_for_open),
Ok(_) => {
cx.open_with_system(&destination_for_open);
// Remember the clone 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(destination, addr, cx);
});
}
Err(error) => {
this.error = Some(format!("Failed to clone: {error}").into());
}
@@ -934,15 +971,7 @@ impl RepoDetailView {
return;
};
let panel = cx.new(|cx| {
IssuesView::new(
self.dock_area.clone(),
store,
self.display_name(cx),
window,
cx,
)
});
let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx));
dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
@@ -958,15 +987,7 @@ impl RepoDetailView {
return;
};
let panel = cx.new(|cx| {
PullRequestsView::new(
self.dock_area.clone(),
store,
self.display_name(cx),
window,
cx,
)
});
let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx));
dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
@@ -1798,14 +1819,127 @@ impl RepoDetailView {
}
let store =
cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx));
// Re-render when the store refreshes (issues, PRs, statuses).
self._subscriptions
.push(cx.observe(&store, |_this, _store, cx| cx.notify()));
// Re-render on store refreshes (issues, PRs, statuses) and keep the
// "ready to contribute" statuses of this repository requested.
self.attach_store(&store, cx);
self.store = Some(store);
self.initial = Some(announcement);
cx.notify();
}
/// Observe the repository's store (re-render on refreshes) and request
/// the "ready to contribute" statuses for it.
fn attach_store(&mut self, store: &Entity<RepoStore>, cx: &mut Context<Self>) {
self._subscriptions
.push(cx.observe(store, |this, _store, cx| {
cx.notify();
// The first refresh fills the announced HEAD, which defaults
// the banner's base branch; re-request when it changes.
this.refresh_ready_statuses(cx);
}));
self.refresh_ready_statuses(cx);
}
/// (Re)request the ready statuses of this repository when the announced
/// HEAD — the base the checkouts are compared against — changed since
/// the last request.
fn refresh_ready_statuses(&mut self, cx: &mut Context<Self>) {
let Some(store) = self.store.clone() else {
return;
};
let head = store.read(cx).head.clone();
if self.ready_requested && self.ready_head == head {
return;
}
self.ready_requested = true;
self.ready_head = head.clone();
let addr = store.read(cx).addr().clone();
CheckoutsStore::global(cx).update(cx, |store, cx| {
store.request_statuses(&addr, head, cx);
});
}
/// The first checkout ready for a pull request on this repository,
/// not covered by an open PR of the signed-in user and not dismissed in this panel.
fn ready_suggestion(&self, cx: &App) -> Option<CheckoutStatus> {
let store = self.store.as_ref()?;
let addr = store.read(cx).addr().clone();
let statuses = CheckoutsStore::global(cx).read(cx).statuses_of(&addr);
let user = Backend::global(cx).read(cx).current_user()?;
'status: for status in statuses {
if self
.banner_dismissed
.contains(&(status.path.clone(), status.branch.clone()))
{
continue;
}
let store = store.read(cx);
for pr in &store.pull_requests {
if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status)
{
continue 'status;
}
}
return Some(status);
}
None
}
/// The "ready to contribute" banner of the repository panel: message,
/// a Create action opening the prefilled New PR panel, and a dismiss
/// control.
fn render_ready_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let status = self.ready_suggestion(cx)?;
let commits = if status.ahead == 1 {
"1 commit".to_owned()
} else {
format!("{} commits", status.ahead)
};
let message = SharedString::from(format!(
"{} is {} ahead of {} in {}",
status.branch,
commits,
status.base,
status.path.display()
));
let key = (status.path.clone(), status.branch.clone());
let view = cx.entity().clone();
Some(
h_flex()
.gap_2()
.px_4()
.pt_1()
.w_full()
.items_center()
.child(
Alert::info("repo-ready-to-contribute", message)
.banner()
.flex_1()
.on_close(move |_event, _window, cx| {
view.update(cx, |this, _| {
this.banner_dismissed.insert(key.clone());
});
}),
)
.child(
Button::new("create-pr-from-banner")
.small()
.icon(IconName::Plus)
.label("Create pull request")
.on_click(cx.listener(|this, _event, window, cx| {
if let Some(store) = this.store.clone() {
open_new_pull_panel(this.dock_area.clone(), store, window, cx);
}
})),
)
.into_any_element(),
)
}
/// The tab row shared by both header variants: Files/Commits tabs, the
/// HEAD commit button and the branch/tag selectors.
fn render_header_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
@@ -2036,6 +2170,9 @@ impl Render for RepoDetailView {
.id("repo")
.size_full()
.child(self.render_header(cx))
.when_some(self.render_ready_banner(cx), |this, banner| {
this.child(banner)
})
.when_some(self.error.clone(), |this, error| {
this.child(
Alert::error("repo-error", error)
File diff suppressed because it is too large Load Diff
@@ -92,10 +92,11 @@ impl PullRequestsView {
pub fn new(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
repo_name: SharedString,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let repo_name = store.read(cx).name();
Self {
focus_handle: cx.focus_handle(),
dock_area,