Skip to main content

lattice_vcs/
reference.rs

1use crate::{Repository, Result, VcsError};
2
3/// Reference operations — resolve a named ref to an object id.
4pub struct Reference;
5
6impl Reference {
7    /// Resolve a reference name (e.g., `"HEAD"`, `"refs/heads/main"`,
8    /// `"main"`) to its target object id.
9    ///
10    /// Uses `git rev-parse --verify <name>`.
11    /// Short names like `"main"` are resolved via git's standard ref
12    /// resolution rules.
13    pub fn resolve(repo: &Repository, name: &str) -> Result<gix::ObjectId> {
14        let hex = repo
15            .run_git_str(["rev-parse", "--verify", name])
16            .map_err(|e| VcsError::ReferenceNotFound(format!("{}: {}", name, e)))?;
17        let hex = hex.trim();
18        let oid: gix::ObjectId = hex
19            .parse()
20            .map_err(|_| VcsError::ReferenceNotFound(format!("invalid oid: {}", hex)))?;
21        Ok(oid)
22    }
23
24    /// Return the symbolic target of a reference, if it is symbolic
25    /// (e.g., `"HEAD"` → `"refs/heads/main"`).
26    ///
27    /// Uses `git symbolic-ref -q <name>`.
28    pub fn symbolic_target(repo: &Repository, name: &str) -> Result<Option<String>> {
29        match repo.run_git_str(["symbolic-ref", "-q", name]) {
30            Ok(target) => Ok(Some(target.trim().to_string())),
31            Err(_) => Ok(None),
32        }
33    }
34
35    /// Every local branch, remote-tracking branch and tag, in one call.
36    ///
37    /// MG.35. [`crate::Branch::list`] answers "what branches are there";
38    /// this answers "what refs are there, and where does each point" —
39    /// the question magit's refs buffer exists for. One `for-each-ref`
40    /// rather than three walks plus an ahead/behind count per branch:
41    /// git computes `%(upstream:track)` while it is already reading the
42    /// ref, so the whole buffer costs a single invocation regardless of
43    /// how many branches the repository has.
44    pub fn list(repo: &Repository) -> Result<Vec<RefEntry>> {
45        let out = repo
46            .run_git_str([
47                "for-each-ref",
48                REF_FORMAT,
49                "refs/heads",
50                "refs/remotes",
51                "refs/tags",
52            ])
53            .map_err(|e| VcsError::ReferenceNotFound(format!("for-each-ref: {e}")))?;
54        Ok(parse_for_each_ref(&out))
55    }
56}
57
58/// What `Reference::list` asks `for-each-ref` for.
59///
60/// NUL-separated, because every one of these fields can contain a space
61/// and `%(subject)` can contain almost anything. A tab-separated format
62/// would work until someone writes a tab in a commit subject, which is
63/// the kind of failure that shows up once and is never reproduced.
64const REF_FORMAT: &str = "--format=%(refname)%00%(objectname)%00%(objectname:short)%00\
65                          %(upstream:short)%00%(upstream:track)%00\
66                          %(HEAD)%00%(contents:subject)";
67
68/// What kind of thing a ref is. The three groups magit's refs buffer
69/// shows, in the order it shows them.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
71pub enum RefKind {
72    /// `refs/heads/*`.
73    Branch,
74    /// `refs/remotes/*`.
75    Remote,
76    /// `refs/tags/*`.
77    Tag,
78}
79
80/// One ref, and where it points.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct RefEntry {
83    pub kind: RefKind,
84    /// The short name — `main`, `origin/main`, `v1.0.0`.
85    pub name: String,
86    /// Full object id. Callers that hand an id to another git command
87    /// use this one: an abbreviation is ambiguous in principle, and git
88    /// resolves the ambiguity by refusing — which would surface as a ref
89    /// that opens nothing.
90    pub id: String,
91    /// Abbreviated object id, for display.
92    pub short_id: String,
93    /// The configured upstream's short name, empty when there is none.
94    /// Only ever set for [`RefKind::Branch`].
95    pub upstream: String,
96    /// Git's own ahead/behind summary — `[ahead 2]`, `[behind 1]`,
97    /// `[ahead 3, behind 1]`, `[gone]` — with the brackets stripped.
98    /// Empty when the branch is level with its upstream or has none.
99    pub track: String,
100    /// Is this the checked-out branch?
101    pub head: bool,
102    /// The subject line of the commit (or of the tag, when annotated).
103    pub subject: String,
104}
105
106/// Parse [`REF_FORMAT`] output into entries.
107///
108/// **A malformed line is skipped, not fatal** — the same judgement
109/// [`crate::remote::parse_remote_v`] makes and for the same reason: one
110/// unparseable ref must not blank the whole buffer and hide every ref
111/// that is fine. A ref under none of the three prefixes is also skipped;
112/// `refs/stash`, `refs/notes/*` and `refs/bisect/*` are real refs that
113/// this buffer deliberately does not show.
114pub fn parse_for_each_ref(out: &str) -> Vec<RefEntry> {
115    out.lines().filter_map(parse_ref_line).collect()
116}
117
118fn parse_ref_line(line: &str) -> Option<RefEntry> {
119    let mut f = line.split('\0');
120    let refname = f.next()?;
121    let id = f.next()?.to_string();
122    let short_id = f.next()?.to_string();
123    let upstream = f.next()?.to_string();
124    let track = f.next()?.trim().to_string();
125    let head = f.next()? == "*";
126    // Subject last, and taken whole: it is free text and may contain
127    // anything except the NUL that separates it.
128    let subject = f.next().unwrap_or_default().to_string();
129
130    let (kind, name) = if let Some(n) = refname.strip_prefix("refs/heads/") {
131        (RefKind::Branch, n)
132    } else if let Some(n) = refname.strip_prefix("refs/remotes/") {
133        (RefKind::Remote, n)
134    } else if let Some(n) = refname.strip_prefix("refs/tags/") {
135        (RefKind::Tag, n)
136    } else {
137        return None;
138    };
139    if name.is_empty() {
140        return None;
141    }
142    Some(RefEntry {
143        kind,
144        name: name.to_string(),
145        id,
146        short_id,
147        upstream,
148        // Git prints the summary bracketed; the brackets are
149        // presentation, and the renderer supplies its own.
150        track: track
151            .strip_prefix('[')
152            .and_then(|t| t.strip_suffix(']'))
153            .unwrap_or(&track)
154            .to_string(),
155        head,
156        subject,
157    })
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    /// The three prefixes map to the three kinds, and the short name
165    /// drops the prefix — `origin/main` keeps its remote, `main` does
166    /// not keep `refs/heads/`.
167    #[test]
168    fn each_prefix_maps_to_its_kind_with_the_prefix_stripped() {
169        let out = "refs/heads/main\0aaaa111\0a1b2c3d\0origin/main\0\0*\0the subject\n\
170                   refs/remotes/origin/main\0aaaa111\0a1b2c3d\0\0\0\0the subject\n\
171                   refs/tags/v1.0.0\0eeee444\0e4f5g6h\0\0\0\0tagged\n";
172        let refs = parse_for_each_ref(out);
173        assert_eq!(refs.len(), 3);
174        assert_eq!(
175            (refs[0].kind, refs[0].name.as_str()),
176            (RefKind::Branch, "main")
177        );
178        assert_eq!(
179            (refs[1].kind, refs[1].name.as_str()),
180            (RefKind::Remote, "origin/main")
181        );
182        assert_eq!(
183            (refs[2].kind, refs[2].name.as_str()),
184            (RefKind::Tag, "v1.0.0")
185        );
186        assert!(refs[0].head, "the `*` marks the checked-out branch");
187        assert!(!refs[1].head);
188    }
189
190    /// The brackets git prints around the tracking summary are stripped
191    /// once, here, so no renderer has to know they were there. `[gone]`
192    /// matters as much as the counts: it is how a branch whose upstream
193    /// was deleted announces itself.
194    #[test]
195    fn the_tracking_summary_loses_its_brackets_and_keeps_its_text() {
196        let out = "refs/heads/a\0aaa\0a1\0origin/a\0[ahead 3, behind 1]\0\0s\n\
197                   refs/heads/b\0bbb\0b1\0origin/b\0[gone]\0\0s\n\
198                   refs/heads/c\0ccc\0c1\0origin/c\0\0\0s\n";
199        let refs = parse_for_each_ref(out);
200        assert_eq!(refs[0].track, "ahead 3, behind 1");
201        assert_eq!(refs[1].track, "gone");
202        assert_eq!(refs[2].track, "", "level with upstream reports nothing");
203    }
204
205    /// A subject containing the field separator's *near misses* survives
206    /// whole. This is the reason the format is NUL-separated rather than
207    /// tab- or space-separated: commit subjects are free text.
208    #[test]
209    fn a_subject_with_tabs_and_brackets_survives_whole() {
210        let out = "refs/heads/main\0aaa\0a1\0\0\0\0fix:\ttabs [and] brackets\n";
211        let refs = parse_for_each_ref(out);
212        assert_eq!(refs[0].subject, "fix:\ttabs [and] brackets");
213    }
214
215    /// Refs outside the three prefixes are dropped rather than rendered
216    /// as an unnamed row — `refs/stash` and `refs/notes/*` are real refs
217    /// this buffer deliberately does not show. Truncated lines are
218    /// dropped for the same reason `parse_remote_v` drops them: one bad
219    /// ref must not blank the list.
220    #[test]
221    fn unknown_prefixes_and_truncated_lines_are_skipped_not_fatal() {
222        let out = "refs/stash\0aaa\0a1\0\0\0\0wip\n\
223                   refs/heads/\0aaa\0a1\0\0\0\0empty name\n\
224                   truncated\n\
225                   refs/heads/good\0aaa\0a1\0\0\0\0kept\n";
226        let refs = parse_for_each_ref(out);
227        assert_eq!(refs.len(), 1, "only the good one survives: {refs:?}");
228        assert_eq!(refs[0].name, "good");
229    }
230}