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