status.rs

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