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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum StatusCode {
66 Unmodified,
68 Modified,
70 TypeChanged,
72 Added,
74 Deleted,
76 Renamed,
78 Copied,
80 Unmerged,
82 Untracked,
84 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#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct StatusEntry {
133 pub left: StatusCode,
141 pub right: StatusCode,
149 pub path: Utf8PathBuf,
151 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 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#[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}