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