1use std::path::Path;
5use std::process::Command;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use crate::Error;
9
10pub fn current_refspec(cwd: &Path) -> Option<String> {
13 let branch = current_branch(cwd)?;
14 if let Some(tracked) = tracked_upstream(cwd, &branch) {
15 return Some(tracked);
16 }
17 Some(format!("refs/heads/{branch}"))
18}
19
20fn current_branch(cwd: &Path) -> Option<String> {
23 let out = Command::new("git")
24 .arg("-C")
25 .arg(cwd)
26 .args(["symbolic-ref", "--short", "HEAD"])
27 .output()
28 .ok()?;
29 if !out.status.success() {
30 return None;
31 }
32 let s = String::from_utf8_lossy(&out.stdout).trim().to_owned();
33 if s.is_empty() { None } else { Some(s) }
34}
35
36fn tracked_upstream(cwd: &Path, branch: &str) -> Option<String> {
40 let key = format!("branch.{branch}.merge");
41 let out = Command::new("git")
42 .arg("-C")
43 .arg(cwd)
44 .args(["config", "--get", &key])
45 .output()
46 .ok()?;
47 if !out.status.success() {
48 return None;
49 }
50 let s = String::from_utf8_lossy(&out.stdout).trim().to_owned();
51 if s.is_empty() { None } else { Some(s) }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct RecentRef {
57 pub full: String,
60 pub oid: String,
62 pub kind: RefKind,
63 pub committer_unix: i64,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum RefKind {
70 LocalBranch,
72 RemoteBranch,
74 Tag,
76 Other,
78}
79
80pub fn recent_branches(
94 cwd: &Path,
95 since: SystemTime,
96 include_remote_branches: bool,
97 only_remote: Option<&str>,
98) -> Result<Vec<RecentRef>, Error> {
99 let since_unix: i64 = since
100 .duration_since(UNIX_EPOCH)
101 .map(|d| d.as_secs() as i64)
102 .unwrap_or(0);
103 let out = Command::new("git")
104 .arg("-C")
105 .arg(cwd)
106 .args([
107 "for-each-ref",
108 "--sort=-committerdate",
109 "--format=%(refname) %(objectname) %(committerdate:unix)",
110 "refs",
111 ])
112 .output()?;
113 if !out.status.success() {
114 return Err(Error::Failed(format!(
115 "git for-each-ref failed: {}",
116 String::from_utf8_lossy(&out.stderr).trim()
117 )));
118 }
119
120 let mut result = Vec::new();
121 for line in String::from_utf8_lossy(&out.stdout).lines() {
122 let mut parts = line.splitn(3, ' ');
123 let (Some(full), Some(oid), Some(unix_str)) = (parts.next(), parts.next(), parts.next())
124 else {
125 continue;
126 };
127 let Ok(committer_unix) = unix_str.trim().parse::<i64>() else {
128 continue;
129 };
130 if committer_unix < since_unix {
133 break;
134 }
135 let kind = classify_ref(full);
136 if matches!(kind, RefKind::RemoteBranch) {
137 if !include_remote_branches {
138 continue;
139 }
140 if let Some(remote) = only_remote {
141 let prefix = format!("refs/remotes/{remote}/");
142 if !full.starts_with(&prefix) {
143 continue;
144 }
145 }
146 }
147 result.push(RecentRef {
148 full: full.to_owned(),
149 oid: oid.to_owned(),
150 kind,
151 committer_unix,
152 });
153 }
154 Ok(result)
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct WorktreeEntry {
161 pub dir: std::path::PathBuf,
163 pub head: Option<String>,
165 pub prunable: bool,
171}
172
173pub fn worktrees(cwd: &Path) -> Vec<WorktreeEntry> {
178 let Ok(out) = Command::new("git")
179 .arg("-C")
180 .arg(cwd)
181 .args(["worktree", "list", "--porcelain", "-z"])
182 .output()
183 else {
184 return Vec::new();
185 };
186 if !out.status.success() {
187 return Vec::new();
188 }
189 parse_worktree_list(&out.stdout)
190}
191
192fn parse_worktree_list(bytes: &[u8]) -> Vec<WorktreeEntry> {
193 let mut entries = Vec::new();
194 let mut current: Option<WorktreeEntry> = None;
195 for record in bytes.split(|&b| b == 0) {
200 let Ok(line) = std::str::from_utf8(record) else {
201 continue;
202 };
203 if line.is_empty() {
204 if let Some(entry) = current.take() {
205 entries.push(entry);
206 }
207 continue;
208 }
209 if let Some(rest) = line.strip_prefix("worktree ") {
210 if let Some(entry) = current.take() {
212 entries.push(entry);
213 }
214 current = Some(WorktreeEntry {
215 dir: std::path::PathBuf::from(rest),
216 head: None,
217 prunable: false,
218 });
219 } else if let Some(rest) = line.strip_prefix("HEAD ")
220 && let Some(c) = current.as_mut()
221 {
222 c.head = Some(rest.to_owned());
223 } else if line.starts_with("prunable")
224 && let Some(c) = current.as_mut()
225 {
226 c.prunable = true;
227 } else if line == "bare" {
228 current = None;
231 }
232 }
233 if let Some(entry) = current.take() {
234 entries.push(entry);
235 }
236 entries
237}
238
239fn classify_ref(full: &str) -> RefKind {
240 if full.starts_with("refs/heads/") {
241 RefKind::LocalBranch
242 } else if full.starts_with("refs/remotes/") {
243 RefKind::RemoteBranch
244 } else if full.starts_with("refs/tags/") {
245 RefKind::Tag
246 } else {
247 RefKind::Other
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use crate::tests::commit_helper;
255
256 #[test]
257 fn refspec_falls_back_to_current_branch() {
258 let tmp = commit_helper::init_repo();
259 commit_helper::commit_file(&tmp, "a.txt", b"hi");
260 assert_eq!(
262 current_refspec(tmp.path()).as_deref(),
263 Some("refs/heads/main"),
264 );
265 }
266
267 #[test]
268 fn refspec_prefers_tracked_upstream() {
269 let tmp = commit_helper::init_repo();
270 commit_helper::commit_file(&tmp, "a.txt", b"hi");
271 std::process::Command::new("git")
272 .arg("-C")
273 .arg(tmp.path())
274 .args(["config", "branch.main.merge", "refs/heads/tracked"])
275 .status()
276 .unwrap();
277 assert_eq!(
278 current_refspec(tmp.path()).as_deref(),
279 Some("refs/heads/tracked"),
280 );
281 }
282
283 #[test]
284 fn recent_branches_returns_main_for_fresh_repo() {
285 let tmp = commit_helper::init_repo();
286 commit_helper::commit_file(&tmp, "a.txt", b"hi");
287 let refs = recent_branches(tmp.path(), UNIX_EPOCH, true, None).unwrap();
288 assert_eq!(refs.len(), 1);
289 assert_eq!(refs[0].full, "refs/heads/main");
290 assert_eq!(refs[0].kind, RefKind::LocalBranch);
291 }
292
293 #[test]
294 fn recent_branches_drops_remotes_when_excluded() {
295 let tmp = commit_helper::init_repo();
296 commit_helper::commit_file(&tmp, "a.txt", b"hi");
297 let head = commit_helper::head_oid(&tmp);
299 Command::new("git")
300 .arg("-C")
301 .arg(tmp.path())
302 .args(["update-ref", "refs/remotes/origin/main", &head])
303 .status()
304 .unwrap();
305 let with_remotes = recent_branches(tmp.path(), UNIX_EPOCH, true, None).unwrap();
306 let without = recent_branches(tmp.path(), UNIX_EPOCH, false, None).unwrap();
307 assert!(
308 with_remotes
309 .iter()
310 .any(|r| r.full == "refs/remotes/origin/main")
311 );
312 assert!(!without.iter().any(|r| r.full == "refs/remotes/origin/main"));
313 assert!(without.iter().any(|r| r.full == "refs/heads/main"));
315 }
316
317 #[test]
318 fn recent_branches_only_remote_filter() {
319 let tmp = commit_helper::init_repo();
320 commit_helper::commit_file(&tmp, "a.txt", b"hi");
321 let head = commit_helper::head_oid(&tmp);
322 for r in ["refs/remotes/origin/main", "refs/remotes/upstream/main"] {
323 Command::new("git")
324 .arg("-C")
325 .arg(tmp.path())
326 .args(["update-ref", r, &head])
327 .status()
328 .unwrap();
329 }
330 let only_origin = recent_branches(tmp.path(), UNIX_EPOCH, true, Some("origin")).unwrap();
331 assert!(
332 only_origin
333 .iter()
334 .any(|r| r.full == "refs/remotes/origin/main")
335 );
336 assert!(
337 !only_origin
338 .iter()
339 .any(|r| r.full == "refs/remotes/upstream/main")
340 );
341 }
342
343 #[test]
344 fn recent_branches_skips_old_refs() {
345 let tmp = commit_helper::init_repo();
346 commit_helper::commit_file(&tmp, "a.txt", b"hi");
347 let future = SystemTime::now() + std::time::Duration::from_secs(86400);
349 let refs = recent_branches(tmp.path(), future, true, None).unwrap();
350 assert!(refs.is_empty());
351 }
352
353 #[test]
354 fn refspec_none_on_detached_head() {
355 let tmp = commit_helper::init_repo();
356 commit_helper::commit_file(&tmp, "a.txt", b"hi");
357 let head = commit_helper::head_oid(&tmp);
358 std::process::Command::new("git")
359 .arg("-C")
360 .arg(tmp.path())
361 .args(["checkout", "--quiet", &head])
362 .status()
363 .unwrap();
364 assert_eq!(current_refspec(tmp.path()), None);
365 }
366}