fix: repository sync #29
@@ -62,16 +62,13 @@ impl Repo {
|
|||||||
pub fn init(path: &Path, name: &str, description: &str) -> Result<String> {
|
pub fn init(path: &Path, name: &str, description: &str) -> Result<String> {
|
||||||
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
||||||
|
|
||||||
std::fs::create_dir_all(path)
|
std::fs::create_dir_all(path)?;
|
||||||
.with_context(|| format!("failed to create {}", path.display()))?;
|
|
||||||
|
|
||||||
let repo = gix::init(path)?;
|
let repo = gix::init(path)?;
|
||||||
|
|
||||||
let (signature, mut time_buf) = Self::repository_signature();
|
let (signature, mut time_buf) = Self::repository_signature();
|
||||||
let signature = signature.to_ref(&mut time_buf);
|
|
||||||
|
|
||||||
let head = gix::refs::FullName::try_from("HEAD")
|
let signature = signature.to_ref(&mut time_buf);
|
||||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
let head = gix::refs::FullName::try_from("HEAD")?;
|
||||||
|
|
||||||
repo.edit_references_as(
|
repo.edit_references_as(
|
||||||
[RefEdit {
|
[RefEdit {
|
||||||
@@ -99,7 +96,7 @@ impl Repo {
|
|||||||
format!("# {name}\n\n{description}\n")
|
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 {
|
let blob = repo.write_object(gix::objs::Blob {
|
||||||
data: readme.into_bytes(),
|
data: readme.into_bytes(),
|
||||||
|
|||||||
@@ -78,16 +78,10 @@ pub async fn user_grasp_list_servers(
|
|||||||
) -> Result<Vec<RelayUrl>, Error> {
|
) -> Result<Vec<RelayUrl>, Error> {
|
||||||
let events: Vec<Event> = client
|
let events: Vec<Event> = client
|
||||||
.database()
|
.database()
|
||||||
.query(Filters::grasp_list(user))
|
.query(Filters::grasp_list(user).limit(1))
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let latest = events
|
Ok(events.first().map(grasp_list_servers).unwrap_or_default())
|
||||||
.into_iter()
|
|
||||||
.max_by_key(|event| event.created_at)
|
|
||||||
.map(|event| grasp_list_servers(&event))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(latest)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,53 @@ struct Remembered {
|
|||||||
last_used: u64,
|
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 {
|
pub struct CheckoutsStore {
|
||||||
by_repo: HashMap<RepoAddr, Vec<PathBuf>>,
|
by_repo: HashMap<RepoAddr, Vec<PathBuf>>,
|
||||||
statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
|
statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
|
||||||
@@ -117,9 +164,10 @@ impl CheckoutsStore {
|
|||||||
if !cfg!(target_arch = "wasm32") {
|
if !cfg!(target_arch = "wasm32") {
|
||||||
let weak = cx.entity().downgrade();
|
let weak = cx.entity().downgrade();
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
if let Err(error) = weak.update(cx, |this, cx| this.refresh(cx)) {
|
weak.update(cx, |this, cx| {
|
||||||
log::warn!("checkouts store dropped before initial refresh could run: {error}");
|
this.refresh(cx);
|
||||||
}
|
})
|
||||||
|
.ok();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,13 +191,15 @@ impl CheckoutsStore {
|
|||||||
if cfg!(target_arch = "wasm32") {
|
if cfg!(target_arch = "wasm32") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let settings = SettingsStore::global(cx);
|
||||||
|
let addr_str = addr.to_string();
|
||||||
|
|
||||||
let last_used = SystemTime::now()
|
let last_used = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs())
|
.map(|d| d.as_secs())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let addr_str = addr.to_string();
|
|
||||||
|
|
||||||
let settings = SettingsStore::global(cx);
|
|
||||||
settings.update(cx, |settings, cx| {
|
settings.update(cx, |settings, cx| {
|
||||||
settings.edit(
|
settings.edit(
|
||||||
|s| {
|
|s| {
|
||||||
@@ -306,7 +356,7 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let associations =
|
let associations =
|
||||||
CheckoutsStore::resolve_associations(&remembered, &facts, announcements.iter());
|
Remembered::resolve_associations(&remembered, &facts, announcements.iter());
|
||||||
|
|
||||||
let associations: HashMap<RepoAddr, Vec<PathBuf>> = associations
|
let associations: HashMap<RepoAddr, Vec<PathBuf>> = associations
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -435,7 +485,7 @@ impl CheckoutsStore {
|
|||||||
Ok::<_, Error>((statuses, push_statuses))
|
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 {
|
let Ok((statuses, push_statuses)) = work.await else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
@@ -456,53 +506,9 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok::<(), anyhow::Error>(())
|
||||||
});
|
})
|
||||||
task.detach();
|
.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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
|
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> {
|
fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
||||||
let repo = Repo::try_open(path)?;
|
let repo = Repo::try_open(path)?;
|
||||||
if repo.is_dirty() {
|
if repo.is_dirty() {
|
||||||
@@ -546,15 +552,13 @@ impl CheckoutsStore {
|
|||||||
let head = repo.head()?;
|
let head = repo.head()?;
|
||||||
let origin = repo.origin_url().ok().flatten()?;
|
let origin = repo.origin_url().ok().flatten()?;
|
||||||
|
|
||||||
if fetch {
|
if fetch && let Err(e) = repo.fetch_refs(&[origin], "+refs/heads/*:refs/remotes/origin/*") {
|
||||||
repo.fetch_refs(&[origin], "+refs/heads/*:refs/remotes/origin/*")
|
log::warn!("failed to fetch refs for {path:?}: {e}");
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let remote = format!("refs/remotes/origin/{branch}");
|
let remote = format!("refs/remotes/origin/{branch}");
|
||||||
|
|
||||||
// A branch never fetched or pushed yet compares against the remote
|
// If the branch is new and has no remote tracking branch, fall back to origin/HEAD.
|
||||||
// HEAD, the fork point in practice.
|
|
||||||
let base = if repo.ref_exists(&remote) {
|
let base = if repo.ref_exists(&remote) {
|
||||||
remote
|
remote
|
||||||
} else if repo.ref_exists("refs/remotes/origin/HEAD") {
|
} else if repo.ref_exists("refs/remotes/origin/HEAD") {
|
||||||
@@ -648,146 +652,3 @@ impl CheckoutsStore {
|
|||||||
branch_matches || tip_matches
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ use crate::checkouts::CheckoutsStore;
|
|||||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||||
use crate::repos::RepoListStore;
|
use crate::repos::RepoListStore;
|
||||||
|
|
||||||
// Nostr state changes rarely, so drift detection runs on a slow poll.
|
const SYNC_POLL: Duration = Duration::from_secs(180);
|
||||||
const SYNC_POLL: Duration = Duration::from_secs(60);
|
|
||||||
const MAX_SYNC_CHECKOUTS: usize = 8;
|
const MAX_SYNC_CHECKOUTS: usize = 8;
|
||||||
|
|
||||||
struct GlobalSyncStatusStore(Entity<SyncStatusStore>);
|
struct GlobalSyncStatusStore(Entity<SyncStatusStore>);
|
||||||
|
|||||||
Reference in New Issue
Block a user