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