1use 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
41#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
42pub struct Branch {
43 pub name: String,
45 pub current: bool,
47 pub upstream: Option<String>,
49 pub ahead: u32,
51 pub behind: u32,
53 pub upstream_gone: bool,
55 pub head: String,
57 pub subject: Option<String>,
59}
60
61#[derive(Debug)]
66pub struct BranchOps<'a> {
67 repo: &'a Repository,
68}
69
70impl<'a> BranchOps<'a> {
71 pub async fn list(&self) -> Result<Vec<Branch>> {
73 self.list_inner(None).await
74 }
75
76 pub async fn list_matching(&self, pattern: impl Into<String>) -> Result<Vec<Branch>> {
80 self.list_inner(Some(pattern.into())).await
81 }
82
83 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 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 #[must_use]
133 pub fn branches(&self) -> BranchOps<'_> {
134 BranchOps { repo: self }
135 }
136}
137
138const 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 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}