Skip to main content

git_spawn/
info.rs

1//! Repository overview in a single call.
2//!
3//! [`RepoInfo`] folds together the bits most automation reaches for first —
4//! current branch, upstream tracking, default branch, dirty state, ahead/behind
5//! counts — and is produced by [`Repository::info`].
6//!
7//! Behind a single call we run `git status --porcelain=v2 --branch` (which
8//! emits a stable header with branch / upstream / ab counts plus per-file
9//! entries) and `git symbolic-ref refs/remotes/origin/HEAD` for the default
10//! branch. The default-branch lookup fails silently when there is no `origin`
11//! remote yet — the field stays `None` rather than surfacing an error.
12//!
13//! # Example
14//!
15//! ```no_run
16//! # async fn ex() -> git_spawn::Result<()> {
17//! use git_spawn::Repository;
18//!
19//! let repo = Repository::open("/path/to/repo")?;
20//! let info = repo.info().await?;
21//!
22//! if info.dirty {
23//!     eprintln!("uncommitted changes on {}", info.branch.as_deref().unwrap_or("(detached)"));
24//! }
25//! if info.behind > 0 {
26//!     eprintln!("{} commits behind {}", info.behind, info.upstream.as_deref().unwrap_or("upstream"));
27//! }
28//! # Ok(())
29//! # }
30//! ```
31
32use crate::command::GitCommand;
33use crate::command::status::StatusFormat;
34use crate::command::symbolic_ref::SymbolicRefCommand;
35use crate::error::Result;
36use crate::repo::Repository;
37
38/// Snapshot of a repository's state.
39///
40/// Fields are populated independently — a missing upstream leaves `upstream`,
41/// `ahead`, and `behind` at their defaults (`None`, `0`, `0`); a missing remote
42/// leaves `default_branch` at `None`.
43#[derive(Debug, Clone, Default, PartialEq, Eq)]
44#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
45pub struct RepoInfo {
46    /// Current branch, or `None` when `HEAD` is detached.
47    pub branch: Option<String>,
48    /// Configured upstream in `remote/branch` form (e.g. `"origin/main"`).
49    pub upstream: Option<String>,
50    /// Default branch as advertised by `refs/remotes/origin/HEAD`
51    /// (short form, e.g. `"main"`). `None` when no `origin` remote is
52    /// configured or the symbolic ref is missing.
53    pub default_branch: Option<String>,
54    /// `true` when the working tree or index has any pending changes
55    /// (modified, staged, untracked, etc.).
56    pub dirty: bool,
57    /// Commits the current branch is ahead of its upstream.
58    pub ahead: u32,
59    /// Commits the current branch is behind its upstream.
60    pub behind: u32,
61}
62
63impl Repository {
64    /// Collect a [`RepoInfo`] snapshot in a single call.
65    ///
66    /// Runs `git status --porcelain=v2 --branch` plus one `symbolic-ref` lookup
67    /// for the default branch. See the [module docs](self) for details and
68    /// caveats.
69    pub async fn info(&self) -> Result<RepoInfo> {
70        let status_out = self
71            .status()
72            .format(StatusFormat::PorcelainV2)
73            .branch()
74            .execute()
75            .await?;
76
77        let mut info = parse_porcelain_v2(&status_out.stdout);
78
79        let mut sym = SymbolicRefCommand::read("refs/remotes/origin/HEAD").short();
80        sym.current_dir(self.path());
81        if let Ok(target) = sym.execute().await {
82            let short = target
83                .strip_prefix("origin/")
84                .map_or_else(|| target.clone(), str::to_string);
85            if !short.is_empty() {
86                info.default_branch = Some(short);
87            }
88        }
89
90        Ok(info)
91    }
92}
93
94fn parse_porcelain_v2(stdout: &str) -> RepoInfo {
95    let mut info = RepoInfo::default();
96    for line in stdout.lines() {
97        if let Some(rest) = line.strip_prefix("# branch.head ") {
98            if rest != "(detached)" {
99                info.branch = Some(rest.to_string());
100            }
101        } else if let Some(rest) = line.strip_prefix("# branch.upstream ") {
102            info.upstream = Some(rest.to_string());
103        } else if let Some(rest) = line.strip_prefix("# branch.ab ") {
104            let mut parts = rest.split_whitespace();
105            if let Some(a) = parts.next() {
106                info.ahead = a.trim_start_matches('+').parse().unwrap_or(0);
107            }
108            if let Some(b) = parts.next() {
109                info.behind = b.trim_start_matches('-').parse().unwrap_or(0);
110            }
111        } else if !line.is_empty() && !line.starts_with('#') {
112            info.dirty = true;
113        }
114    }
115    info
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn parses_clean_repo_with_upstream() {
124        let input = "\
125# branch.oid abc123
126# branch.head main
127# branch.upstream origin/main
128# branch.ab +0 -0
129";
130        let info = parse_porcelain_v2(input);
131        assert_eq!(info.branch.as_deref(), Some("main"));
132        assert_eq!(info.upstream.as_deref(), Some("origin/main"));
133        assert_eq!(info.ahead, 0);
134        assert_eq!(info.behind, 0);
135        assert!(!info.dirty);
136    }
137
138    #[test]
139    fn parses_dirty_with_ahead_behind() {
140        let input = "\
141# branch.oid abc123
142# branch.head feature
143# branch.upstream origin/feature
144# branch.ab +3 -1
1451 .M N... 100644 100644 100644 aaa bbb hello.txt
146? new.txt
147";
148        let info = parse_porcelain_v2(input);
149        assert_eq!(info.branch.as_deref(), Some("feature"));
150        assert_eq!(info.ahead, 3);
151        assert_eq!(info.behind, 1);
152        assert!(info.dirty);
153    }
154
155    #[test]
156    fn parses_detached_head() {
157        let input = "\
158# branch.oid abc123
159# branch.head (detached)
160";
161        let info = parse_porcelain_v2(input);
162        assert!(info.branch.is_none());
163        assert!(info.upstream.is_none());
164        assert!(!info.dirty);
165    }
166
167    #[test]
168    fn parses_no_upstream() {
169        let input = "\
170# branch.oid abc123
171# branch.head main
172";
173        let info = parse_porcelain_v2(input);
174        assert_eq!(info.branch.as_deref(), Some("main"));
175        assert!(info.upstream.is_none());
176        assert_eq!(info.ahead, 0);
177        assert_eq!(info.behind, 0);
178    }
179}