status.rs

  1use crate::{Oid, repository::RepoPath};
  2use anyhow::{Result, anyhow};
  3use collections::HashMap;
  4use gpui::SharedString;
  5use serde::{Deserialize, Serialize};
  6use std::{str::FromStr, sync::Arc};
  7use util::{ResultExt, rel_path::RelPath};
  8
  9#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
 10pub enum FileStatus {
 11    Untracked,
 12    Ignored,
 13    Unmerged(UnmergedStatus),
 14    Tracked(TrackedStatus),
 15}
 16
 17#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
 18pub struct UnmergedStatus {
 19    pub first_head: UnmergedStatusCode,
 20    pub second_head: UnmergedStatusCode,
 21}
 22
 23#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
 24pub enum UnmergedStatusCode {
 25    Added,
 26    Deleted,
 27    Updated,
 28}
 29
 30#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
 31pub struct TrackedStatus {
 32    pub index_status: StatusCode,
 33    pub worktree_status: StatusCode,
 34}
 35
 36#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
 37pub enum StatusCode {
 38    Modified,
 39    TypeChanged,
 40    Added,
 41    Deleted,
 42    Renamed,
 43    Copied,
 44    Unmodified,
 45}
 46
 47impl From<UnmergedStatus> for FileStatus {
 48    fn from(value: UnmergedStatus) -> Self {
 49        FileStatus::Unmerged(value)
 50    }
 51}
 52
 53impl From<TrackedStatus> for FileStatus {
 54    fn from(value: TrackedStatus) -> Self {
 55        FileStatus::Tracked(value)
 56    }
 57}
 58
 59#[derive(Debug, PartialEq, Eq, Clone, Copy)]
 60pub enum StageStatus {
 61    Staged,
 62    Unstaged,
 63    PartiallyStaged,
 64}
 65
 66impl StageStatus {
 67    pub const fn is_fully_staged(&self) -> bool {
 68        matches!(self, StageStatus::Staged)
 69    }
 70
 71    pub const fn is_fully_unstaged(&self) -> bool {
 72        matches!(self, StageStatus::Unstaged)
 73    }
 74
 75    pub const fn has_staged(&self) -> bool {
 76        matches!(self, StageStatus::Staged | StageStatus::PartiallyStaged)
 77    }
 78
 79    pub const fn has_unstaged(&self) -> bool {
 80        matches!(self, StageStatus::Unstaged | StageStatus::PartiallyStaged)
 81    }
 82
 83    pub const fn as_bool(self) -> Option<bool> {
 84        match self {
 85            StageStatus::Staged => Some(true),
 86            StageStatus::Unstaged => Some(false),
 87            StageStatus::PartiallyStaged => None,
 88        }
 89    }
 90}
 91
 92impl FileStatus {
 93    pub const fn worktree(worktree_status: StatusCode) -> Self {
 94        FileStatus::Tracked(TrackedStatus {
 95            index_status: StatusCode::Unmodified,
 96            worktree_status,
 97        })
 98    }
 99
100    pub const fn index(index_status: StatusCode) -> Self {
101        FileStatus::Tracked(TrackedStatus {
102            worktree_status: StatusCode::Unmodified,
103            index_status,
104        })
105    }
106
107    /// Generate a FileStatus Code from a byte pair, as described in
108    /// https://git-scm.com/docs/git-status#_output
109    ///
110    /// NOTE: That instead of '', we use ' ' to denote no change
111    fn from_bytes(bytes: [u8; 2]) -> anyhow::Result<Self> {
112        let status = match bytes {
113            [b'?', b'?'] => FileStatus::Untracked,
114            [b'!', b'!'] => FileStatus::Ignored,
115            [b'A', b'A'] => UnmergedStatus {
116                first_head: UnmergedStatusCode::Added,
117                second_head: UnmergedStatusCode::Added,
118            }
119            .into(),
120            [b'D', b'D'] => UnmergedStatus {
121                first_head: UnmergedStatusCode::Added,
122                second_head: UnmergedStatusCode::Added,
123            }
124            .into(),
125            [x, b'U'] => UnmergedStatus {
126                first_head: UnmergedStatusCode::from_byte(x)?,
127                second_head: UnmergedStatusCode::Updated,
128            }
129            .into(),
130            [b'U', y] => UnmergedStatus {
131                first_head: UnmergedStatusCode::Updated,
132                second_head: UnmergedStatusCode::from_byte(y)?,
133            }
134            .into(),
135            [x, y] => TrackedStatus {
136                index_status: StatusCode::from_byte(x)?,
137                worktree_status: StatusCode::from_byte(y)?,
138            }
139            .into(),
140        };
141        Ok(status)
142    }
143
144    pub fn staging(self) -> StageStatus {
145        match self {
146            FileStatus::Untracked | FileStatus::Ignored | FileStatus::Unmerged { .. } => {
147                StageStatus::Unstaged
148            }
149            FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) {
150                (StatusCode::Unmodified, _) => StageStatus::Unstaged,
151                (_, StatusCode::Unmodified) => StageStatus::Staged,
152                _ => StageStatus::PartiallyStaged,
153            },
154        }
155    }
156
157    pub fn is_conflicted(self) -> bool {
158        matches!(self, FileStatus::Unmerged { .. })
159    }
160
161    pub fn is_ignored(self) -> bool {
162        matches!(self, FileStatus::Ignored)
163    }
164
165    pub fn has_changes(&self) -> bool {
166        self.is_modified()
167            || self.is_created()
168            || self.is_deleted()
169            || self.is_untracked()
170            || self.is_conflicted()
171    }
172
173    pub fn is_modified(self) -> bool {
174        match self {
175            FileStatus::Tracked(tracked) => matches!(
176                (tracked.index_status, tracked.worktree_status),
177                (StatusCode::Modified, _) | (_, StatusCode::Modified)
178            ),
179            _ => false,
180        }
181    }
182
183    pub fn is_created(self) -> bool {
184        match self {
185            FileStatus::Tracked(tracked) => matches!(
186                (tracked.index_status, tracked.worktree_status),
187                (StatusCode::Added, _) | (_, StatusCode::Added)
188            ),
189            FileStatus::Untracked => true,
190            _ => false,
191        }
192    }
193
194    pub fn is_deleted(self) -> bool {
195        let FileStatus::Tracked(tracked) = self else {
196            return false;
197        };
198        tracked.index_status == StatusCode::Deleted && tracked.worktree_status != StatusCode::Added
199            || tracked.worktree_status == StatusCode::Deleted
200    }
201
202    pub fn is_untracked(self) -> bool {
203        matches!(self, FileStatus::Untracked)
204    }
205
206    pub fn is_renamed(self) -> bool {
207        let FileStatus::Tracked(tracked) = self else {
208            return false;
209        };
210        tracked.index_status == StatusCode::Renamed
211            || tracked.worktree_status == StatusCode::Renamed
212    }
213
214    pub fn summary(self) -> GitSummary {
215        match self {
216            FileStatus::Ignored => GitSummary::UNCHANGED,
217            FileStatus::Untracked => GitSummary::UNTRACKED,
218            FileStatus::Unmerged(_) => GitSummary::CONFLICT,
219            FileStatus::Tracked(TrackedStatus {
220                index_status,
221                worktree_status,
222            }) => GitSummary {
223                index: index_status.to_summary(),
224                worktree: worktree_status.to_summary(),
225                conflict: 0,
226                untracked: 0,
227                count: 1,
228            },
229        }
230    }
231}
232
233impl StatusCode {
234    fn from_byte(byte: u8) -> anyhow::Result<Self> {
235        match byte {
236            b'M' => Ok(StatusCode::Modified),
237            b'T' => Ok(StatusCode::TypeChanged),
238            b'A' => Ok(StatusCode::Added),
239            b'D' => Ok(StatusCode::Deleted),
240            b'R' => Ok(StatusCode::Renamed),
241            b'C' => Ok(StatusCode::Copied),
242            b' ' => Ok(StatusCode::Unmodified),
243            _ => anyhow::bail!("Invalid status code: {byte}"),
244        }
245    }
246
247    fn to_summary(self) -> TrackedSummary {
248        match self {
249            StatusCode::Modified | StatusCode::TypeChanged => TrackedSummary {
250                modified: 1,
251                ..TrackedSummary::UNCHANGED
252            },
253            StatusCode::Added => TrackedSummary {
254                added: 1,
255                ..TrackedSummary::UNCHANGED
256            },
257            StatusCode::Deleted => TrackedSummary {
258                deleted: 1,
259                ..TrackedSummary::UNCHANGED
260            },
261            StatusCode::Renamed | StatusCode::Copied | StatusCode::Unmodified => {
262                TrackedSummary::UNCHANGED
263            }
264        }
265    }
266
267    pub fn index(self) -> FileStatus {
268        FileStatus::Tracked(TrackedStatus {
269            index_status: self,
270            worktree_status: StatusCode::Unmodified,
271        })
272    }
273
274    pub fn worktree(self) -> FileStatus {
275        FileStatus::Tracked(TrackedStatus {
276            index_status: StatusCode::Unmodified,
277            worktree_status: self,
278        })
279    }
280}
281
282impl UnmergedStatusCode {
283    fn from_byte(byte: u8) -> anyhow::Result<Self> {
284        match byte {
285            b'A' => Ok(UnmergedStatusCode::Added),
286            b'D' => Ok(UnmergedStatusCode::Deleted),
287            b'U' => Ok(UnmergedStatusCode::Updated),
288            _ => anyhow::bail!("Invalid unmerged status code: {byte}"),
289        }
290    }
291}
292
293#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
294pub struct TrackedSummary {
295    pub added: usize,
296    pub modified: usize,
297    pub deleted: usize,
298}
299
300impl TrackedSummary {
301    pub const UNCHANGED: Self = Self {
302        added: 0,
303        modified: 0,
304        deleted: 0,
305    };
306
307    pub const ADDED: Self = Self {
308        added: 1,
309        modified: 0,
310        deleted: 0,
311    };
312
313    pub const MODIFIED: Self = Self {
314        added: 0,
315        modified: 1,
316        deleted: 0,
317    };
318
319    pub const DELETED: Self = Self {
320        added: 0,
321        modified: 0,
322        deleted: 1,
323    };
324}
325
326impl std::ops::AddAssign for TrackedSummary {
327    fn add_assign(&mut self, rhs: Self) {
328        self.added += rhs.added;
329        self.modified += rhs.modified;
330        self.deleted += rhs.deleted;
331    }
332}
333
334impl std::ops::Add for TrackedSummary {
335    type Output = Self;
336
337    fn add(self, rhs: Self) -> Self::Output {
338        TrackedSummary {
339            added: self.added + rhs.added,
340            modified: self.modified + rhs.modified,
341            deleted: self.deleted + rhs.deleted,
342        }
343    }
344}
345
346impl std::ops::Sub for TrackedSummary {
347    type Output = Self;
348
349    fn sub(self, rhs: Self) -> Self::Output {
350        TrackedSummary {
351            added: self.added - rhs.added,
352            modified: self.modified - rhs.modified,
353            deleted: self.deleted - rhs.deleted,
354        }
355    }
356}
357
358#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
359pub struct GitSummary {
360    pub index: TrackedSummary,
361    pub worktree: TrackedSummary,
362    pub conflict: usize,
363    pub untracked: usize,
364    pub count: usize,
365}
366
367impl GitSummary {
368    pub const CONFLICT: Self = Self {
369        conflict: 1,
370        count: 1,
371        ..Self::UNCHANGED
372    };
373
374    pub const UNTRACKED: Self = Self {
375        untracked: 1,
376        count: 1,
377        ..Self::UNCHANGED
378    };
379
380    pub const UNCHANGED: Self = Self {
381        index: TrackedSummary::UNCHANGED,
382        worktree: TrackedSummary::UNCHANGED,
383        conflict: 0,
384        untracked: 0,
385        count: 0,
386    };
387}
388
389impl From<FileStatus> for GitSummary {
390    fn from(status: FileStatus) -> Self {
391        status.summary()
392    }
393}
394
395impl sum_tree::ContextLessSummary for GitSummary {
396    fn zero() -> Self {
397        Default::default()
398    }
399
400    fn add_summary(&mut self, rhs: &Self) {
401        *self += *rhs;
402    }
403}
404
405impl std::ops::Add<Self> for GitSummary {
406    type Output = Self;
407
408    fn add(mut self, rhs: Self) -> Self {
409        self += rhs;
410        self
411    }
412}
413
414impl std::ops::AddAssign for GitSummary {
415    fn add_assign(&mut self, rhs: Self) {
416        self.index += rhs.index;
417        self.worktree += rhs.worktree;
418        self.conflict += rhs.conflict;
419        self.untracked += rhs.untracked;
420        self.count += rhs.count;
421    }
422}
423
424impl std::ops::Sub for GitSummary {
425    type Output = GitSummary;
426
427    fn sub(self, rhs: Self) -> Self::Output {
428        GitSummary {
429            index: self.index - rhs.index,
430            worktree: self.worktree - rhs.worktree,
431            conflict: self.conflict - rhs.conflict,
432            untracked: self.untracked - rhs.untracked,
433            count: self.count - rhs.count,
434        }
435    }
436}
437
438#[derive(Clone, Debug)]
439pub struct GitStatus {
440    pub entries: Arc<[(RepoPath, FileStatus)]>,
441    pub renamed_paths: HashMap<RepoPath, RepoPath>,
442}
443
444impl FromStr for GitStatus {
445    type Err = anyhow::Error;
446
447    fn from_str(s: &str) -> Result<Self> {
448        let mut parts = s.split('\0').peekable();
449        let mut entries = Vec::new();
450        let mut renamed_paths = HashMap::default();
451
452        while let Some(entry) = parts.next() {
453            if entry.is_empty() {
454                continue;
455            }
456
457            if !matches!(entry.get(2..3), Some(" ")) {
458                continue;
459            }
460
461            let path_or_old_path = &entry[3..];
462
463            if path_or_old_path.ends_with('/') {
464                continue;
465            }
466
467            let status = match entry.as_bytes()[0..2].try_into() {
468                Ok(bytes) => match FileStatus::from_bytes(bytes).log_err() {
469                    Some(s) => s,
470                    None => continue,
471                },
472                Err(_) => continue,
473            };
474
475            let is_rename = matches!(
476                status,
477                FileStatus::Tracked(TrackedStatus {
478                    index_status: StatusCode::Renamed | StatusCode::Copied,
479                    ..
480                }) | FileStatus::Tracked(TrackedStatus {
481                    worktree_status: StatusCode::Renamed | StatusCode::Copied,
482                    ..
483                })
484            );
485
486            let (old_path_str, new_path_str) = if is_rename {
487                let new_path = match parts.next() {
488                    Some(new_path) if !new_path.is_empty() => new_path,
489                    _ => continue,
490                };
491                (path_or_old_path, new_path)
492            } else {
493                (path_or_old_path, path_or_old_path)
494            };
495
496            if new_path_str.ends_with('/') {
497                continue;
498            }
499
500            let new_path = match RelPath::unix(new_path_str).log_err() {
501                Some(p) => RepoPath::from_rel_path(p),
502                None => continue,
503            };
504
505            if is_rename {
506                if let Some(old_path_rel) = RelPath::unix(old_path_str).log_err() {
507                    let old_path_repo = RepoPath::from_rel_path(old_path_rel);
508                    renamed_paths.insert(new_path.clone(), old_path_repo);
509                }
510            }
511
512            entries.push((new_path, status));
513        }
514        entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
515        // When a file exists in HEAD, is deleted in the index, and exists again in the working copy,
516        // git produces two lines for it, one reading `D ` (deleted in index, unmodified in working copy)
517        // and the other reading `??` (untracked). Merge these two into the equivalent of `DA`.
518        entries.dedup_by(|(a, a_status), (b, b_status)| {
519            const INDEX_DELETED: FileStatus = FileStatus::index(StatusCode::Deleted);
520            if a.ne(&b) {
521                return false;
522            }
523            match (*a_status, *b_status) {
524                (INDEX_DELETED, FileStatus::Untracked) | (FileStatus::Untracked, INDEX_DELETED) => {
525                    *b_status = TrackedStatus {
526                        index_status: StatusCode::Deleted,
527                        worktree_status: StatusCode::Added,
528                    }
529                    .into();
530                }
531                _ => panic!("Unexpected duplicated status entries: {a_status:?} and {b_status:?}"),
532            }
533            true
534        });
535        Ok(Self {
536            entries: entries.into(),
537            renamed_paths,
538        })
539    }
540}
541
542impl Default for GitStatus {
543    fn default() -> Self {
544        Self {
545            entries: Arc::new([]),
546            renamed_paths: HashMap::default(),
547        }
548    }
549}
550
551pub enum DiffTreeType {
552    MergeBase {
553        base: SharedString,
554        head: SharedString,
555    },
556    Since {
557        base: SharedString,
558        head: SharedString,
559    },
560}
561
562impl DiffTreeType {
563    pub fn base(&self) -> &SharedString {
564        match self {
565            DiffTreeType::MergeBase { base, .. } => base,
566            DiffTreeType::Since { base, .. } => base,
567        }
568    }
569
570    pub fn head(&self) -> &SharedString {
571        match self {
572            DiffTreeType::MergeBase { head, .. } => head,
573            DiffTreeType::Since { head, .. } => head,
574        }
575    }
576}
577
578#[derive(Debug, PartialEq)]
579pub struct TreeDiff {
580    pub entries: HashMap<RepoPath, TreeDiffStatus>,
581}
582
583#[derive(Debug, Clone, PartialEq)]
584pub enum TreeDiffStatus {
585    Added,
586    Modified { old: Oid },
587    Deleted { old: Oid },
588}
589
590impl FromStr for TreeDiff {
591    type Err = anyhow::Error;
592
593    fn from_str(s: &str) -> Result<Self> {
594        let mut fields = s.split('\0');
595        let mut parsed = HashMap::default();
596        while let Some((status, path)) = fields.next().zip(fields.next()) {
597            let path = RepoPath::from_rel_path(RelPath::unix(path)?);
598
599            let mut fields = status.split(" ").skip(2);
600            let old_sha = fields
601                .next()
602                .ok_or_else(|| anyhow!("expected to find old_sha"))?
603                .to_owned()
604                .parse()?;
605            let _new_sha = fields
606                .next()
607                .ok_or_else(|| anyhow!("expected to find new_sha"))?;
608            let status = fields
609                .next()
610                .and_then(|s| {
611                    if s.len() == 1 {
612                        s.as_bytes().first()
613                    } else {
614                        None
615                    }
616                })
617                .ok_or_else(|| anyhow!("expected to find status"))?;
618
619            let result = match StatusCode::from_byte(*status)? {
620                StatusCode::Modified => TreeDiffStatus::Modified { old: old_sha },
621                StatusCode::Added => TreeDiffStatus::Added,
622                StatusCode::Deleted => TreeDiffStatus::Deleted { old: old_sha },
623                _status => continue,
624            };
625
626            parsed.insert(path, result);
627        }
628
629        Ok(Self { entries: parsed })
630    }
631}
632
633#[cfg(test)]
634mod tests {
635
636    use crate::{
637        repository::RepoPath,
638        status::{TreeDiff, TreeDiffStatus},
639    };
640
641    #[test]
642    fn test_tree_diff_parsing() {
643        let input = ":000000 100644 0000000000000000000000000000000000000000 0062c311b8727c3a2e3cd7a41bc9904feacf8f98 A\x00.zed/settings.json\x00".to_owned() +
644            ":100644 000000 bb3e9ed2e97a8c02545bae243264d342c069afb3 0000000000000000000000000000000000000000 D\x00README.md\x00" +
645            ":100644 100644 42f097005a1f21eb2260fad02ec8c991282beee8 a437d85f63bb8c62bd78f83f40c506631fabf005 M\x00parallel.go\x00";
646
647        let output: TreeDiff = input.parse().unwrap();
648        assert_eq!(
649            output,
650            TreeDiff {
651                entries: [
652                    (
653                        RepoPath::new(".zed/settings.json").unwrap(),
654                        TreeDiffStatus::Added,
655                    ),
656                    (
657                        RepoPath::new("README.md").unwrap(),
658                        TreeDiffStatus::Deleted {
659                            old: "bb3e9ed2e97a8c02545bae243264d342c069afb3".parse().unwrap()
660                        }
661                    ),
662                    (
663                        RepoPath::new("parallel.go").unwrap(),
664                        TreeDiffStatus::Modified {
665                            old: "42f097005a1f21eb2260fad02ec8c991282beee8".parse().unwrap(),
666                        }
667                    ),
668                ]
669                .into_iter()
670                .collect()
671            }
672        )
673    }
674}