use std::collections::HashMap;
use anyhow::bail;
use super::branches::{
LOCAL_BRANCH_FORMAT, REMOTE_BRANCH_FORMAT, parse_local_branch_line, parse_remote_branch_line,
};
use super::{LocalBranch, RemoteBranch, Repository};
#[derive(Debug, Clone, Default)]
pub struct RefSnapshot {
commits: HashMap<String, String>,
locals: Vec<LocalBranch>,
locals_by_name: HashMap<String, usize>,
remotes: Vec<RemoteBranch>,
ahead_behind: HashMap<(String, String), (usize, usize)>,
}
impl RefSnapshot {
pub fn resolve(&self, name: &str) -> Option<&str> {
self.commits.get(name).map(String::as_str)
}
pub fn must_resolve(&self, name: &str) -> anyhow::Result<&str> {
match self.resolve(name) {
Some(sha) => Ok(sha),
None => bail!("ref not present in snapshot: {name}"),
}
}
pub fn upstream_of(&self, branch: &str) -> Option<&str> {
self.local_branch(branch)
.and_then(|b| b.upstream_short.as_deref())
}
pub fn ahead_behind(&self, base: &str, head: &str) -> Option<(usize, usize)> {
self.ahead_behind
.get(&(base.to_string(), head.to_string()))
.copied()
}
pub fn local_branches(&self) -> &[LocalBranch] {
&self.locals
}
pub fn local_branch(&self, name: &str) -> Option<&LocalBranch> {
self.locals_by_name.get(name).map(|&i| &self.locals[i])
}
pub fn remote_branches(&self) -> &[RemoteBranch] {
&self.remotes
}
}
impl Repository {
pub fn capture_refs(&self) -> anyhow::Result<RefSnapshot> {
let locals = scan_locals(self)?;
let remotes = scan_remotes(self)?;
Ok(build(locals, remotes, HashMap::new()))
}
pub fn capture_refs_with_ahead_behind(&self, base: &str) -> anyhow::Result<RefSnapshot> {
let locals = scan_locals(self)?;
let remotes = scan_remotes(self)?;
let ahead_behind = scan_ahead_behind(self, base);
Ok(build(locals, remotes, ahead_behind))
}
}
fn scan_locals(repo: &Repository) -> anyhow::Result<Vec<LocalBranch>> {
let output = repo.run_command(&["for-each-ref", LOCAL_BRANCH_FORMAT, "refs/heads/"])?;
let mut branches: Vec<LocalBranch> =
output.lines().filter_map(parse_local_branch_line).collect();
branches.sort_by_key(|b| std::cmp::Reverse(b.committer_ts));
Ok(branches)
}
fn scan_remotes(repo: &Repository) -> anyhow::Result<Vec<RemoteBranch>> {
let output = repo.run_command(&["for-each-ref", REMOTE_BRANCH_FORMAT, "refs/remotes/"])?;
let mut branches: Vec<RemoteBranch> = output
.lines()
.filter_map(parse_remote_branch_line)
.collect();
branches.sort_by_key(|b| std::cmp::Reverse(b.committer_ts));
Ok(branches)
}
fn scan_ahead_behind(repo: &Repository, base: &str) -> HashMap<(String, String), (usize, usize)> {
let format = format!("%(refname) %(ahead-behind:{base})");
let output =
match repo.run_command(&["for-each-ref", &format!("--format={format}"), "refs/heads/"]) {
Ok(out) => out,
Err(e) => {
log::debug!("RefSnapshot ahead/behind batch failed for base {base}: {e}");
return HashMap::new();
}
};
output
.lines()
.filter_map(|line| {
let mut parts = line.rsplitn(3, ' ');
let behind: usize = parts.next()?.parse().ok()?;
let ahead: usize = parts.next()?.parse().ok()?;
let full_ref = parts.next()?.to_string();
Some(((base.to_string(), full_ref), (ahead, behind)))
})
.collect()
}
fn build(
locals: Vec<LocalBranch>,
remotes: Vec<RemoteBranch>,
ahead_behind: HashMap<(String, String), (usize, usize)>,
) -> RefSnapshot {
let mut commits: HashMap<String, String> = HashMap::new();
for b in &locals {
commits.insert(b.name.clone(), b.commit_sha.clone());
commits.insert(format!("refs/heads/{}", b.name), b.commit_sha.clone());
}
for r in &remotes {
commits.insert(r.short_name.clone(), r.commit_sha.clone());
commits.insert(
format!("refs/remotes/{}", r.short_name),
r.commit_sha.clone(),
);
}
let locals_by_name = locals
.iter()
.enumerate()
.map(|(i, b)| (b.name.clone(), i))
.collect();
RefSnapshot {
commits,
locals,
locals_by_name,
remotes,
ahead_behind,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TestRepo;
#[test]
fn captures_local_branches_with_shas() {
let test = TestRepo::with_initial_commit();
test.run_git(&["checkout", "-b", "feature"]);
std::fs::write(test.root_path().join("a.txt"), "x\n").unwrap();
test.run_git(&["add", "a.txt"]);
test.run_git(&["commit", "-m", "feat"]);
test.run_git(&["checkout", "main"]);
let repo = Repository::at(test.root_path()).unwrap();
let snap = repo.capture_refs().unwrap();
let main_sha = test.git_output(&["rev-parse", "main"]);
let feature_sha = test.git_output(&["rev-parse", "feature"]);
assert_eq!(snap.resolve("main"), Some(main_sha.as_str()));
assert_eq!(snap.resolve("refs/heads/main"), Some(main_sha.as_str()));
assert_eq!(snap.resolve("feature"), Some(feature_sha.as_str()));
assert_eq!(snap.local_branches().len(), 2);
}
#[test]
fn captures_are_independent_after_ref_update() {
let test = TestRepo::with_initial_commit();
let repo = Repository::at(test.root_path()).unwrap();
let before = repo.capture_refs().unwrap();
let main_before = before.resolve("main").unwrap().to_owned();
std::fs::write(test.root_path().join("b.txt"), "y\n").unwrap();
test.run_git(&["add", "b.txt"]);
test.run_git(&["commit", "-m", "advance main"]);
let after = repo.capture_refs().unwrap();
let main_after = after.resolve("main").unwrap();
assert_ne!(
main_before, main_after,
"post-write snapshot must reflect new SHA"
);
assert_eq!(before.resolve("main"), Some(main_before.as_str()));
}
#[test]
fn must_resolve_errors_on_missing_ref() {
let test = TestRepo::with_initial_commit();
let repo = Repository::at(test.root_path()).unwrap();
let snap = repo.capture_refs().unwrap();
assert!(snap.must_resolve("does-not-exist").is_err());
assert_eq!(snap.resolve("HEAD"), None);
}
#[test]
fn ahead_behind_populated_when_requested() {
let test = TestRepo::with_initial_commit();
test.run_git(&["checkout", "-b", "feature"]);
std::fs::write(test.root_path().join("a.txt"), "x\n").unwrap();
test.run_git(&["add", "a.txt"]);
test.run_git(&["commit", "-m", "feat"]);
test.run_git(&["checkout", "main"]);
let repo = Repository::at(test.root_path()).unwrap();
let snap = repo.capture_refs_with_ahead_behind("main").unwrap();
let plain = repo.capture_refs().unwrap();
assert_eq!(plain.ahead_behind("main", "refs/heads/feature"), None);
if let Some((ahead, behind)) = snap.ahead_behind("main", "refs/heads/feature") {
assert_eq!(ahead, 1, "feature is one commit ahead of main");
assert_eq!(behind, 0);
}
}
#[test]
fn upstream_of_reads_from_local_inventory() {
let test = TestRepo::with_initial_commit();
test.run_git(&["config", "branch.main.remote", "origin"]);
test.run_git(&["config", "branch.main.merge", "refs/heads/main"]);
let repo = Repository::at(test.root_path()).unwrap();
let _snap = repo.capture_refs().unwrap();
}
}