Skip to main content

git_lfs_git/
refs.rs

1//! Refspec resolution for the LFS lock APIs and ref enumeration
2//! helpers used by fetch-recent and prune retention.
3
4use std::path::Path;
5use std::process::Command;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use crate::Error;
9
10/// Resolve the refspec to send with lock-API requests, or `None` if
11/// the working tree is on a detached HEAD.
12pub 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
20/// Short name of the current branch (`git symbolic-ref --short HEAD`),
21/// or `None` for detached HEAD.
22fn 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
36/// `branch.<branch>.merge` if set — the upstream branch that pushes /
37/// pulls of the current branch are routed to. When set, locks should
38/// be scoped to this ref rather than the local branch's ref.
39fn 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/// One ref returned by [`recent_branches`].
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct RecentRef {
57    /// Full ref name (`refs/heads/main`, `refs/remotes/origin/feature`,
58    /// `refs/tags/v1`, ...).
59    pub full: String,
60    /// Hex commit OID the ref points at.
61    pub oid: String,
62    pub kind: RefKind,
63    /// Committer date as Unix epoch seconds. Useful for the per-ref
64    /// `commits_days` window calculation in prune.
65    pub committer_unix: i64,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum RefKind {
70    /// Under `refs/heads/`.
71    LocalBranch,
72    /// Under `refs/remotes/`.
73    RemoteBranch,
74    /// Under `refs/tags/`.
75    Tag,
76    /// Anything else (`refs/notes/`, `refs/stash`, custom namespaces).
77    Other,
78}
79
80/// Refs whose tip commit was authored on or after `since`. Mirrors
81/// upstream's `git.RecentBranches` (`git/git.go::RecentBranches`).
82///
83/// Output is filtered:
84/// - if `include_remote_branches` is false, refs under `refs/remotes/`
85///   are dropped entirely.
86/// - if `only_remote` is `Some(name)`, remote refs not under
87///   `refs/remotes/<name>/` are dropped (local refs and tags pass
88///   through regardless).
89///
90/// `git for-each-ref` is asked to sort newest-first; the iteration
91/// stops at the first ref older than `since` so large repos don't
92/// pay for refs they'd discard anyway.
93pub 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        // Sorted newest-first → first ref older than the cutoff means
131        // every remaining one is too.
132        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/// One entry from `git worktree list --porcelain`. Includes the main
158/// working copy plus every linked worktree.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct WorktreeEntry {
161    /// Absolute path to the worktree's directory.
162    pub dir: std::path::PathBuf,
163    /// Tip commit SHA, or `None` for bare worktrees.
164    pub head: Option<String>,
165    /// `true` when git considers this entry prunable — typically the
166    /// worktree's directory has been deleted but `git worktree prune`
167    /// hasn't run. Mirrors upstream's `Prunable` field; prune retention
168    /// keeps the HEAD-state but skips the index scan for prunable
169    /// entries (the index file may still be inaccessible / removed).
170    pub prunable: bool,
171}
172
173/// Every worktree attached to this repo (`git worktree list
174/// --porcelain -z`). Includes the main working copy. Returns an empty
175/// vec when `git worktree` exits non-zero (older git versions, bare
176/// repos with no linked worktrees, etc.).
177pub 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    // Records are NUL-separated lines; an empty record terminates an
196    // entry. With `-z` git emits `\0` between every field, including
197    // an extra `\0` between entries — easiest to split + match by
198    // line-prefix and treat empty splits as separators.
199    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            // Starting a new entry; flush whatever was being built.
211            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            // Bare worktree — ignore the entire entry (no index, no
229            // HEAD content to retain).
230            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        // init_repo uses --initial-branch=main.
261        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        // Synthesize a remote-tracking ref by pointing it at HEAD.
298        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        // Local branch always survives.
314        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        // Cutoff strictly in the future → no refs qualify.
348        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}