diff --git a/AGENTS.md b/AGENTS.md index c28ad0a..c3d2695 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,9 @@ * Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified. * Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious. +* Keep doc comments and comments simple, one line, straightforward, and unbloated. * Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files. +* Prefer `impl` blocks over standalone functions, place functionality as methods or associated functions on the relevant type, extending the type's existing `impl` block instead of creating a duplicate one in another file. * Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors. * Be careful with operations like indexing which may panic if the indexes are out of bounds. * Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately: diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index feaceca..35aa7ef 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -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 { diff --git a/crates/signed_git/src/repo.rs b/crates/signed_git/src/repo.rs index 64282ad..780ac3b 100644 --- a/crates/signed_git/src/repo.rs +++ b/crates/signed_git/src/repo.rs @@ -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 { + 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> { let workdir = self.inner.workdir().context("repository has no worktree")?; diff --git a/crates/signed_git/src/sync.rs b/crates/signed_git/src/sync.rs new file mode 100644 index 0000000..2a80f80 --- /dev/null +++ b/crates/signed_git/src/sync.rs @@ -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() + } +}