use crate::command::GitCommand;
use crate::command::branch::BranchCommand;
use crate::command::for_each_ref::ForEachRefCommand;
use crate::error::Result;
use crate::repo::Repository;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Branch {
pub name: String,
pub current: bool,
pub upstream: Option<String>,
pub ahead: u32,
pub behind: u32,
pub upstream_gone: bool,
pub head: String,
pub subject: Option<String>,
}
#[derive(Debug)]
pub struct BranchOps<'a> {
repo: &'a Repository,
}
impl<'a> BranchOps<'a> {
pub async fn list(&self) -> Result<Vec<Branch>> {
self.list_inner(None).await
}
pub async fn list_matching(&self, pattern: impl Into<String>) -> Result<Vec<Branch>> {
self.list_inner(Some(pattern.into())).await
}
pub async fn delete_merged(&self, into: impl AsRef<str>) -> Result<Vec<String>> {
let into = into.as_ref();
let current = self.list().await?.into_iter().find(|b| b.current);
let current_name = current.as_ref().map(|b| b.name.as_str());
let mut cmd = ForEachRefCommand::new();
cmd.pattern("refs/heads/")
.format("%(refname:short)".to_string())
.merged(into.to_string());
cmd.current_dir(self.repo.path());
let out = cmd.execute().await?;
let mut deleted = Vec::new();
for name in out.stdout.lines() {
if name.is_empty() || name == into || Some(name) == current_name {
continue;
}
let mut del = BranchCommand::new();
del.delete(name);
del.current_dir(self.repo.path());
del.execute().await?;
deleted.push(name.to_string());
}
Ok(deleted)
}
pub async fn rename(&self, from: impl Into<String>, to: impl Into<String>) -> Result<()> {
let mut cmd = BranchCommand::new();
cmd.rename(from, to);
cmd.current_dir(self.repo.path());
cmd.execute().await?;
Ok(())
}
async fn list_inner(&self, pattern: Option<String>) -> Result<Vec<Branch>> {
let mut cmd = ForEachRefCommand::new();
cmd.format(FORMAT.to_string())
.pattern(pattern.unwrap_or_else(|| "refs/heads/".to_string()));
cmd.current_dir(self.repo.path());
let out = cmd.execute().await?;
parse_branches(&out.stdout)
}
}
impl Repository {
#[must_use]
pub fn branches(&self) -> BranchOps<'_> {
BranchOps { repo: self }
}
}
const FORMAT: &str = concat!(
"%(refname:short)",
"%00",
"%(HEAD)",
"%00",
"%(upstream:short)",
"%00",
"%(upstream:track)",
"%00",
"%(objectname:short)",
"%00",
"%(contents:subject)",
);
fn parse_branches(stdout: &str) -> Result<Vec<Branch>> {
let mut out = Vec::new();
for line in stdout.lines() {
if line.is_empty() {
continue;
}
let fields: Vec<&str> = line.split('\0').collect();
if fields.len() < 6 {
return Err(crate::error::Error::parse_error(format!(
"branch record has {} fields, expected 6: {line:?}",
fields.len()
)));
}
let (ahead, behind, gone) = parse_track(fields[3]);
out.push(Branch {
name: fields[0].to_string(),
current: fields[1] == "*",
upstream: if fields[2].is_empty() {
None
} else {
Some(fields[2].to_string())
},
ahead,
behind,
upstream_gone: gone,
head: fields[4].to_string(),
subject: if fields[5].is_empty() {
None
} else {
Some(fields[5].to_string())
},
});
}
Ok(out)
}
fn parse_track(s: &str) -> (u32, u32, bool) {
let inside = s.trim().trim_start_matches('[').trim_end_matches(']');
if inside.is_empty() {
return (0, 0, false);
}
if inside == "gone" {
return (0, 0, true);
}
let mut ahead = 0;
let mut behind = 0;
for part in inside.split(',') {
let part = part.trim();
if let Some(n) = part.strip_prefix("ahead ") {
ahead = n.parse().unwrap_or(0);
} else if let Some(n) = part.strip_prefix("behind ") {
behind = n.parse().unwrap_or(0);
}
}
(ahead, behind, false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_track_field() {
assert_eq!(parse_track(""), (0, 0, false));
assert_eq!(parse_track("[gone]"), (0, 0, true));
assert_eq!(parse_track("[ahead 3]"), (3, 0, false));
assert_eq!(parse_track("[behind 2]"), (0, 2, false));
assert_eq!(parse_track("[ahead 1, behind 4]"), (1, 4, false));
}
#[test]
fn parses_branch_records() {
let line1 = "main\0*\0origin/main\0[ahead 1]\0abc1234\0fix: things";
let line2 = "feature/x\0 \0\0\0def5678\0";
let input = format!("{line1}\n{line2}\n");
let branches = parse_branches(&input).unwrap();
assert_eq!(branches.len(), 2);
assert_eq!(branches[0].name, "main");
assert!(branches[0].current);
assert_eq!(branches[0].upstream.as_deref(), Some("origin/main"));
assert_eq!(branches[0].ahead, 1);
assert_eq!(branches[0].behind, 0);
assert!(!branches[0].upstream_gone);
assert_eq!(branches[0].head, "abc1234");
assert_eq!(branches[0].subject.as_deref(), Some("fix: things"));
assert_eq!(branches[1].name, "feature/x");
assert!(!branches[1].current);
assert!(branches[1].upstream.is_none());
assert!(branches[1].subject.is_none());
assert_eq!(branches[1].head, "def5678");
}
#[test]
fn malformed_record_errors() {
let input = "only\0three\0fields\n";
assert!(parse_branches(input).is_err());
}
}