status.rs

  1use crate::repository::RepoPath;
  2use anyhow::Result;
  3use serde::{Deserialize, Serialize};
  4use std::{path::Path, str::FromStr, sync::Arc};
  5use util::ResultExt;
  6
  7#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
  8pub enum FileStatus {
  9    Untracked,
 10    Ignored,
 11    Unmerged(UnmergedStatus),
 12    Tracked(TrackedStatus),
 13}
 14
 15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
 16pub struct UnmergedStatus {
 17    pub first_head: UnmergedStatusCode,
 18    pub second_head: UnmergedStatusCode,
 19}
 20
 21#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
 22pub enum UnmergedStatusCode {
 23    Added,
 24    Deleted,
 25    Updated,
 26}
 27
 28#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
 29pub struct TrackedStatus {
 30    pub index_status: StatusCode,
 31    pub worktree_status: StatusCode,
 32}
 33
 34#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
 35pub enum StatusCode {
 36    Modified,
 37    TypeChanged,
 38    Added,
 39    Deleted,
 40    Renamed,
 41    Copied,
 42    Unmodified,
 43}
 44
 45impl From<UnmergedStatus> for FileStatus {
 46    fn from(value: UnmergedStatus) -> Self {
 47        FileStatus::Unmerged(value)
 48    }
 49}
 50
 51impl From<TrackedStatus> for FileStatus {
 52    fn from(value: TrackedStatus) -> Self {
 53        FileStatus::Tracked(value)
 54    }
 55}
 56
 57#[derive(Debug, PartialEq, Eq, Clone, Copy)]
 58pub enum StageStatus {
 59    Staged,
 60    Unstaged,
 61    PartiallyStaged,
 62}
 63
 64impl StageStatus {
 65    pub fn is_fully_staged(&self) -> bool {
 66        matches!(self, StageStatus::Staged)
 67    }
 68
 69    pub fn is_fully_unstaged(&self) -> bool {
 70        matches!(self, StageStatus::Unstaged)
 71    }
 72
 73    pub fn has_staged(&self) -> bool {
 74        matches!(self, StageStatus::Staged | StageStatus::PartiallyStaged)
 75    }
 76
 77    pub fn has_unstaged(&self) -> bool {
 78        matches!(self, StageStatus::Unstaged | StageStatus::PartiallyStaged)
 79    }
 80
 81    pub fn as_bool(self) -> Option<bool> {
 82        match self {
 83            StageStatus::Staged => Some(true),
 84            StageStatus::Unstaged => Some(false),
 85            StageStatus::PartiallyStaged => None,
 86        }
 87    }
 88}
 89
 90impl FileStatus {
 91    pub const fn worktree(worktree_status: StatusCode) -> Self {
 92        FileStatus::Tracked(TrackedStatus {
 93            index_status: StatusCode::Unmodified,
 94            worktree_status,
 95        })
 96    }
 97
 98    pub const fn index(index_status: StatusCode) -> Self {
 99        FileStatus::Tracked(TrackedStatus {
100            worktree_status: StatusCode::Unmodified,
101            index_status,
102        })
103    }
104
105    /// Generate a FileStatus Code from a byte pair, as described in
106    /// https://git-scm.com/docs/git-status#_output
107    ///
108    /// NOTE: That instead of '', we use ' ' to denote no change
109    fn from_bytes(bytes: [u8; 2]) -> anyhow::Result<Self> {
110        let status = match bytes {
111            [b'?', b'?'] => FileStatus::Untracked,
112            [b'!', b'!'] => FileStatus::Ignored,
113            [b'A', b'A'] => UnmergedStatus {
114                first_head: UnmergedStatusCode::Added,
115                second_head: UnmergedStatusCode::Added,
116            }
117            .into(),
118            [b'D', b'D'] => UnmergedStatus {
119                first_head: UnmergedStatusCode::Added,
120                second_head: UnmergedStatusCode::Added,
121            }
122            .into(),
123            [x, b'U'] => UnmergedStatus {
124                first_head: UnmergedStatusCode::from_byte(x)?,
125                second_head: UnmergedStatusCode::Updated,
126            }
127            .into(),
128            [b'U', y] => UnmergedStatus {
129                first_head: UnmergedStatusCode::Updated,
130                second_head: UnmergedStatusCode::from_byte(y)?,
131            }
132            .into(),
133            [x, y] => TrackedStatus {
134                index_status: StatusCode::from_byte(x)?,
135                worktree_status: StatusCode::from_byte(y)?,
136            }
137            .into(),
138        };
139        Ok(status)
140    }
141
142    pub fn staging(self) -> StageStatus {
143        match self {
144            FileStatus::Untracked | FileStatus::Ignored | FileStatus::Unmerged { .. } => {
145                StageStatus::Unstaged
146            }
147            FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) {
148                (StatusCode::Unmodified, _) => StageStatus::Unstaged,
149                (_, StatusCode::Unmodified) => StageStatus::Staged,
150                _ => StageStatus::PartiallyStaged,
151            },
152        }
153    }
154
155    pub fn is_conflicted(self) -> bool {
156        match self {
157            FileStatus::Unmerged { .. } => true,
158            _ => false,
159        }
160    }
161
162    pub fn is_ignored(self) -> bool {
163        match self {
164            FileStatus::Ignored => true,
165            _ => false,
166        }
167    }
168
169    pub fn has_changes(&self) -> bool {
170        self.is_modified()
171            || self.is_created()
172            || self.is_deleted()
173            || self.is_untracked()
174            || self.is_conflicted()
175    }
176
177    pub fn is_modified(self) -> bool {
178        match self {
179            FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) {
180                (StatusCode::Modified, _) | (_, StatusCode::Modified) => true,
181                _ => false,
182            },
183            _ => false,
184        }
185    }
186
187    pub fn is_created(self) -> bool {
188        match self {
189            FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) {
190                (StatusCode::Added, _) | (_, StatusCode::Added) => true,
191                _ => false,
192            },
193            FileStatus::Untracked => true,
194            _ => false,
195        }
196    }
197
198    pub fn is_deleted(self) -> bool {
199        match self {
200            FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) {
201                (StatusCode::Deleted, _) | (_, StatusCode::Deleted) => true,
202                _ => false,
203            },
204            _ => false,
205        }
206    }
207
208    pub fn is_untracked(self) -> bool {
209        match self {
210            FileStatus::Untracked => true,
211            _ => false,
212        }
213    }
214
215    pub fn summary(self) -> GitSummary {
216        match self {
217            FileStatus::Ignored => GitSummary::UNCHANGED,
218            FileStatus::Untracked => GitSummary::UNTRACKED,
219            FileStatus::Unmerged(_) => GitSummary::CONFLICT,
220            FileStatus::Tracked(TrackedStatus {
221                index_status,
222                worktree_status,
223            }) => GitSummary {
224                index: index_status.to_summary(),
225                worktree: worktree_status.to_summary(),
226                conflict: 0,
227                untracked: 0,
228                count: 1,
229            },
230        }
231    }
232}
233
234impl StatusCode {
235    fn from_byte(byte: u8) -> anyhow::Result<Self> {
236        match byte {
237            b'M' => Ok(StatusCode::Modified),
238            b'T' => Ok(StatusCode::TypeChanged),
239            b'A' => Ok(StatusCode::Added),
240            b'D' => Ok(StatusCode::Deleted),
241            b'R' => Ok(StatusCode::Renamed),
242            b'C' => Ok(StatusCode::Copied),
243            b' ' => Ok(StatusCode::Unmodified),
244            _ => anyhow::bail!("Invalid status code: {byte}"),
245        }
246    }
247
248    fn to_summary(self) -> TrackedSummary {
249        match self {
250            StatusCode::Modified | StatusCode::TypeChanged => TrackedSummary {
251                modified: 1,
252                ..TrackedSummary::UNCHANGED
253            },
254            StatusCode::Added => TrackedSummary {
255                added: 1,
256                ..TrackedSummary::UNCHANGED
257            },
258            StatusCode::Deleted => TrackedSummary {
259                deleted: 1,
260                ..TrackedSummary::UNCHANGED
261            },
262            StatusCode::Renamed | StatusCode::Copied | StatusCode::Unmodified => {
263                TrackedSummary::UNCHANGED
264            }
265        }
266    }
267
268    pub fn index(self) -> FileStatus {
269        FileStatus::Tracked(TrackedStatus {
270            index_status: self,
271            worktree_status: StatusCode::Unmodified,
272        })
273    }
274
275    pub fn worktree(self) -> FileStatus {
276        FileStatus::Tracked(TrackedStatus {
277            index_status: StatusCode::Unmodified,
278            worktree_status: self,
279        })
280    }
281}
282
283impl UnmergedStatusCode {
284    fn from_byte(byte: u8) -> anyhow::Result<Self> {
285        match byte {
286            b'A' => Ok(UnmergedStatusCode::Added),
287            b'D' => Ok(UnmergedStatusCode::Deleted),
288            b'U' => Ok(UnmergedStatusCode::Updated),
289            _ => anyhow::bail!("Invalid unmerged status code: {byte}"),
290        }
291    }
292}
293
294#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
295pub struct TrackedSummary {
296    pub added: usize,
297    pub modified: usize,
298    pub deleted: usize,
299}
300
301impl TrackedSummary {
302    pub const UNCHANGED: Self = Self {
303        added: 0,
304        modified: 0,
305        deleted: 0,
306    };
307
308    pub const ADDED: Self = Self {
309        added: 1,
310        modified: 0,
311        deleted: 0,
312    };
313
314    pub const MODIFIED: Self = Self {
315        added: 0,
316        modified: 1,
317        deleted: 0,
318    };
319
320    pub const DELETED: Self = Self {
321        added: 0,
322        modified: 0,
323        deleted: 1,
324    };
325}
326
327impl std::ops::AddAssign for TrackedSummary {
328    fn add_assign(&mut self, rhs: Self) {
329        self.added += rhs.added;
330        self.modified += rhs.modified;
331        self.deleted += rhs.deleted;
332    }
333}
334
335impl std::ops::Add for TrackedSummary {
336    type Output = Self;
337
338    fn add(self, rhs: Self) -> Self::Output {
339        TrackedSummary {
340            added: self.added + rhs.added,
341            modified: self.modified + rhs.modified,
342            deleted: self.deleted + rhs.deleted,
343        }
344    }
345}
346
347impl std::ops::Sub for TrackedSummary {
348    type Output = Self;
349
350    fn sub(self, rhs: Self) -> Self::Output {
351        TrackedSummary {
352            added: self.added - rhs.added,
353            modified: self.modified - rhs.modified,
354            deleted: self.deleted - rhs.deleted,
355        }
356    }
357}
358
359#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
360pub struct GitSummary {
361    pub index: TrackedSummary,
362    pub worktree: TrackedSummary,
363    pub conflict: usize,
364    pub untracked: usize,
365    pub count: usize,
366}
367
368impl GitSummary {
369    pub const CONFLICT: Self = Self {
370        conflict: 1,
371        count: 1,
372        ..Self::UNCHANGED
373    };
374
375    pub const UNTRACKED: Self = Self {
376        untracked: 1,
377        count: 1,
378        ..Self::UNCHANGED
379    };
380
381    pub const UNCHANGED: Self = Self {
382        index: TrackedSummary::UNCHANGED,
383        worktree: TrackedSummary::UNCHANGED,
384        conflict: 0,
385        untracked: 0,
386        count: 0,
387    };
388}
389
390impl From<FileStatus> for GitSummary {
391    fn from(status: FileStatus) -> Self {
392        status.summary()
393    }
394}
395
396impl sum_tree::Summary for GitSummary {
397    type Context = ();
398
399    fn zero(_: &Self::Context) -> Self {
400        Default::default()
401    }
402
403    fn add_summary(&mut self, rhs: &Self, _: &Self::Context) {
404        *self += *rhs;
405    }
406}
407
408impl std::ops::Add<Self> for GitSummary {
409    type Output = Self;
410
411    fn add(mut self, rhs: Self) -> Self {
412        self += rhs;
413        self
414    }
415}
416
417impl std::ops::AddAssign for GitSummary {
418    fn add_assign(&mut self, rhs: Self) {
419        self.index += rhs.index;
420        self.worktree += rhs.worktree;
421        self.conflict += rhs.conflict;
422        self.untracked += rhs.untracked;
423        self.count += rhs.count;
424    }
425}
426
427impl std::ops::Sub for GitSummary {
428    type Output = GitSummary;
429
430    fn sub(self, rhs: Self) -> Self::Output {
431        GitSummary {
432            index: self.index - rhs.index,
433            worktree: self.worktree - rhs.worktree,
434            conflict: self.conflict - rhs.conflict,
435            untracked: self.untracked - rhs.untracked,
436            count: self.count - rhs.count,
437        }
438    }
439}
440
441#[derive(Clone, Debug)]
442pub struct GitStatus {
443    pub entries: Arc<[(RepoPath, FileStatus)]>,
444}
445
446impl FromStr for GitStatus {
447    type Err = anyhow::Error;
448
449    fn from_str(s: &str) -> Result<Self> {
450        let mut entries = s
451            .split('\0')
452            .filter_map(|entry| {
453                let sep = entry.get(2..3)?;
454                if sep != " " {
455                    return None;
456                };
457                let path = &entry[3..];
458                // The git status output includes untracked directories as well as untracked files.
459                // We do our own processing to compute the "summary" status of each directory,
460                // so just skip any directories in the output, since they'll otherwise interfere
461                // with our handling of nested repositories.
462                if path.ends_with('/') {
463                    return None;
464                }
465                let status = entry.as_bytes()[0..2].try_into().unwrap();
466                let status = FileStatus::from_bytes(status).log_err()?;
467                let path = RepoPath(Path::new(path).into());
468                Some((path, status))
469            })
470            .collect::<Vec<_>>();
471        entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
472        // When a file exists in HEAD, is deleted in the index, and exists again in the working copy,
473        // git produces two lines for it, one reading `D ` (deleted in index, unmodified in working copy)
474        // and the other reading `??` (untracked). Merge these two into the equivalent of `DA`.
475        entries.dedup_by(|(a, a_status), (b, b_status)| {
476            const INDEX_DELETED: FileStatus = FileStatus::index(StatusCode::Deleted);
477            if a.ne(&b) {
478                return false;
479            }
480            match (*a_status, *b_status) {
481                (INDEX_DELETED, FileStatus::Untracked) | (FileStatus::Untracked, INDEX_DELETED) => {
482                    *b_status = TrackedStatus {
483                        index_status: StatusCode::Deleted,
484                        worktree_status: StatusCode::Added,
485                    }
486                    .into();
487                }
488                _ => panic!("Unexpected duplicated status entries: {a_status:?} and {b_status:?}"),
489            }
490            true
491        });
492        Ok(Self {
493            entries: entries.into(),
494        })
495    }
496}
497
498impl Default for GitStatus {
499    fn default() -> Self {
500        Self {
501            entries: Arc::new([]),
502        }
503    }
504}