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}