Skip to main content

git_lfs_git/
refs.rs

1//! Refspec resolution for the LFS lock APIs.
2//!
3//! LFS lock create/delete/list endpoints all carry a `ref.name` field
4//! that the server can use to enforce per-branch lock scoping. Upstream
5//! resolves this once per command and ships it on every request: the
6//! tracked upstream branch (from `branch.<current>.merge`) takes
7//! precedence, falling back to the current branch's full ref. A
8//! detached HEAD has no resolvable refspec, in which case the field
9//! is omitted from the request.
10
11use std::path::Path;
12use std::process::Command;
13
14/// Resolve the refspec to send with lock-API requests, or `None` if
15/// the working tree is on a detached HEAD.
16pub fn current_refspec(cwd: &Path) -> Option<String> {
17    let branch = current_branch(cwd)?;
18    if let Some(tracked) = tracked_upstream(cwd, &branch) {
19        return Some(tracked);
20    }
21    Some(format!("refs/heads/{branch}"))
22}
23
24/// Short name of the current branch (`git symbolic-ref --short HEAD`),
25/// or `None` for detached HEAD.
26fn current_branch(cwd: &Path) -> Option<String> {
27    let out = Command::new("git")
28        .arg("-C")
29        .arg(cwd)
30        .args(["symbolic-ref", "--short", "HEAD"])
31        .output()
32        .ok()?;
33    if !out.status.success() {
34        return None;
35    }
36    let s = String::from_utf8_lossy(&out.stdout).trim().to_owned();
37    if s.is_empty() { None } else { Some(s) }
38}
39
40/// `branch.<branch>.merge` if set — the upstream branch that pushes /
41/// pulls of the current branch are routed to. When set, locks should
42/// be scoped to this ref rather than the local branch's ref.
43fn tracked_upstream(cwd: &Path, branch: &str) -> Option<String> {
44    let key = format!("branch.{branch}.merge");
45    let out = Command::new("git")
46        .arg("-C")
47        .arg(cwd)
48        .args(["config", "--get", &key])
49        .output()
50        .ok()?;
51    if !out.status.success() {
52        return None;
53    }
54    let s = String::from_utf8_lossy(&out.stdout).trim().to_owned();
55    if s.is_empty() { None } else { Some(s) }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use crate::tests::commit_helper;
62
63    #[test]
64    fn refspec_falls_back_to_current_branch() {
65        let tmp = commit_helper::init_repo();
66        commit_helper::commit_file(&tmp, "a.txt", b"hi");
67        // init_repo uses --initial-branch=main.
68        assert_eq!(
69            current_refspec(tmp.path()).as_deref(),
70            Some("refs/heads/main"),
71        );
72    }
73
74    #[test]
75    fn refspec_prefers_tracked_upstream() {
76        let tmp = commit_helper::init_repo();
77        commit_helper::commit_file(&tmp, "a.txt", b"hi");
78        std::process::Command::new("git")
79            .arg("-C")
80            .arg(tmp.path())
81            .args(["config", "branch.main.merge", "refs/heads/tracked"])
82            .status()
83            .unwrap();
84        assert_eq!(
85            current_refspec(tmp.path()).as_deref(),
86            Some("refs/heads/tracked"),
87        );
88    }
89
90    #[test]
91    fn refspec_none_on_detached_head() {
92        let tmp = commit_helper::init_repo();
93        commit_helper::commit_file(&tmp, "a.txt", b"hi");
94        let head = commit_helper::head_oid(&tmp);
95        std::process::Command::new("git")
96            .arg("-C")
97            .arg(tmp.path())
98            .args(["checkout", "--quiet", &head])
99            .status()
100            .unwrap();
101        assert_eq!(current_refspec(tmp.path()), None);
102    }
103}