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        matches!(self, FileStatus::Unmerged { .. })
157    }
158
159    pub fn is_ignored(self) -> bool {
160        matches!(self, FileStatus::Ignored)
161    }
162
163    pub fn has_changes(&self) -> bool {
164        self.is_modified()
165            || self.is_created()
166            || self.is_deleted()
167            || self.is_untracked()
168            || self.is_conflicted()
169    }
170
171    pub fn is_modified(self) -> bool {
172        match self {
173            FileStatus::Tracked(tracked) => matches!(
174                (tracked.index_status, tracked.worktree_status),
175                (StatusCode::Modified, _) | (_, StatusCode::Modified)
176            ),
177            _ => false,
178        }
179    }
180
181    pub fn is_created(self) -> bool {
182        match self {
183            FileStatus::Tracked(tracked) => matches!(
184                (tracked.index_status, tracked.worktree_status),
185                (StatusCode::Added, _) | (_, StatusCode::Added)
186            ),
187            FileStatus::Untracked => true,
188            _ => false,
189        }
190    }
191
192    pub fn is_deleted(self) -> bool {
193        matches!(self, FileStatus::Tracked(tracked) if matches!((tracked.index_status, tracked.worktree_status), (StatusCode::Deleted, _) | (_, StatusCode::Deleted)))
194    }
195
196    pub fn is_untracked(self) -> bool {
197        matches!(self, FileStatus::Untracked)
198    }
199
200    pub fn summary(self) -> GitSummary {
201        match self {
202            FileStatus::Ignored => GitSummary::UNCHANGED,
203            FileStatus::Untracked => GitSummary::UNTRACKED,
204            FileStatus::Unmerged(_) => GitSummary::CONFLICT,
205            FileStatus::Tracked(TrackedStatus {
206                index_status,
207                worktree_status,
208            }) => GitSummary {
209                index: index_status.to_summary(),
210                worktree: worktree_status.to_summary(),
211                conflict: 0,
212                untracked: 0,
213                count: 1,
214            },
215        }
216    }
217}
218
219impl StatusCode {
220    fn from_byte(byte: u8) -> anyhow::Result<Self> {
221        match byte {
222            b'M' => Ok(StatusCode::Modified),
223            b'T' => Ok(StatusCode::TypeChanged),
224            b'A' => Ok(StatusCode::Added),
225            b'D' => Ok(StatusCode::Deleted),
226            b'R' => Ok(StatusCode::Renamed),
227            b'C' => Ok(StatusCode::Copied),
228            b' ' => Ok(StatusCode::Unmodified),
229            _ => anyhow::bail!("Invalid status code: {byte}"),
230        }
231    }
232
233    fn to_summary(self) -> TrackedSummary {
234        match self {
235            StatusCode::Modified | StatusCode::TypeChanged => TrackedSummary {
236                modified: 1,
237                ..TrackedSummary::UNCHANGED
238            },
239            StatusCode::Added => TrackedSummary {
240                added: 1,
241                ..TrackedSummary::UNCHANGED
242            },
243            StatusCode::Deleted => TrackedSummary {
244                deleted: 1,
245                ..TrackedSummary::UNCHANGED
246            },
247            StatusCode::Renamed | StatusCode::Copied | StatusCode::Unmodified => {
248                TrackedSummary::UNCHANGED
249            }
250        }
251    }
252
253    pub fn index(self) -> FileStatus {
254        FileStatus::Tracked(TrackedStatus {
255            index_status: self,
256            worktree_status: StatusCode::Unmodified,
257        })
258    }
259
260    pub fn worktree(self) -> FileStatus {
261        FileStatus::Tracked(TrackedStatus {
262            index_status: StatusCode::Unmodified,
263            worktree_status: self,
264        })
265    }
266}
267
268impl UnmergedStatusCode {
269    fn from_byte(byte: u8) -> anyhow::Result<Self> {
270        match byte {
271            b'A' => Ok(UnmergedStatusCode::Added),
272            b'D' => Ok(UnmergedStatusCode::Deleted),
273            b'U' => Ok(UnmergedStatusCode::Updated),
274            _ => anyhow::bail!("Invalid unmerged status code: {byte}"),
275        }
276    }
277}
278
279#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
280pub struct TrackedSummary {
281    pub added: usize,
282    pub modified: usize,
283    pub deleted: usize,
284}
285
286impl TrackedSummary {
287    pub const UNCHANGED: Self = Self {
288        added: 0,
289        modified: 0,
290        deleted: 0,
291    };
292
293    pub const ADDED: Self = Self {
294        added: 1,
295        modified: 0,
296        deleted: 0,
297    };
298
299    pub const MODIFIED: Self = Self {
300        added: 0,
301        modified: 1,
302        deleted: 0,
303    };
304
305    pub const DELETED: Self = Self {
306        added: 0,
307        modified: 0,
308        deleted: 1,
309    };
310}
311
312impl std::ops::AddAssign for TrackedSummary {
313    fn add_assign(&mut self, rhs: Self) {
314        self.added += rhs.added;
315        self.modified += rhs.modified;
316        self.deleted += rhs.deleted;
317    }
318}
319
320impl std::ops::Add for TrackedSummary {
321    type Output = Self;
322
323    fn add(self, rhs: Self) -> Self::Output {
324        TrackedSummary {
325            added: self.added + rhs.added,
326            modified: self.modified + rhs.modified,
327            deleted: self.deleted + rhs.deleted,
328        }
329    }
330}
331
332impl std::ops::Sub for TrackedSummary {
333    type Output = Self;
334
335    fn sub(self, rhs: Self) -> Self::Output {
336        TrackedSummary {
337            added: self.added - rhs.added,
338            modified: self.modified - rhs.modified,
339            deleted: self.deleted - rhs.deleted,
340        }
341    }
342}
343
344#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
345pub struct GitSummary {
346    pub index: TrackedSummary,
347    pub worktree: TrackedSummary,
348    pub conflict: usize,
349    pub untracked: usize,
350    pub count: usize,
351}
352
353impl GitSummary {
354    pub const CONFLICT: Self = Self {
355        conflict: 1,
356        count: 1,
357        ..Self::UNCHANGED
358    };
359
360    pub const UNTRACKED: Self = Self {
361        untracked: 1,
362        count: 1,
363        ..Self::UNCHANGED
364    };
365
366    pub const UNCHANGED: Self = Self {
367        index: TrackedSummary::UNCHANGED,
368        worktree: TrackedSummary::UNCHANGED,
369        conflict: 0,
370        untracked: 0,
371        count: 0,
372    };
373}
374
375impl From<FileStatus> for GitSummary {
376    fn from(status: FileStatus) -> Self {
377        status.summary()
378    }
379}
380
381impl sum_tree::Summary for GitSummary {
382    type Context = ();
383
384    fn zero(_: &Self::Context) -> Self {
385        Default::default()
386    }
387
388    fn add_summary(&mut self, rhs: &Self, _: &Self::Context) {
389        *self += *rhs;
390    }
391}
392
393impl std::ops::Add<Self> for GitSummary {
394    type Output = Self;
395
396    fn add(mut self, rhs: Self) -> Self {
397        self += rhs;
398        self
399    }
400}
401
402impl std::ops::AddAssign for GitSummary {
403    fn add_assign(&mut self, rhs: Self) {
404        self.index += rhs.index;
405        self.worktree += rhs.worktree;
406        self.conflict += rhs.conflict;
407        self.untracked += rhs.untracked;
408        self.count += rhs.count;
409    }
410}
411
412impl std::ops::Sub for GitSummary {
413    type Output = GitSummary;
414
415    fn sub(self, rhs: Self) -> Self::Output {
416        GitSummary {
417            index: self.index - rhs.index,
418            worktree: self.worktree - rhs.worktree,
419            conflict: self.conflict - rhs.conflict,
420            untracked: self.untracked - rhs.untracked,
421            count: self.count - rhs.count,
422        }
423    }
424}
425
426#[derive(Clone, Debug)]
427pub struct GitStatus {
428    pub entries: Arc<[(RepoPath, FileStatus)]>,
429}
430
431impl FromStr for GitStatus {
432    type Err = anyhow::Error;
433
434    fn from_str(s: &str) -> Result<Self> {
435        let mut entries = s
436            .split('\0')
437            .filter_map(|entry| {
438                let sep = entry.get(2..3)?;
439                if sep != " " {
440                    return None;
441                };
442                let path = &entry[3..];
443                // The git status output includes untracked directories as well as untracked files.
444                // We do our own processing to compute the "summary" status of each directory,
445                // so just skip any directories in the output, since they'll otherwise interfere
446                // with our handling of nested repositories.
447                if path.ends_with('/') {
448                    return None;
449                }
450                let status = entry.as_bytes()[0..2].try_into().unwrap();
451                let status = FileStatus::from_bytes(status).log_err()?;
452                let path = RepoPath(Path::new(path).into());
453                Some((path, status))
454            })
455            .collect::<Vec<_>>();
456        entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
457        // When a file exists in HEAD, is deleted in the index, and exists again in the working copy,
458        // git produces two lines for it, one reading `D ` (deleted in index, unmodified in working copy)
459        // and the other reading `??` (untracked). Merge these two into the equivalent of `DA`.
460        entries.dedup_by(|(a, a_status), (b, b_status)| {
461            const INDEX_DELETED: FileStatus = FileStatus::index(StatusCode::Deleted);
462            if a.ne(&b) {
463                return false;
464            }
465            match (*a_status, *b_status) {
466                (INDEX_DELETED, FileStatus::Untracked) | (FileStatus::Untracked, INDEX_DELETED) => {
467                    *b_status = TrackedStatus {
468                        index_status: StatusCode::Deleted,
469                        worktree_status: StatusCode::Added,
470                    }
471                    .into();
472                }
473                _ => panic!("Unexpected duplicated status entries: {a_status:?} and {b_status:?}"),
474            }
475            true
476        });
477        Ok(Self {
478            entries: entries.into(),
479        })
480    }
481}
482
483impl Default for GitStatus {
484    fn default() -> Self {
485        Self {
486            entries: Arc::new([]),
487        }
488    }
489}