git_status_vars/
lib.rs

1//! This is primarily a command line utility. The documentation for the command
2//! line interface is in [README.md][].
3//!
4//! The primary entrance to this code is [`summarize_repository()`]. It opens a
5//! [`Repository`], then calls [`summarize_opened_repository()`] on it.
6//!
7//! Currently the minimum supported Rust version (MSRV) is **1.74.1**.
8//!
9//! # Versioning
10//!
11//! This follows semantic versioning for the command line utility, not the crate
12//! API. Breaking changes to the API are not guaranteed to involve a major
13//! version change, since I don’t anticipate this being used as a crate by
14//! anyone else.
15//!
16//! [README.md]: https://github.com/danielparks/git-status-vars/blob/main/README.md
17
18// Most lint configuration is in lints.toml, but that isn’t supported by
19// cargo-geiger, and it only supports deny, not forbid.
20#![forbid(unsafe_code)]
21
22use git2::Branch;
23use git2::ReferenceType;
24use git2::Repository;
25use git2::{ErrorClass, ErrorCode};
26use git2::{Status, StatusOptions, StatusShow};
27use std::fmt;
28use std::io;
29use std::path::Path;
30
31/// Manage outputting shell variables.
32mod shell_writer;
33pub use shell_writer::*;
34
35/// A reference in a git repository.
36#[derive(Debug, Default)]
37pub struct Reference {
38    /// The name of the reference, e.g. `"refs/heads/my_branch"`.
39    pub name: String,
40
41    /// The kind of reference, e.g. `"symbolic"` or `"direct"`.
42    pub kind: String,
43
44    /// An error encountered when trying to resolve the reference, or `""`.
45    pub error: String,
46}
47
48impl Reference {
49    /// Create a new reference without an error.
50    #[must_use]
51    pub fn new<N: fmt::Display, K: fmt::Display>(name: N, kind: K) -> Self {
52        Self {
53            name: name.to_string(),
54            kind: kind.to_string(),
55            error: "".to_owned(),
56        }
57    }
58
59    /// Create a new reference with an error.
60    #[must_use]
61    pub fn new_with_error<N, K, E>(name: N, kind: K, error: E) -> Self
62    where
63        N: fmt::Display,
64        K: fmt::Display,
65        E: fmt::Debug,
66    {
67        Self {
68            name: name.to_string(),
69            kind: kind.to_string(),
70            error: format!("{error:?}"),
71        }
72    }
73
74    /// Create a new symbolic reference.
75    #[must_use]
76    pub fn symbolic<N: fmt::Display>(name: N) -> Self {
77        Self::new(name, "symbolic")
78    }
79
80    /// Create a new direct reference.
81    #[must_use]
82    pub fn direct<N: fmt::Display>(name: N) -> Self {
83        Self::new(name, "direct")
84    }
85
86    /// Get the short name of a reference if it’s a tag or branch. Otherwise,
87    /// get the full name.
88    #[must_use]
89    pub fn short(&self) -> &str {
90        self.name
91            .strip_prefix("refs/heads/")
92            .or_else(|| self.name.strip_prefix("refs/tags/"))
93            .unwrap_or(&self.name)
94    }
95}
96
97impl ShellVars for Reference {
98    // Output the reference information with a prefix (e.g. "ref_").
99    fn write_to_shell<W: io::Write>(&self, out: &ShellWriter<W>) {
100        out.write_var("name", &self.name);
101        out.write_var("short", self.short());
102        out.write_var("kind", &self.kind);
103        out.write_var("error", &self.error);
104    }
105}
106
107/// The trail of a `HEAD` reference.
108#[derive(Debug, Default)]
109pub struct Head {
110    /// The trail of references leading to the actual underlying commit.
111    pub trail: Vec<Reference>,
112
113    /// The hash of the commit.
114    pub hash: String,
115
116    /// How many commits are we ahead of upstream?
117    ///
118    /// `None` means that there is no upstream, or there is no equivalent branch
119    /// in upstream.
120    pub ahead_of_upstream: Option<usize>,
121
122    /// How many commits are we behind upstream?
123    ///
124    /// `None` means that there is no upstream, or there is no equivalent branch
125    /// in upstream.
126    pub behind_upstream: Option<usize>,
127
128    /// An error encountered trying to calculate differences with upstream.
129    pub upstream_error: String,
130}
131
132impl ShellVars for Head {
133    fn write_to_shell<W: io::Write>(&self, out: &ShellWriter<W>) {
134        let trail = self.trail.get(1..).unwrap_or(&[]);
135        out.write_var("ref_length", trail.len());
136        for (i, reference) in trail.iter().enumerate() {
137            // self.trail is actually 1 longer, so i + 1 always fits.
138            #[allow(clippy::arithmetic_side_effects)]
139            out.group_n("ref", i + 1).write_vars(reference);
140        }
141        out.write_var("hash", &self.hash);
142        out.write_var("ahead", display_option(self.ahead_of_upstream));
143        out.write_var("behind", display_option(self.behind_upstream));
144        out.write_var("upstream_error", &self.upstream_error);
145    }
146}
147
148/// Summarize information about a repository.
149///
150/// This takes the `Result` from one of the `Repository::open()` functions.
151///
152/// # Example
153///
154/// ```no_run
155/// use git_status_vars::{summarize_repository, ShellWriter};
156/// use git2::Repository;
157///
158/// summarize_repository(&ShellWriter::default(), Repository::open_from_env());
159/// ```
160///
161/// # Panics
162///
163/// This may panic if it can’t resolve a symbolic reference to a symbolic
164/// target.
165pub fn summarize_repository<W: std::io::Write>(
166    out: &ShellWriter<W>,
167    opened: Result<Repository, git2::Error>,
168) {
169    let result = match opened {
170        Ok(mut repository) => summarize_opened_repository(out, &mut repository),
171        Err(error)
172            if error.code() == ErrorCode::NotFound
173                && error.class() == ErrorClass::Repository =>
174        {
175            out.write_var("repo_state", "NotFound");
176            Ok(())
177        }
178        Err(error) => Err(error),
179    };
180
181    if let Err(error) = result {
182        out.write_var("repo_state", "Error");
183        out.write_var_debug("repo_error", error);
184    }
185}
186
187/// Summarize information about a successfully opened repository.
188///
189/// # Example
190///
191/// ```no_run
192/// use git_status_vars::{summarize_opened_repository, ShellWriter};
193/// use git2::Repository;
194///
195/// summarize_opened_repository(
196///     &ShellWriter::default(),
197///     &mut Repository::open_from_env().unwrap(),
198/// ).unwrap();
199/// ```
200///
201/// # Errors
202///
203/// This will return a [`git2::Error`] if there were problems getting repository
204/// information. This is careful to load all repository information (and thus
205/// encountering any errors) before generating any output.
206///
207/// # Panics
208///
209/// This may panic if it can’t resolve a symbolic reference to a symbolic
210/// target.
211pub fn summarize_opened_repository<W: std::io::Write>(
212    out: &ShellWriter<W>,
213    repository: &mut Repository,
214) -> Result<(), git2::Error> {
215    let state = repository.state();
216    let workdir = display_option(repository.workdir().map(Path::display));
217    let empty = repository.is_empty()?;
218    let bare = repository.is_bare();
219    let head = &head_info(repository);
220    let changes = &count_changes(repository)?;
221    let stash = count_stash(repository)?;
222
223    out.write_var_debug("repo_state", state);
224    out.write_var("repo_workdir", workdir);
225    out.write_var("repo_empty", empty);
226    out.write_var("repo_bare", bare);
227    out.group("head").write_vars(head);
228    out.write_vars(changes);
229    out.write_var("stash_count", stash);
230
231    Ok(())
232}
233
234/// Trace the `HEAD` reference for a repository.
235///
236/// # Panics
237///
238/// This may panic if it can’t resolve a symbolic reference to a symbolic
239/// target.
240#[allow(clippy::similar_names)]
241#[must_use]
242pub fn head_info(repository: &Repository) -> Head {
243    let mut current = "HEAD".to_owned();
244    let mut head = Head::default();
245    loop {
246        match repository.find_reference(&current) {
247            Ok(reference) => match reference.kind() {
248                Some(ReferenceType::Direct) => {
249                    head.trail.push(Reference::direct(display_option(
250                        reference.name(),
251                    )));
252                    head.hash = display_option(reference.target());
253                    break;
254                }
255                Some(ReferenceType::Symbolic) => {
256                    head.trail.push(Reference::symbolic(display_option(
257                        reference.name(),
258                    )));
259                    let target = reference
260                        .symbolic_target()
261                        .expect("Symbolic ref should have symbolic target");
262                    target.clone_into(&mut current);
263                }
264                None => {
265                    head.trail.push(Reference::new(
266                        display_option(reference.name()),
267                        "unknown",
268                    ));
269                    break;
270                }
271            },
272            Err(error) => {
273                head.trail
274                    .push(Reference::new_with_error(current, "", error));
275                break;
276            }
277        };
278    }
279
280    match get_upstream_difference(repository) {
281        Ok(Some((ahead, behind))) => {
282            head.ahead_of_upstream = Some(ahead);
283            head.behind_upstream = Some(behind);
284        }
285        Ok(None) => {}
286        Err(error) => {
287            head.upstream_error = format!("{error:?}");
288        }
289    }
290
291    head
292}
293
294/// Get the (ahead, behind) count of HEAD versus its upstream branch.
295///
296/// # Errors
297///
298/// This will return [`git2::Error`] if there were problems resolving the
299/// the repository head, or if there was an error finding the upstream branch
300/// (but it will return `Ok(None)` if there simply is no upstream or upstream
301/// branch).
302pub fn get_upstream_difference(
303    repository: &Repository,
304) -> Result<Option<(usize, usize)>, git2::Error> {
305    let local_ref = repository.head()?.resolve()?;
306    if let Some(local_oid) = local_ref.target() {
307        Branch::wrap(local_ref)
308            .upstream()?
309            .get()
310            .target()
311            .map(|upstream_oid| {
312                repository.graph_ahead_behind(local_oid, upstream_oid)
313            })
314            .transpose()
315    } else {
316        Ok(None)
317    }
318}
319
320/// Format `Option<impl fmt::Display>` for display. `None` becomes `""`.
321fn display_option<V: fmt::Display>(s: Option<V>) -> String {
322    s.map(|s| s.to_string()).unwrap_or_else(|| "".to_owned())
323}
324
325/// Track changes in the working tree and index (staged area).
326#[derive(Debug, Default)]
327pub struct ChangeCounters {
328    /// The number of untracked files (not in the index).
329    pub untracked: usize,
330
331    /// The number of files that have been modified, but haven’t been staged.
332    pub unstaged: usize,
333
334    /// The number of files that have been staged.
335    pub staged: usize,
336
337    /// The number of files with conflicts.
338    pub conflicted: usize,
339}
340
341impl From<[usize; 4]> for ChangeCounters {
342    fn from(array: [usize; 4]) -> Self {
343        Self {
344            untracked: array[0],
345            unstaged: array[1],
346            staged: array[2],
347            conflicted: array[3],
348        }
349    }
350}
351
352impl ShellVars for ChangeCounters {
353    // Output the tree change information with a prefix (e.g. "tree_").
354    fn write_to_shell<W: io::Write>(&self, out: &ShellWriter<W>) {
355        out.write_var("untracked_count", self.untracked);
356        out.write_var("unstaged_count", self.unstaged);
357        out.write_var("staged_count", self.staged);
358        out.write_var("conflicted_count", self.conflicted);
359    }
360}
361
362/// Count changes in the working tree and index (staged area) of a repository.
363///
364/// # Errors
365///
366/// This will return [`git2::Error`] if there was an error getting status
367/// information from the repository.
368pub fn count_changes(
369    repository: &Repository,
370) -> Result<ChangeCounters, git2::Error> {
371    if repository.is_bare() {
372        // Can't run status on bare repo.
373        return Ok(ChangeCounters::default());
374    }
375
376    let mut options = StatusOptions::new();
377    // exclude_submodules optional?
378    options
379        .show(StatusShow::IndexAndWorkdir)
380        .include_untracked(true)
381        .exclude_submodules(true);
382    let statuses = repository.statuses(Some(&mut options))?;
383
384    let mut counters: [usize; 4] = [0; 4];
385    let buckets = [
386        // Untracked
387        Status::WT_NEW,
388        // Working tree changed
389        Status::WT_MODIFIED
390            | Status::WT_DELETED
391            | Status::WT_TYPECHANGE
392            | Status::WT_RENAMED,
393        // Staged
394        Status::INDEX_NEW
395            | Status::INDEX_MODIFIED
396            | Status::INDEX_DELETED
397            | Status::INDEX_RENAMED
398            | Status::INDEX_TYPECHANGE,
399        // Conflicted
400        Status::CONFLICTED,
401    ];
402
403    for status in statuses.iter() {
404        for (i, bits) in buckets.iter().enumerate() {
405            if status.status().intersects(*bits) {
406                counters[i] = counters[i].saturating_add(1);
407            }
408        }
409    }
410
411    Ok(ChangeCounters::from(counters))
412}
413
414/// Count entries in stash.
415///
416/// Not sure why `repository` needs to be `mut` for this.
417///
418/// If there were somehow more than `usize::MAX` entries in stash, this would
419/// return `usize::MAX`.
420///
421/// # Errors
422///
423/// This will return [`git2::Error`] if there was an error getting status
424/// information from the repository.
425pub fn count_stash(repository: &mut Repository) -> Result<usize, git2::Error> {
426    let mut count: usize = 0;
427    repository.stash_foreach(|_, _, _| {
428        count = count.saturating_add(1);
429        true
430    })?;
431
432    Ok(count)
433}