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 summary(self) -> GitSummary {
207 match self {
208 FileStatus::Ignored => GitSummary::UNCHANGED,
209 FileStatus::Untracked => GitSummary::UNTRACKED,
210 FileStatus::Unmerged(_) => GitSummary::CONFLICT,
211 FileStatus::Tracked(TrackedStatus {
212 index_status,
213 worktree_status,
214 }) => GitSummary {
215 index: index_status.to_summary(),
216 worktree: worktree_status.to_summary(),
217 conflict: 0,
218 untracked: 0,
219 count: 1,
220 },
221 }
222 }
223}
224
225impl StatusCode {
226 fn from_byte(byte: u8) -> anyhow::Result<Self> {
227 match byte {
228 b'M' => Ok(StatusCode::Modified),
229 b'T' => Ok(StatusCode::TypeChanged),
230 b'A' => Ok(StatusCode::Added),
231 b'D' => Ok(StatusCode::Deleted),
232 b'R' => Ok(StatusCode::Renamed),
233 b'C' => Ok(StatusCode::Copied),
234 b' ' => Ok(StatusCode::Unmodified),
235 _ => anyhow::bail!("Invalid status code: {byte}"),
236 }
237 }
238
239 fn to_summary(self) -> TrackedSummary {
240 match self {
241 StatusCode::Modified | StatusCode::TypeChanged => TrackedSummary {
242 modified: 1,
243 ..TrackedSummary::UNCHANGED
244 },
245 StatusCode::Added => TrackedSummary {
246 added: 1,
247 ..TrackedSummary::UNCHANGED
248 },
249 StatusCode::Deleted => TrackedSummary {
250 deleted: 1,
251 ..TrackedSummary::UNCHANGED
252 },
253 StatusCode::Renamed | StatusCode::Copied | StatusCode::Unmodified => {
254 TrackedSummary::UNCHANGED
255 }
256 }
257 }
258
259 pub fn index(self) -> FileStatus {
260 FileStatus::Tracked(TrackedStatus {
261 index_status: self,
262 worktree_status: StatusCode::Unmodified,
263 })
264 }
265
266 pub fn worktree(self) -> FileStatus {
267 FileStatus::Tracked(TrackedStatus {
268 index_status: StatusCode::Unmodified,
269 worktree_status: self,
270 })
271 }
272}
273
274impl UnmergedStatusCode {
275 fn from_byte(byte: u8) -> anyhow::Result<Self> {
276 match byte {
277 b'A' => Ok(UnmergedStatusCode::Added),
278 b'D' => Ok(UnmergedStatusCode::Deleted),
279 b'U' => Ok(UnmergedStatusCode::Updated),
280 _ => anyhow::bail!("Invalid unmerged status code: {byte}"),
281 }
282 }
283}
284
285#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
286pub struct TrackedSummary {
287 pub added: usize,
288 pub modified: usize,
289 pub deleted: usize,
290}
291
292impl TrackedSummary {
293 pub const UNCHANGED: Self = Self {
294 added: 0,
295 modified: 0,
296 deleted: 0,
297 };
298
299 pub const ADDED: Self = Self {
300 added: 1,
301 modified: 0,
302 deleted: 0,
303 };
304
305 pub const MODIFIED: Self = Self {
306 added: 0,
307 modified: 1,
308 deleted: 0,
309 };
310
311 pub const DELETED: Self = Self {
312 added: 0,
313 modified: 0,
314 deleted: 1,
315 };
316}
317
318impl std::ops::AddAssign for TrackedSummary {
319 fn add_assign(&mut self, rhs: Self) {
320 self.added += rhs.added;
321 self.modified += rhs.modified;
322 self.deleted += rhs.deleted;
323 }
324}
325
326impl std::ops::Add for TrackedSummary {
327 type Output = Self;
328
329 fn add(self, rhs: Self) -> Self::Output {
330 TrackedSummary {
331 added: self.added + rhs.added,
332 modified: self.modified + rhs.modified,
333 deleted: self.deleted + rhs.deleted,
334 }
335 }
336}
337
338impl std::ops::Sub for TrackedSummary {
339 type Output = Self;
340
341 fn sub(self, rhs: Self) -> Self::Output {
342 TrackedSummary {
343 added: self.added - rhs.added,
344 modified: self.modified - rhs.modified,
345 deleted: self.deleted - rhs.deleted,
346 }
347 }
348}
349
350#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
351pub struct GitSummary {
352 pub index: TrackedSummary,
353 pub worktree: TrackedSummary,
354 pub conflict: usize,
355 pub untracked: usize,
356 pub count: usize,
357}
358
359impl GitSummary {
360 pub const CONFLICT: Self = Self {
361 conflict: 1,
362 count: 1,
363 ..Self::UNCHANGED
364 };
365
366 pub const UNTRACKED: Self = Self {
367 untracked: 1,
368 count: 1,
369 ..Self::UNCHANGED
370 };
371
372 pub const UNCHANGED: Self = Self {
373 index: TrackedSummary::UNCHANGED,
374 worktree: TrackedSummary::UNCHANGED,
375 conflict: 0,
376 untracked: 0,
377 count: 0,
378 };
379}
380
381impl From<FileStatus> for GitSummary {
382 fn from(status: FileStatus) -> Self {
383 status.summary()
384 }
385}
386
387impl sum_tree::ContextLessSummary for GitSummary {
388 fn zero() -> Self {
389 Default::default()
390 }
391
392 fn add_summary(&mut self, rhs: &Self) {
393 *self += *rhs;
394 }
395}
396
397impl std::ops::Add<Self> for GitSummary {
398 type Output = Self;
399
400 fn add(mut self, rhs: Self) -> Self {
401 self += rhs;
402 self
403 }
404}
405
406impl std::ops::AddAssign for GitSummary {
407 fn add_assign(&mut self, rhs: Self) {
408 self.index += rhs.index;
409 self.worktree += rhs.worktree;
410 self.conflict += rhs.conflict;
411 self.untracked += rhs.untracked;
412 self.count += rhs.count;
413 }
414}
415
416impl std::ops::Sub for GitSummary {
417 type Output = GitSummary;
418
419 fn sub(self, rhs: Self) -> Self::Output {
420 GitSummary {
421 index: self.index - rhs.index,
422 worktree: self.worktree - rhs.worktree,
423 conflict: self.conflict - rhs.conflict,
424 untracked: self.untracked - rhs.untracked,
425 count: self.count - rhs.count,
426 }
427 }
428}
429
430#[derive(Clone, Debug)]
431pub struct GitStatus {
432 pub entries: Arc<[(RepoPath, FileStatus)]>,
433}
434
435impl FromStr for GitStatus {
436 type Err = anyhow::Error;
437
438 fn from_str(s: &str) -> Result<Self> {
439 let mut entries = s
440 .split('\0')
441 .filter_map(|entry| {
442 let sep = entry.get(2..3)?;
443 if sep != " " {
444 return None;
445 };
446 let path = &entry[3..];
447 // The git status output includes untracked directories as well as untracked files.
448 // We do our own processing to compute the "summary" status of each directory,
449 // so just skip any directories in the output, since they'll otherwise interfere
450 // with our handling of nested repositories.
451 if path.ends_with('/') {
452 return None;
453 }
454 let status = entry.as_bytes()[0..2].try_into().unwrap();
455 let status = FileStatus::from_bytes(status).log_err()?;
456 // git-status outputs `/`-delimited repo paths, even on Windows.
457 let path = RepoPath::from_rel_path(RelPath::unix(path).log_err()?);
458 Some((path, status))
459 })
460 .collect::<Vec<_>>();
461 entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
462 // When a file exists in HEAD, is deleted in the index, and exists again in the working copy,
463 // git produces two lines for it, one reading `D ` (deleted in index, unmodified in working copy)
464 // and the other reading `??` (untracked). Merge these two into the equivalent of `DA`.
465 entries.dedup_by(|(a, a_status), (b, b_status)| {
466 const INDEX_DELETED: FileStatus = FileStatus::index(StatusCode::Deleted);
467 if a.ne(&b) {
468 return false;
469 }
470 match (*a_status, *b_status) {
471 (INDEX_DELETED, FileStatus::Untracked) | (FileStatus::Untracked, INDEX_DELETED) => {
472 *b_status = TrackedStatus {
473 index_status: StatusCode::Deleted,
474 worktree_status: StatusCode::Added,
475 }
476 .into();
477 }
478 (x, y) if x == y => {}
479 _ => {
480 log::warn!(
481 "Unexpected duplicated status entries: {a_status:?} and {b_status:?}"
482 );
483 }
484 }
485 true
486 });
487 Ok(Self {
488 entries: entries.into(),
489 })
490 }
491}
492
493impl Default for GitStatus {
494 fn default() -> Self {
495 Self {
496 entries: Arc::new([]),
497 }
498 }
499}
500
501pub enum DiffTreeType {
502 MergeBase {
503 base: SharedString,
504 head: SharedString,
505 },
506 Since {
507 base: SharedString,
508 head: SharedString,
509 },
510}
511
512impl DiffTreeType {
513 pub fn base(&self) -> &SharedString {
514 match self {
515 DiffTreeType::MergeBase { base, .. } => base,
516 DiffTreeType::Since { base, .. } => base,
517 }
518 }
519
520 pub fn head(&self) -> &SharedString {
521 match self {
522 DiffTreeType::MergeBase { head, .. } => head,
523 DiffTreeType::Since { head, .. } => head,
524 }
525 }
526}
527
528#[derive(Debug, PartialEq)]
529pub struct TreeDiff {
530 pub entries: HashMap<RepoPath, TreeDiffStatus>,
531}
532
533#[derive(Debug, Clone, PartialEq)]
534pub enum TreeDiffStatus {
535 Added,
536 Modified { old: Oid },
537 Deleted { old: Oid },
538}
539
540impl FromStr for TreeDiff {
541 type Err = anyhow::Error;
542
543 fn from_str(s: &str) -> Result<Self> {
544 let mut fields = s.split('\0');
545 let mut parsed = HashMap::default();
546 while let Some((status, path)) = fields.next().zip(fields.next()) {
547 let path = RepoPath::from_rel_path(RelPath::unix(path)?);
548
549 let mut fields = status.split(" ").skip(2);
550 let old_sha = fields
551 .next()
552 .ok_or_else(|| anyhow!("expected to find old_sha"))?
553 .to_owned()
554 .parse()?;
555 let _new_sha = fields
556 .next()
557 .ok_or_else(|| anyhow!("expected to find new_sha"))?;
558 let status = fields
559 .next()
560 .and_then(|s| {
561 if s.len() == 1 {
562 s.as_bytes().first()
563 } else {
564 None
565 }
566 })
567 .ok_or_else(|| anyhow!("expected to find status"))?;
568
569 let result = match StatusCode::from_byte(*status)? {
570 StatusCode::Modified => TreeDiffStatus::Modified { old: old_sha },
571 StatusCode::Added => TreeDiffStatus::Added,
572 StatusCode::Deleted => TreeDiffStatus::Deleted { old: old_sha },
573 _status => continue,
574 };
575
576 parsed.insert(path, result);
577 }
578
579 Ok(Self { entries: parsed })
580 }
581}
582
583#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
584pub struct DiffStat {
585 pub added: u32,
586 pub deleted: u32,
587}
588
589/// Parses the output of `git diff --numstat` where output looks like:
590///
591/// ```text
592/// 24 12 dir/file.txt
593/// ```
594pub fn parse_numstat(output: &str) -> HashMap<RepoPath, DiffStat> {
595 let mut stats = HashMap::default();
596 for line in output.lines() {
597 let line = line.trim();
598 if line.is_empty() {
599 continue;
600 }
601 let mut parts = line.splitn(3, '\t');
602 let (Some(added_str), Some(deleted_str), Some(path_str)) =
603 (parts.next(), parts.next(), parts.next())
604 else {
605 continue;
606 };
607 let Ok(added) = added_str.parse::<u32>() else {
608 continue;
609 };
610 let Ok(deleted) = deleted_str.parse::<u32>() else {
611 continue;
612 };
613 let Ok(path) = RepoPath::new(path_str) else {
614 continue;
615 };
616 let stat = DiffStat { added, deleted };
617 stats.insert(path, stat);
618 }
619 stats
620}
621
622#[cfg(test)]
623mod tests {
624
625 use crate::{
626 repository::RepoPath,
627 status::{FileStatus, GitStatus, TreeDiff, TreeDiffStatus},
628 };
629
630 use super::{DiffStat, parse_numstat};
631
632 #[test]
633 fn test_parse_numstat_normal() {
634 let input = "10\t5\tsrc/main.rs\n3\t1\tREADME.md\n";
635 let result = parse_numstat(input);
636 assert_eq!(result.len(), 2);
637 assert_eq!(
638 result.get(&RepoPath::new("src/main.rs").unwrap()),
639 Some(&DiffStat {
640 added: 10,
641 deleted: 5
642 })
643 );
644 assert_eq!(
645 result.get(&RepoPath::new("README.md").unwrap()),
646 Some(&DiffStat {
647 added: 3,
648 deleted: 1
649 })
650 );
651 }
652
653 #[test]
654 fn test_parse_numstat_binary_files_skipped() {
655 // git diff --numstat outputs "-\t-\tpath" for binary files
656 let input = "-\t-\timage.png\n5\t2\tsrc/lib.rs\n";
657 let result = parse_numstat(input);
658 assert_eq!(result.len(), 1);
659 assert!(!result.contains_key(&RepoPath::new("image.png").unwrap()));
660 assert_eq!(
661 result.get(&RepoPath::new("src/lib.rs").unwrap()),
662 Some(&DiffStat {
663 added: 5,
664 deleted: 2
665 })
666 );
667 }
668
669 #[test]
670 fn test_parse_numstat_empty_input() {
671 assert!(parse_numstat("").is_empty());
672 assert!(parse_numstat("\n\n").is_empty());
673 assert!(parse_numstat(" \n \n").is_empty());
674 }
675
676 #[test]
677 fn test_parse_numstat_malformed_lines_skipped() {
678 let input = "not_a_number\t5\tfile.rs\n10\t5\tvalid.rs\n";
679 let result = parse_numstat(input);
680 assert_eq!(result.len(), 1);
681 assert_eq!(
682 result.get(&RepoPath::new("valid.rs").unwrap()),
683 Some(&DiffStat {
684 added: 10,
685 deleted: 5
686 })
687 );
688 }
689
690 #[test]
691 fn test_parse_numstat_incomplete_lines_skipped() {
692 // Lines with fewer than 3 tab-separated fields are skipped
693 let input = "10\t5\n7\t3\tok.rs\n";
694 let result = parse_numstat(input);
695 assert_eq!(result.len(), 1);
696 assert_eq!(
697 result.get(&RepoPath::new("ok.rs").unwrap()),
698 Some(&DiffStat {
699 added: 7,
700 deleted: 3
701 })
702 );
703 }
704
705 #[test]
706 fn test_parse_numstat_zero_stats() {
707 let input = "0\t0\tunchanged_but_present.rs\n";
708 let result = parse_numstat(input);
709 assert_eq!(
710 result.get(&RepoPath::new("unchanged_but_present.rs").unwrap()),
711 Some(&DiffStat {
712 added: 0,
713 deleted: 0
714 })
715 );
716 }
717
718 #[test]
719 fn test_duplicate_untracked_entries() {
720 // Regression test for ZED-2XA: git can produce duplicate untracked entries
721 // for the same path. This should deduplicate them instead of panicking.
722 let input = "?? file.txt\0?? file.txt";
723 let status: GitStatus = input.parse().unwrap();
724 assert_eq!(status.entries.len(), 1);
725 assert_eq!(status.entries[0].1, FileStatus::Untracked);
726 }
727
728 #[test]
729 fn test_tree_diff_parsing() {
730 let input = ":000000 100644 0000000000000000000000000000000000000000 0062c311b8727c3a2e3cd7a41bc9904feacf8f98 A\x00.zed/settings.json\x00".to_owned() +
731 ":100644 000000 bb3e9ed2e97a8c02545bae243264d342c069afb3 0000000000000000000000000000000000000000 D\x00README.md\x00" +
732 ":100644 100644 42f097005a1f21eb2260fad02ec8c991282beee8 a437d85f63bb8c62bd78f83f40c506631fabf005 M\x00parallel.go\x00";
733
734 let output: TreeDiff = input.parse().unwrap();
735 assert_eq!(
736 output,
737 TreeDiff {
738 entries: [
739 (
740 RepoPath::new(".zed/settings.json").unwrap(),
741 TreeDiffStatus::Added,
742 ),
743 (
744 RepoPath::new("README.md").unwrap(),
745 TreeDiffStatus::Deleted {
746 old: "bb3e9ed2e97a8c02545bae243264d342c069afb3".parse().unwrap()
747 }
748 ),
749 (
750 RepoPath::new("parallel.go").unwrap(),
751 TreeDiffStatus::Modified {
752 old: "42f097005a1f21eb2260fad02ec8c991282beee8".parse().unwrap(),
753 }
754 ),
755 ]
756 .into_iter()
757 .collect()
758 }
759 )
760 }
761}