Skip to main content

git_spawn/
branches.rs

1//! Typed listing and bulk operations on local branches.
2//!
3//! Reached through [`Repository::branches`], which returns a [`BranchOps`]
4//! handle:
5//!
6//! ```no_run
7//! # async fn ex() -> git_spawn::Result<()> {
8//! use git_spawn::Repository;
9//!
10//! let repo = Repository::open("/path/to/repo")?;
11//!
12//! // All local branches with upstream / ahead-behind info.
13//! for b in repo.branches().list().await? {
14//!     println!("{}{}  {}  ({})",
15//!         if b.current { "* " } else { "  " },
16//!         b.name,
17//!         b.subject.as_deref().unwrap_or(""),
18//!         b.upstream.as_deref().unwrap_or("no upstream"),
19//!     );
20//! }
21//!
22//! // Delete branches merged into main.
23//! let deleted = repo.branches().delete_merged("main").await?;
24//! println!("removed {} merged branch(es)", deleted.len());
25//! # Ok(())
26//! # }
27//! ```
28//!
29//! Listing is implemented with `git for-each-ref refs/heads/` and a fixed,
30//! NUL-delimited format string, so the parser is deterministic across git
31//! versions and locales.
32
33use crate::command::GitCommand;
34use crate::command::branch::BranchCommand;
35use crate::command::for_each_ref::ForEachRefCommand;
36use crate::error::Result;
37use crate::repo::Repository;
38
39/// One local branch with tracking info.
40#[derive(Debug, Clone, Default, PartialEq, Eq)]
41#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
42pub struct Branch {
43    /// Short branch name (e.g. `"main"`).
44    pub name: String,
45    /// `true` when this is the currently-checked-out branch.
46    pub current: bool,
47    /// Configured upstream in `remote/branch` form, if any.
48    pub upstream: Option<String>,
49    /// Commits this branch is ahead of its upstream.
50    pub ahead: u32,
51    /// Commits this branch is behind its upstream.
52    pub behind: u32,
53    /// `true` when the configured upstream no longer exists (`[gone]`).
54    pub upstream_gone: bool,
55    /// Short SHA of the branch tip.
56    pub head: String,
57    /// Subject line of the tip commit, if non-empty.
58    pub subject: Option<String>,
59}
60
61/// Operations on local branches, scoped to a [`Repository`].
62///
63/// Obtained via [`Repository::branches`]. The handle borrows the repository
64/// for the duration of one chained call — there is no shared state.
65#[derive(Debug)]
66pub struct BranchOps<'a> {
67    repo: &'a Repository,
68}
69
70impl<'a> BranchOps<'a> {
71    /// List every local branch.
72    pub async fn list(&self) -> Result<Vec<Branch>> {
73        self.list_inner(None).await
74    }
75
76    /// List branches whose ref path matches `pattern` (a `fnmatch`-style glob
77    /// applied to the full ref, e.g. `"refs/heads/feature/*"`). For short-name
78    /// matching, pass `"refs/heads/<glob>"`.
79    pub async fn list_matching(&self, pattern: impl Into<String>) -> Result<Vec<Branch>> {
80        self.list_inner(Some(pattern.into())).await
81    }
82
83    /// Delete every local branch fully merged into `into` (other than `into`
84    /// itself and the current branch). Returns the deleted branch names.
85    pub async fn delete_merged(&self, into: impl AsRef<str>) -> Result<Vec<String>> {
86        let into = into.as_ref();
87        let current = self.list().await?.into_iter().find(|b| b.current);
88        let current_name = current.as_ref().map(|b| b.name.as_str());
89
90        let mut cmd = ForEachRefCommand::new();
91        cmd.pattern("refs/heads/")
92            .format("%(refname:short)".to_string())
93            .merged(into.to_string());
94        cmd.current_dir(self.repo.path());
95        let out = cmd.execute().await?;
96
97        let mut deleted = Vec::new();
98        for name in out.stdout.lines() {
99            if name.is_empty() || name == into || Some(name) == current_name {
100                continue;
101            }
102            let mut del = BranchCommand::new();
103            del.delete(name);
104            del.current_dir(self.repo.path());
105            del.execute().await?;
106            deleted.push(name.to_string());
107        }
108        Ok(deleted)
109    }
110
111    /// Rename a branch (`git branch -m <from> <to>`).
112    pub async fn rename(&self, from: impl Into<String>, to: impl Into<String>) -> Result<()> {
113        let mut cmd = BranchCommand::new();
114        cmd.rename(from, to);
115        cmd.current_dir(self.repo.path());
116        cmd.execute().await?;
117        Ok(())
118    }
119
120    async fn list_inner(&self, pattern: Option<String>) -> Result<Vec<Branch>> {
121        let mut cmd = ForEachRefCommand::new();
122        cmd.format(FORMAT.to_string())
123            .pattern(pattern.unwrap_or_else(|| "refs/heads/".to_string()));
124        cmd.current_dir(self.repo.path());
125        let out = cmd.execute().await?;
126        parse_branches(&out.stdout)
127    }
128}
129
130impl Repository {
131    /// Operations on local branches.
132    #[must_use]
133    pub fn branches(&self) -> BranchOps<'_> {
134        BranchOps { repo: self }
135    }
136}
137
138/// NUL-delimited format for one record. Field order matches [`parse_branches`].
139const FORMAT: &str = concat!(
140    "%(refname:short)",
141    "%00",
142    "%(HEAD)",
143    "%00",
144    "%(upstream:short)",
145    "%00",
146    "%(upstream:track)",
147    "%00",
148    "%(objectname:short)",
149    "%00",
150    "%(contents:subject)",
151);
152
153fn parse_branches(stdout: &str) -> Result<Vec<Branch>> {
154    let mut out = Vec::new();
155    for line in stdout.lines() {
156        if line.is_empty() {
157            continue;
158        }
159        let fields: Vec<&str> = line.split('\0').collect();
160        if fields.len() < 6 {
161            return Err(crate::error::Error::parse_error(format!(
162                "branch record has {} fields, expected 6: {line:?}",
163                fields.len()
164            )));
165        }
166        let (ahead, behind, gone) = parse_track(fields[3]);
167        out.push(Branch {
168            name: fields[0].to_string(),
169            current: fields[1] == "*",
170            upstream: if fields[2].is_empty() {
171                None
172            } else {
173                Some(fields[2].to_string())
174            },
175            ahead,
176            behind,
177            upstream_gone: gone,
178            head: fields[4].to_string(),
179            subject: if fields[5].is_empty() {
180                None
181            } else {
182                Some(fields[5].to_string())
183            },
184        });
185    }
186    Ok(out)
187}
188
189fn parse_track(s: &str) -> (u32, u32, bool) {
190    // Possible shapes: "", "[gone]", "[ahead 1]", "[behind 2]", "[ahead 1, behind 2]"
191    let inside = s.trim().trim_start_matches('[').trim_end_matches(']');
192    if inside.is_empty() {
193        return (0, 0, false);
194    }
195    if inside == "gone" {
196        return (0, 0, true);
197    }
198    let mut ahead = 0;
199    let mut behind = 0;
200    for part in inside.split(',') {
201        let part = part.trim();
202        if let Some(n) = part.strip_prefix("ahead ") {
203            ahead = n.parse().unwrap_or(0);
204        } else if let Some(n) = part.strip_prefix("behind ") {
205            behind = n.parse().unwrap_or(0);
206        }
207    }
208    (ahead, behind, false)
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn parses_track_field() {
217        assert_eq!(parse_track(""), (0, 0, false));
218        assert_eq!(parse_track("[gone]"), (0, 0, true));
219        assert_eq!(parse_track("[ahead 3]"), (3, 0, false));
220        assert_eq!(parse_track("[behind 2]"), (0, 2, false));
221        assert_eq!(parse_track("[ahead 1, behind 4]"), (1, 4, false));
222    }
223
224    #[test]
225    fn parses_branch_records() {
226        let line1 = "main\0*\0origin/main\0[ahead 1]\0abc1234\0fix: things";
227        let line2 = "feature/x\0 \0\0\0def5678\0";
228        let input = format!("{line1}\n{line2}\n");
229        let branches = parse_branches(&input).unwrap();
230        assert_eq!(branches.len(), 2);
231
232        assert_eq!(branches[0].name, "main");
233        assert!(branches[0].current);
234        assert_eq!(branches[0].upstream.as_deref(), Some("origin/main"));
235        assert_eq!(branches[0].ahead, 1);
236        assert_eq!(branches[0].behind, 0);
237        assert!(!branches[0].upstream_gone);
238        assert_eq!(branches[0].head, "abc1234");
239        assert_eq!(branches[0].subject.as_deref(), Some("fix: things"));
240
241        assert_eq!(branches[1].name, "feature/x");
242        assert!(!branches[1].current);
243        assert!(branches[1].upstream.is_none());
244        assert!(branches[1].subject.is_none());
245        assert_eq!(branches[1].head, "def5678");
246    }
247
248    #[test]
249    fn malformed_record_errors() {
250        let input = "only\0three\0fields\n";
251        assert!(parse_branches(input).is_err());
252    }
253}