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}