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            }) => {
175                let mut summary = index_status.to_summary() + worktree_status.to_summary();
176                if summary != GitSummary::UNCHANGED {
177                    summary.count = 1;
178                };
179                summary
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    /// Returns the contribution of this status code to the Git summary.
200    ///
201    /// Note that this does not include the count field, which must be set manually.
202    fn to_summary(self) -> GitSummary {
203        match self {
204            StatusCode::Modified | StatusCode::TypeChanged => GitSummary {
205                modified: 1,
206                ..GitSummary::UNCHANGED
207            },
208            StatusCode::Added => GitSummary {
209                added: 1,
210                ..GitSummary::UNCHANGED
211            },
212            StatusCode::Deleted => GitSummary {
213                deleted: 1,
214                ..GitSummary::UNCHANGED
215            },
216            StatusCode::Renamed | StatusCode::Copied | StatusCode::Unmodified => {
217                GitSummary::UNCHANGED
218            }
219        }
220    }
221}
222
223impl UnmergedStatusCode {
224    fn from_byte(byte: u8) -> anyhow::Result<Self> {
225        match byte {
226            b'A' => Ok(UnmergedStatusCode::Added),
227            b'D' => Ok(UnmergedStatusCode::Deleted),
228            b'U' => Ok(UnmergedStatusCode::Updated),
229            _ => Err(anyhow!("Invalid unmerged status code: {byte}")),
230        }
231    }
232}
233
234#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
235pub struct GitSummary {
236    pub added: usize,
237    pub modified: usize,
238    pub conflict: usize,
239    pub untracked: usize,
240    pub deleted: usize,
241    pub count: usize,
242}
243
244impl GitSummary {
245    pub const CONFLICT: Self = Self {
246        conflict: 1,
247        count: 1,
248        ..Self::UNCHANGED
249    };
250
251    pub const UNTRACKED: Self = Self {
252        untracked: 1,
253        count: 1,
254        ..Self::UNCHANGED
255    };
256
257    pub const UNCHANGED: Self = Self {
258        added: 0,
259        modified: 0,
260        conflict: 0,
261        untracked: 0,
262        deleted: 0,
263        count: 0,
264    };
265}
266
267impl From<FileStatus> for GitSummary {
268    fn from(status: FileStatus) -> Self {
269        status.summary()
270    }
271}
272
273impl sum_tree::Summary for GitSummary {
274    type Context = ();
275
276    fn zero(_: &Self::Context) -> Self {
277        Default::default()
278    }
279
280    fn add_summary(&mut self, rhs: &Self, _: &Self::Context) {
281        *self += *rhs;
282    }
283}
284
285impl std::ops::Add<Self> for GitSummary {
286    type Output = Self;
287
288    fn add(mut self, rhs: Self) -> Self {
289        self += rhs;
290        self
291    }
292}
293
294impl std::ops::AddAssign for GitSummary {
295    fn add_assign(&mut self, rhs: Self) {
296        self.added += rhs.added;
297        self.modified += rhs.modified;
298        self.conflict += rhs.conflict;
299        self.untracked += rhs.untracked;
300        self.deleted += rhs.deleted;
301        self.count += rhs.count;
302    }
303}
304
305impl std::ops::Sub for GitSummary {
306    type Output = GitSummary;
307
308    fn sub(self, rhs: Self) -> Self::Output {
309        GitSummary {
310            added: self.added - rhs.added,
311            modified: self.modified - rhs.modified,
312            conflict: self.conflict - rhs.conflict,
313            untracked: self.untracked - rhs.untracked,
314            deleted: self.deleted - rhs.deleted,
315            count: self.count - rhs.count,
316        }
317    }
318}
319
320#[derive(Clone)]
321pub struct GitStatus {
322    pub entries: Arc<[(RepoPath, FileStatus)]>,
323}
324
325impl GitStatus {
326    pub(crate) fn new(
327        git_binary: &Path,
328        working_directory: &Path,
329        path_prefixes: &[RepoPath],
330    ) -> Result<Self> {
331        let child = util::command::new_std_command(git_binary)
332            .current_dir(working_directory)
333            .args([
334                "--no-optional-locks",
335                "status",
336                "--porcelain=v1",
337                "--untracked-files=all",
338                "--no-renames",
339                "-z",
340            ])
341            .args(path_prefixes.iter().map(|path_prefix| {
342                if path_prefix.0.as_ref() == Path::new("") {
343                    Path::new(".")
344                } else {
345                    path_prefix
346                }
347            }))
348            .stdin(Stdio::null())
349            .stdout(Stdio::piped())
350            .stderr(Stdio::piped())
351            .spawn()
352            .map_err(|e| anyhow!("Failed to start git status process: {}", e))?;
353
354        let output = child
355            .wait_with_output()
356            .map_err(|e| anyhow!("Failed to read git blame output: {}", e))?;
357
358        if !output.status.success() {
359            let stderr = String::from_utf8_lossy(&output.stderr);
360            return Err(anyhow!("git status process failed: {}", stderr));
361        }
362        let stdout = String::from_utf8_lossy(&output.stdout);
363        let mut entries = stdout
364            .split('\0')
365            .filter_map(|entry| {
366                let sep = entry.get(2..3)?;
367                if sep != " " {
368                    return None;
369                };
370                let path = &entry[3..];
371                let status = entry[0..2].as_bytes().try_into().unwrap();
372                let status = FileStatus::from_bytes(status).log_err()?;
373                let path = RepoPath(Path::new(path).into());
374                Some((path, status))
375            })
376            .collect::<Vec<_>>();
377        entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
378        Ok(Self {
379            entries: entries.into(),
380        })
381    }
382}
383
384impl Default for GitStatus {
385    fn default() -> Self {
386        Self {
387            entries: Arc::new([]),
388        }
389    }
390}