feat: add sync detection for repositories (#28)
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run

Reviewed-on: #28
This commit was merged in pull request #28.
This commit is contained in:
2026-10-08 02:34:33 +00:00
parent 31256a4870
commit 4d8aa97e24
12 changed files with 691 additions and 37 deletions
+2
View File
@@ -5,6 +5,7 @@ mod nip34;
mod patch;
mod repo;
mod scan;
mod sync;
mod worktree;
#[cfg(test)]
@@ -17,6 +18,7 @@ pub use nip34::{GraspSignals, Nip34Binding, Nip34Kind};
pub use patch::PatchParser;
pub use repo::{Repo, RepoRefState};
pub use scan::{LocalRepo, find_git_repos};
pub use sync::{RefSync, RepoSyncStatus};
pub use worktree::WorktreeSnapshot;
pub(crate) trait GixResultExt<T> {
+82 -1
View File
@@ -1,4 +1,4 @@
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;
@@ -13,6 +13,7 @@ use crate::GixResultExt as _;
use crate::diff::{CommitDiff, DiffStatus, FileDiff, HunkCollector};
use crate::history::{CommitList, FileCommit, MAX_LISTED_COMMITS};
use crate::nip34::{GraspSignals, Nip34Binding, Nip34Json, Nip34Kind};
use crate::sync::{RefSync, RepoSyncStatus};
use crate::worktree::WorktreeSnapshot;
const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024;
@@ -797,6 +798,86 @@ impl Repo {
self.inner.rev_parse_single(rev.as_bytes()).ok()
}
/// Classifies every local branch against the Nostr state refs, ignoring tags.
pub fn sync_status(&self, remote_refs: &[(String, String)]) -> Result<RepoSyncStatus> {
let local_refs = self.ref_state()?;
let local = RepoSyncStatus::branches(&local_refs.refs);
let remote = RepoSyncStatus::branches(remote_refs);
let names: BTreeSet<&str> = local.keys().chain(remote.keys()).copied().collect();
let mut refs = Vec::new();
let mut ahead_total = 0;
let mut behind_total = 0;
for name in names {
let sync = match (local.get(name), remote.get(name)) {
(Some(local_commit), Some(remote_commit)) => {
self.branch_sync(local_commit, remote_commit)
}
(Some(_), None) => RefSync::LocalOnly,
(None, Some(_)) => RefSync::RemoteOnly,
(None, None) => continue,
};
match &sync {
RefSync::LocalAhead { ahead } => ahead_total += ahead,
RefSync::RemoteAhead { behind } => behind_total += behind,
RefSync::Diverged { ahead, behind } => {
ahead_total += ahead;
behind_total += behind;
}
RefSync::InSync | RefSync::LocalOnly | RefSync::RemoteOnly => {}
}
refs.push((name.to_owned(), sync));
}
Ok(RepoSyncStatus {
refs,
ahead_total,
behind_total,
})
}
/// Classifies a branch present on both sides.
fn branch_sync(&self, local_commit: &str, remote_commit: &str) -> RefSync {
if local_commit == remote_commit {
return RefSync::InSync;
}
match self.merge_base(local_commit, remote_commit) {
Ok(Some(base)) if base == remote_commit => RefSync::LocalAhead {
ahead: self.commits_ahead(&base, local_commit) as usize,
},
Ok(Some(base)) if base == local_commit => RefSync::RemoteAhead {
behind: self.commits_ahead(&base, remote_commit) as usize,
},
Ok(Some(base)) => RefSync::Diverged {
ahead: self.commits_ahead(&base, local_commit) as usize,
behind: self.commits_ahead(&base, remote_commit) as usize,
},
// Unrelated histories share no base, so every commit counts.
Ok(None) => RefSync::Diverged {
ahead: self.count_reachable(local_commit),
behind: self.count_reachable(remote_commit),
},
// Nostr tip missing locally, so at least that commit differs.
Err(_) => RefSync::RemoteAhead { behind: 1 },
}
}
/// Counts commits reachable from a commit id.
fn count_reachable(&self, commit: &str) -> usize {
let Ok(commit_id) = self.inner.rev_parse_single(commit.as_bytes()) else {
return 0;
};
match self.inner.rev_walk([commit_id]).all() {
Ok(walk) => walk.filter_map(Result::ok).count(),
Err(_) => 0,
}
}
/// Lists every worktree path relative to the root, directories first.
pub fn entries(&self) -> Result<Vec<PathBuf>> {
let workdir = self.inner.workdir().context("repository has no worktree")?;
+38
View File
@@ -0,0 +1,38 @@
use std::collections::BTreeMap;
/// How a single branch compares between local and Nostr.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefSync {
InSync,
LocalAhead { ahead: usize },
RemoteAhead { behind: usize },
Diverged { ahead: usize, behind: usize },
LocalOnly,
RemoteOnly,
}
/// Branch comparison between a local repository and its Nostr state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoSyncStatus {
/// Branch short name and its classification.
pub refs: Vec<(String, RefSync)>,
pub ahead_total: usize,
pub behind_total: usize,
}
impl RepoSyncStatus {
/// True when every branch matches the Nostr state.
pub fn in_sync(&self) -> bool {
self.refs.iter().all(|(_, sync)| *sync == RefSync::InSync)
}
/// Maps branch short name to lowercase commit oid.
pub(crate) fn branches(refs: &[(String, String)]) -> BTreeMap<&str, String> {
refs.iter()
.filter_map(|(name, commit)| {
name.strip_prefix("refs/heads/")
.map(|branch| (branch, commit.to_ascii_lowercase()))
})
.collect()
}
}