git_prole/git/
status.rs

1use std::fmt::Debug;
2use std::fmt::Display;
3use std::iter;
4use std::ops::Deref;
5use std::str::FromStr;
6
7use camino::Utf8PathBuf;
8use command_error::CommandExt;
9use command_error::OutputContext;
10use miette::miette;
11use tracing::instrument;
12use utf8_command::Utf8Output;
13use winnow::combinator::eof;
14use winnow::combinator::opt;
15use winnow::combinator::repeat_till;
16use winnow::token::one_of;
17use winnow::PResult;
18use winnow::Parser;
19
20use crate::parse::till_null;
21
22use super::GitLike;
23
24/// Git methods for dealing with statuses and the working tree.
25#[repr(transparent)]
26pub struct GitStatus<'a, G>(&'a G);
27
28impl<G> Debug for GitStatus<'_, G>
29where
30    G: GitLike,
31{
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        f.debug_tuple("GitStatus")
34            .field(&self.0.get_current_dir().as_ref())
35            .finish()
36    }
37}
38
39impl<'a, G> GitStatus<'a, G>
40where
41    G: GitLike,
42{
43    pub fn new(git: &'a G) -> Self {
44        Self(git)
45    }
46
47    #[instrument(level = "trace")]
48    pub fn get(&self) -> miette::Result<Status> {
49        Ok(self
50            .0
51            .command()
52            .args(["status", "--porcelain=v1", "--ignored=traditional", "-z"])
53            .output_checked_as(|context: OutputContext<Utf8Output>| {
54                if context.status().success() {
55                    Status::from_str(&context.output().stdout).map_err(|err| context.error_msg(err))
56                } else {
57                    Err(context.error())
58                }
59            })?)
60    }
61}
62
63/// The status code of a particular file. Each [`StatusEntry`] has two of these.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum StatusCode {
66    /// ` `
67    Unmodified,
68    /// `M`
69    Modified,
70    /// `T`
71    TypeChanged,
72    /// `A`
73    Added,
74    /// `D`
75    Deleted,
76    /// `R`
77    Renamed,
78    /// `C`
79    Copied,
80    /// `U`
81    Unmerged,
82    /// `?`
83    Untracked,
84    /// `!`
85    Ignored,
86}
87
88impl StatusCode {
89    pub fn parser(input: &mut &str) -> PResult<Self> {
90        let code = one_of([' ', 'M', 'T', 'A', 'D', 'R', 'C', 'U', '?', '!']).parse_next(input)?;
91        Ok(match code {
92            ' ' => Self::Unmodified,
93            'M' => Self::Modified,
94            'T' => Self::TypeChanged,
95            'A' => Self::Added,
96            'D' => Self::Deleted,
97            'R' => Self::Renamed,
98            'C' => Self::Copied,
99            'U' => Self::Unmerged,
100            '?' => Self::Untracked,
101            '!' => Self::Ignored,
102            _ => {
103                unreachable!()
104            }
105        })
106    }
107}
108
109impl Display for StatusCode {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        write!(
112            f,
113            "{}",
114            match self {
115                Self::Unmodified => ' ',
116                Self::Modified => 'M',
117                Self::TypeChanged => 'T',
118                Self::Added => 'A',
119                Self::Deleted => 'D',
120                Self::Renamed => 'R',
121                Self::Copied => 'C',
122                Self::Unmerged => 'U',
123                Self::Untracked => '?',
124                Self::Ignored => '!',
125            }
126        )
127    }
128}
129
130/// The status of a particular file.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct StatusEntry {
133    /// The status of the file in the index.
134    ///
135    /// If no merge is occurring, or a merge was successful, this indicates the status of the
136    /// index.
137    ///
138    /// If a merge conflict has occured and is not resolved, this is the left head of th
139    /// merge.
140    pub left: StatusCode,
141    /// The status of the file in the working tree.
142    ///
143    /// If no merge is occurring, or a merge was successful, this indicates the status of the
144    /// working tree.
145    ///
146    /// If a merge conflict has occured and is not resolved, this is the right head of th
147    /// merge.
148    pub right: StatusCode,
149    /// The path for this status entry.
150    pub path: Utf8PathBuf,
151    /// The path this status entry was renamed from, if any.
152    pub renamed_from: Option<Utf8PathBuf>,
153}
154
155impl StatusEntry {
156    pub fn codes(&self) -> impl Iterator<Item = StatusCode> {
157        iter::once(self.left).chain(iter::once(self.right))
158    }
159
160    pub fn is_renamed(&self) -> bool {
161        self.codes().any(|code| matches!(code, StatusCode::Renamed))
162    }
163
164    /// True if the file is not ignored, untracked, or unmodified.
165    pub fn is_modified(&self) -> bool {
166        self.codes().any(|code| {
167            !matches!(
168                code,
169                StatusCode::Ignored | StatusCode::Untracked | StatusCode::Unmodified
170            )
171        })
172    }
173
174    pub fn is_ignored(&self) -> bool {
175        self.codes().any(|code| matches!(code, StatusCode::Ignored))
176    }
177
178    pub fn parser(input: &mut &str) -> PResult<Self> {
179        let left = StatusCode::parser.parse_next(input)?;
180        let right = StatusCode::parser.parse_next(input)?;
181        let _ = ' '.parse_next(input)?;
182        let path = till_null.parse_next(input)?;
183
184        let mut entry = Self {
185            left,
186            right,
187            path: Utf8PathBuf::from(path),
188            renamed_from: None,
189        };
190
191        if entry.is_renamed() {
192            let renamed_from = till_null.parse_next(input)?;
193            entry.renamed_from = Some(Utf8PathBuf::from(renamed_from));
194        }
195
196        Ok(entry)
197    }
198}
199
200impl FromStr for StatusEntry {
201    type Err = miette::Report;
202
203    fn from_str(input: &str) -> Result<Self, Self::Err> {
204        Self::parser.parse(input).map_err(|err| miette!("{err}"))
205    }
206}
207
208impl Display for StatusEntry {
209    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        write!(f, "{}{} ", self.left, self.right)?;
211        if let Some(renamed_from) = &self.renamed_from {
212            write!(f, "{renamed_from} -> ")?;
213        }
214        write!(f, "{}", self.path)
215    }
216}
217
218/// A `git status` listing.
219///
220/// ```plain
221///  M Cargo.lock
222///  M Cargo.toml
223///  M src/app.rs
224///  M src/cli.rs
225///  D src/commit_hash.rs
226///  D src/git.rs
227///  M src/main.rs
228///  D src/ref_name.rs
229///  D src/worktree.rs
230/// ?? src/config.rs
231/// ?? src/git/
232/// ?? src/utf8tempdir.rs
233/// !! target/
234/// ```
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct Status {
237    pub entries: Vec<StatusEntry>,
238}
239
240impl Status {
241    #[instrument(level = "trace")]
242    pub fn is_clean(&self) -> bool {
243        self.entries.iter().all(|entry| !entry.is_modified())
244    }
245
246    pub fn parser(input: &mut &str) -> PResult<Self> {
247        if opt(eof).parse_next(input)?.is_some() {
248            return Ok(Self {
249                entries: Vec::new(),
250            });
251        }
252
253        let (entries, _eof) = repeat_till(1.., StatusEntry::parser, eof).parse_next(input)?;
254        Ok(Self { entries })
255    }
256
257    pub fn iter(&self) -> std::slice::Iter<'_, StatusEntry> {
258        self.entries.iter()
259    }
260}
261
262impl IntoIterator for Status {
263    type Item = StatusEntry;
264
265    type IntoIter = std::vec::IntoIter<Self::Item>;
266
267    fn into_iter(self) -> Self::IntoIter {
268        self.entries.into_iter()
269    }
270}
271
272impl Deref for Status {
273    type Target = Vec<StatusEntry>;
274
275    fn deref(&self) -> &Self::Target {
276        &self.entries
277    }
278}
279
280impl FromStr for Status {
281    type Err = miette::Report;
282
283    fn from_str(input: &str) -> Result<Self, Self::Err> {
284        Self::parser.parse(input).map_err(|err| miette!("{err}"))
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use indoc::indoc;
291    use pretty_assertions::assert_eq;
292
293    use super::*;
294
295    #[test]
296    fn test_status_parse_empty() {
297        assert_eq!(Status::from_str("").unwrap().entries, vec![]);
298    }
299
300    #[test]
301    fn test_status_parse_complex() {
302        assert_eq!(
303            Status::from_str(
304                &indoc!(
305                    " M Cargo.lock
306                     M Cargo.toml
307                     M src/app.rs
308                     M src/cli.rs
309                     D src/commit_hash.rs
310                     D src/git.rs
311                     M src/main.rs
312                     D src/ref_name.rs
313                     D src/worktree.rs
314                    ?? src/config.rs
315                    ?? src/git/
316                    ?? src/utf8tempdir.rs
317                    !! target/
318                    "
319                )
320                .replace('\n', "\0")
321            )
322            .unwrap()
323            .entries,
324            vec![
325                StatusEntry {
326                    left: StatusCode::Unmodified,
327                    right: StatusCode::Modified,
328                    path: "Cargo.lock".into(),
329                    renamed_from: None,
330                },
331                StatusEntry {
332                    left: StatusCode::Unmodified,
333                    right: StatusCode::Modified,
334                    path: "Cargo.toml".into(),
335                    renamed_from: None,
336                },
337                StatusEntry {
338                    left: StatusCode::Unmodified,
339                    right: StatusCode::Modified,
340                    path: "src/app.rs".into(),
341                    renamed_from: None,
342                },
343                StatusEntry {
344                    left: StatusCode::Unmodified,
345                    right: StatusCode::Modified,
346                    path: "src/cli.rs".into(),
347                    renamed_from: None,
348                },
349                StatusEntry {
350                    left: StatusCode::Unmodified,
351                    right: StatusCode::Deleted,
352                    path: "src/commit_hash.rs".into(),
353                    renamed_from: None,
354                },
355                StatusEntry {
356                    left: StatusCode::Unmodified,
357                    right: StatusCode::Deleted,
358                    path: "src/git.rs".into(),
359                    renamed_from: None,
360                },
361                StatusEntry {
362                    left: StatusCode::Unmodified,
363                    right: StatusCode::Modified,
364                    path: "src/main.rs".into(),
365                    renamed_from: None,
366                },
367                StatusEntry {
368                    left: StatusCode::Unmodified,
369                    right: StatusCode::Deleted,
370                    path: "src/ref_name.rs".into(),
371                    renamed_from: None,
372                },
373                StatusEntry {
374                    left: StatusCode::Unmodified,
375                    right: StatusCode::Deleted,
376                    path: "src/worktree.rs".into(),
377                    renamed_from: None,
378                },
379                StatusEntry {
380                    left: StatusCode::Untracked,
381                    right: StatusCode::Untracked,
382                    path: "src/config.rs".into(),
383                    renamed_from: None,
384                },
385                StatusEntry {
386                    left: StatusCode::Untracked,
387                    right: StatusCode::Untracked,
388                    path: "src/git/".into(),
389                    renamed_from: None,
390                },
391                StatusEntry {
392                    left: StatusCode::Untracked,
393                    right: StatusCode::Untracked,
394                    path: "src/utf8tempdir.rs".into(),
395                    renamed_from: None,
396                },
397                StatusEntry {
398                    left: StatusCode::Ignored,
399                    right: StatusCode::Ignored,
400                    path: "target/".into(),
401                    renamed_from: None,
402                },
403            ]
404        );
405    }
406
407    #[test]
408    fn test_status_parse_renamed() {
409        assert_eq!(
410            Status::from_str("R  PUPPY.md\0README.md\0")
411                .unwrap()
412                .entries,
413            vec![StatusEntry {
414                left: StatusCode::Renamed,
415                right: StatusCode::Unmodified,
416                path: "PUPPY.md".into(),
417                renamed_from: Some("README.md".into()),
418            }]
419        );
420    }
421}