This commit is contained in:
2026-10-13 09:40:58 +07:00
parent b4c3565484
commit 0e3a5ed865
4 changed files with 71 additions and 220 deletions
+4 -7
View File
@@ -62,16 +62,13 @@ impl Repo {
pub fn init(path: &Path, name: &str, description: &str) -> Result<String> {
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
std::fs::create_dir_all(path)
.with_context(|| format!("failed to create {}", path.display()))?;
std::fs::create_dir_all(path)?;
let repo = gix::init(path)?;
let (signature, mut time_buf) = Self::repository_signature();
let signature = signature.to_ref(&mut time_buf);
let head = gix::refs::FullName::try_from("HEAD")
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
let signature = signature.to_ref(&mut time_buf);
let head = gix::refs::FullName::try_from("HEAD")?;
repo.edit_references_as(
[RefEdit {
@@ -99,7 +96,7 @@ impl Repo {
format!("# {name}\n\n{description}\n")
};
std::fs::write(path.join("README.md"), &readme).context("failed to write README.md")?;
std::fs::write(path.join("README.md"), &readme)?;
let blob = repo.write_object(gix::objs::Blob {
data: readme.into_bytes(),
+2 -8
View File
@@ -78,16 +78,10 @@ pub async fn user_grasp_list_servers(
) -> Result<Vec<RelayUrl>, Error> {
let events: Vec<Event> = client
.database()
.query(Filters::grasp_list(user))
.query(Filters::grasp_list(user).limit(1))
.await?
.into_iter()
.collect();
let latest = events
.into_iter()
.max_by_key(|event| event.created_at)
.map(|event| grasp_list_servers(&event))
.unwrap_or_default();
Ok(latest)
Ok(events.first().map(grasp_list_servers).unwrap_or_default())
}
+64 -203
View File
@@ -45,6 +45,53 @@ struct Remembered {
last_used: u64,
}
impl Remembered {
fn resolve_associations<'a, A>(
remembered: &[Remembered],
scanned: &[(PathBuf, Option<String>, Option<String>)],
announcements: A,
) -> HashMap<RepoAddr, Vec<PathBuf>>
where
A: IntoIterator<Item = &'a Announcement>,
{
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
}
}
pub struct CheckoutsStore {
by_repo: HashMap<RepoAddr, Vec<PathBuf>>,
statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
@@ -117,9 +164,10 @@ impl CheckoutsStore {
if !cfg!(target_arch = "wasm32") {
let weak = cx.entity().downgrade();
cx.defer(move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.refresh(cx)) {
log::warn!("checkouts store dropped before initial refresh could run: {error}");
}
weak.update(cx, |this, cx| {
this.refresh(cx);
})
.ok();
});
}
@@ -143,13 +191,15 @@ impl CheckoutsStore {
if cfg!(target_arch = "wasm32") {
return;
}
let settings = SettingsStore::global(cx);
let addr_str = addr.to_string();
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| {
@@ -306,7 +356,7 @@ impl CheckoutsStore {
}
let associations =
CheckoutsStore::resolve_associations(&remembered, &facts, announcements.iter());
Remembered::resolve_associations(&remembered, &facts, announcements.iter());
let associations: HashMap<RepoAddr, Vec<PathBuf>> = associations
.into_iter()
@@ -435,7 +485,7 @@ impl CheckoutsStore {
Ok::<_, Error>((statuses, push_statuses))
});
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
cx.spawn(async move |this, cx| {
let Ok((statuses, push_statuses)) = work.await else {
return Ok(());
};
@@ -456,53 +506,9 @@ impl CheckoutsStore {
}
})?;
Ok(())
});
task.detach();
}
}
impl CheckoutsStore {
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
Ok::<(), anyhow::Error>(())
})
.detach();
}
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
@@ -535,7 +541,7 @@ impl CheckoutsStore {
})
}
// Never fetches the checked-out refs; reads the tracking refs as-is.
/// Returns checkouts that are ahead of their remote tracking branch.
fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
let repo = Repo::try_open(path)?;
if repo.is_dirty() {
@@ -546,15 +552,13 @@ impl CheckoutsStore {
let head = repo.head()?;
let origin = repo.origin_url().ok().flatten()?;
if fetch {
repo.fetch_refs(&[origin], "+refs/heads/*:refs/remotes/origin/*")
.ok();
if fetch && let Err(e) = repo.fetch_refs(&[origin], "+refs/heads/*:refs/remotes/origin/*") {
log::warn!("failed to fetch refs for {path:?}: {e}");
}
let remote = format!("refs/remotes/origin/{branch}");
// A branch never fetched or pushed yet compares against the remote
// HEAD, the fork point in practice.
// If the branch is new and has no remote tracking branch, fall back to origin/HEAD.
let base = if repo.ref_exists(&remote) {
remote
} else if repo.ref_exists("refs/remotes/origin/HEAD") {
@@ -648,146 +652,3 @@ impl CheckoutsStore {
branch_matches || tip_matches
}
}
#[cfg(test)]
mod tests {
use std::process::Command;
use super::*;
#[test]
fn same_repo_url_ignores_the_transport_scheme() {
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"
));
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"
));
assert!(same_repo_url("/local/path", "/local/path"));
assert!(!same_repo_url("/local/path", "/local/other"));
}
#[test]
fn checkout_status_reports_ahead_branches_only() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("repo");
let _initial = Repo::init(&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]);
};
run(&["checkout", "-b", "feature"]);
std::fs::write(path.join("feature.txt"), "x\n").expect("write");
commit("feature work");
let status = CheckoutsStore::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);
std::fs::write(path.join("uncommitted.txt"), "y\n").expect("write");
assert!(CheckoutsStore::checkout_status(&path, Some("main")).is_none());
run(&["checkout", "--", "."]);
run(&["checkout", "main"]);
assert_eq!(CheckoutsStore::checkout_status(&path, Some("main")), None);
}
#[test]
fn checkout_push_status_counts_unpushed_commits_only() {
let dir = tempfile::tempdir().expect("tempdir");
let remote = dir.path().join("remote");
Repo::init(&remote, "My Repo", "").expect("init");
let config = Command::new("git")
.args(["config", "receive.denyCurrentBranch", "ignore"])
.current_dir(&remote)
.status()
.expect("git config");
assert!(config.success(), "git config failed");
let checkout = dir.path().join("checkout");
let status = Command::new("git")
.args([
"clone",
"-q",
remote.to_str().unwrap(),
checkout.to_str().unwrap(),
])
.status()
.expect("git clone");
assert!(status.success(), "git clone failed");
let run = |args: &[&str]| {
let status = Command::new("git")
.current_dir(&checkout)
.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");
};
// A fresh clone has nothing to push.
assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None);
std::fs::write(checkout.join("work.txt"), "x\n").expect("write");
run(&["add", "-A"]);
run(&["commit", "-m", "local work"]);
let status = CheckoutsStore::checkout_push_status(&checkout, true).expect("status");
assert_eq!(status.branch, "main");
assert_eq!(status.base, "refs/remotes/origin/main");
assert_eq!(status.ahead, 1);
assert_eq!(status.head.len(), 40);
let local = CheckoutsStore::checkout_push_status(&checkout, false).expect("local status");
assert_eq!(local.ahead, 1);
run(&["push", "origin", "main"]);
assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None);
let remote_run = |args: &[&str]| {
let status = Command::new("git")
.current_dir(&remote)
.env("GIT_AUTHOR_NAME", "Other Author")
.env("GIT_AUTHOR_EMAIL", "other@example.com")
.env("GIT_COMMITTER_NAME", "Other Author")
.env("GIT_COMMITTER_EMAIL", "other@example.com")
.args(args)
.status()
.expect("git");
assert!(status.success(), "git {args:?} failed");
};
std::fs::write(remote.join("other.txt"), "y\n").expect("write");
remote_run(&["add", "-A"]);
remote_run(&["commit", "-m", "remote work"]);
assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None);
}
}
+1 -2
View File
@@ -13,8 +13,7 @@ use crate::checkouts::CheckoutsStore;
use crate::refresh::{RefreshGate, RefreshRequest};
use crate::repos::RepoListStore;
// Nostr state changes rarely, so drift detection runs on a slow poll.
const SYNC_POLL: Duration = Duration::from_secs(60);
const SYNC_POLL: Duration = Duration::from_secs(180);
const MAX_SYNC_CHECKOUTS: usize = 8;
struct GlobalSyncStatusStore(Entity<SyncStatusStore>);