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 fn is_fully_staged(&self) -> bool {
68 matches!(self, StageStatus::Staged)
69 }
70
71 pub fn is_fully_unstaged(&self) -> bool {
72 matches!(self, StageStatus::Unstaged)
73 }
74
75 pub fn has_staged(&self) -> bool {
76 matches!(self, StageStatus::Staged | StageStatus::PartiallyStaged)
77 }
78
79 pub fn has_unstaged(&self) -> bool {
80 matches!(self, StageStatus::Unstaged | StageStatus::PartiallyStaged)
81 }
82
83 pub 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(RelPath::unix(path).log_err()?.into());
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 _ => panic!("Unexpected duplicated status entries: {a_status:?} and {b_status:?}"),
479 }
480 true
481 });
482 Ok(Self {
483 entries: entries.into(),
484 })
485 }
486}
487
488impl Default for GitStatus {
489 fn default() -> Self {
490 Self {
491 entries: Arc::new([]),
492 }
493 }
494}
495
496pub enum DiffTreeType {
497 MergeBase {
498 base: SharedString,
499 head: SharedString,
500 },
501 Since {
502 base: SharedString,
503 head: SharedString,
504 },
505}
506
507impl DiffTreeType {
508 pub fn base(&self) -> &SharedString {
509 match self {
510 DiffTreeType::MergeBase { base, .. } => base,
511 DiffTreeType::Since { base, .. } => base,
512 }
513 }
514
515 pub fn head(&self) -> &SharedString {
516 match self {
517 DiffTreeType::MergeBase { head, .. } => head,
518 DiffTreeType::Since { head, .. } => head,
519 }
520 }
521}
522
523#[derive(Debug, PartialEq)]
524pub struct TreeDiff {
525 pub entries: HashMap<RepoPath, TreeDiffStatus>,
526}
527
528#[derive(Debug, Clone, PartialEq)]
529pub enum TreeDiffStatus {
530 Added,
531 Modified { old: Oid },
532 Deleted { old: Oid },
533}
534
535impl FromStr for TreeDiff {
536 type Err = anyhow::Error;
537
538 fn from_str(s: &str) -> Result<Self> {
539 let mut fields = s.split('\0');
540 let mut parsed = HashMap::default();
541 while let Some((status, path)) = fields.next().zip(fields.next()) {
542 let path = RepoPath(RelPath::unix(path)?.into());
543
544 let mut fields = status.split(" ").skip(2);
545 let old_sha = fields
546 .next()
547 .ok_or_else(|| anyhow!("expected to find old_sha"))?
548 .to_owned()
549 .parse()?;
550 let _new_sha = fields
551 .next()
552 .ok_or_else(|| anyhow!("expected to find new_sha"))?;
553 let status = fields
554 .next()
555 .and_then(|s| {
556 if s.len() == 1 {
557 s.as_bytes().first()
558 } else {
559 None
560 }
561 })
562 .ok_or_else(|| anyhow!("expected to find status"))?;
563
564 let result = match StatusCode::from_byte(*status)? {
565 StatusCode::Modified => TreeDiffStatus::Modified { old: old_sha },
566 StatusCode::Added => TreeDiffStatus::Added,
567 StatusCode::Deleted => TreeDiffStatus::Deleted { old: old_sha },
568 _status => continue,
569 };
570
571 parsed.insert(path, result);
572 }
573
574 Ok(Self { entries: parsed })
575 }
576}
577
578#[cfg(test)]
579mod tests {
580
581 use crate::{
582 repository::RepoPath,
583 status::{TreeDiff, TreeDiffStatus},
584 };
585
586 #[test]
587 fn test_tree_diff_parsing() {
588 let input = ":000000 100644 0000000000000000000000000000000000000000 0062c311b8727c3a2e3cd7a41bc9904feacf8f98 A\x00.zed/settings.json\x00".to_owned() +
589 ":100644 000000 bb3e9ed2e97a8c02545bae243264d342c069afb3 0000000000000000000000000000000000000000 D\x00README.md\x00" +
590 ":100644 100644 42f097005a1f21eb2260fad02ec8c991282beee8 a437d85f63bb8c62bd78f83f40c506631fabf005 M\x00parallel.go\x00";
591
592 let output: TreeDiff = input.parse().unwrap();
593 assert_eq!(
594 output,
595 TreeDiff {
596 entries: [
597 (
598 RepoPath::new(".zed/settings.json").unwrap(),
599 TreeDiffStatus::Added,
600 ),
601 (
602 RepoPath::new("README.md").unwrap(),
603 TreeDiffStatus::Deleted {
604 old: "bb3e9ed2e97a8c02545bae243264d342c069afb3".parse().unwrap()
605 }
606 ),
607 (
608 RepoPath::new("parallel.go").unwrap(),
609 TreeDiffStatus::Modified {
610 old: "42f097005a1f21eb2260fad02ec8c991282beee8".parse().unwrap(),
611 }
612 ),
613 ]
614 .into_iter()
615 .collect()
616 }
617 )
618 }
619}