1use crate::repository::RepoPath;
2use anyhow::Result;
3use serde::{Deserialize, Serialize};
4use std::{str::FromStr, sync::Arc};
5use util::{ResultExt, rel_path::RelPath};
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
57#[derive(Debug, PartialEq, Eq, Clone, Copy)]
58pub enum StageStatus {
59 Staged,
60 Unstaged,
61 PartiallyStaged,
62}
63
64impl StageStatus {
65 pub const fn is_fully_staged(&self) -> bool {
66 matches!(self, StageStatus::Staged)
67 }
68
69 pub const fn is_fully_unstaged(&self) -> bool {
70 matches!(self, StageStatus::Unstaged)
71 }
72
73 pub const fn has_staged(&self) -> bool {
74 matches!(self, StageStatus::Staged | StageStatus::PartiallyStaged)
75 }
76
77 pub const fn has_unstaged(&self) -> bool {
78 matches!(self, StageStatus::Unstaged | StageStatus::PartiallyStaged)
79 }
80
81 pub const fn as_bool(self) -> Option<bool> {
82 match self {
83 StageStatus::Staged => Some(true),
84 StageStatus::Unstaged => Some(false),
85 StageStatus::PartiallyStaged => None,
86 }
87 }
88}
89
90impl FileStatus {
91 pub const fn worktree(worktree_status: StatusCode) -> Self {
92 FileStatus::Tracked(TrackedStatus {
93 index_status: StatusCode::Unmodified,
94 worktree_status,
95 })
96 }
97
98 pub const fn index(index_status: StatusCode) -> Self {
99 FileStatus::Tracked(TrackedStatus {
100 worktree_status: StatusCode::Unmodified,
101 index_status,
102 })
103 }
104
105 /// Generate a FileStatus Code from a byte pair, as described in
106 /// https://git-scm.com/docs/git-status#_output
107 ///
108 /// NOTE: That instead of '', we use ' ' to denote no change
109 fn from_bytes(bytes: [u8; 2]) -> anyhow::Result<Self> {
110 let status = match bytes {
111 [b'?', b'?'] => FileStatus::Untracked,
112 [b'!', b'!'] => FileStatus::Ignored,
113 [b'A', b'A'] => UnmergedStatus {
114 first_head: UnmergedStatusCode::Added,
115 second_head: UnmergedStatusCode::Added,
116 }
117 .into(),
118 [b'D', b'D'] => UnmergedStatus {
119 first_head: UnmergedStatusCode::Added,
120 second_head: UnmergedStatusCode::Added,
121 }
122 .into(),
123 [x, b'U'] => UnmergedStatus {
124 first_head: UnmergedStatusCode::from_byte(x)?,
125 second_head: UnmergedStatusCode::Updated,
126 }
127 .into(),
128 [b'U', y] => UnmergedStatus {
129 first_head: UnmergedStatusCode::Updated,
130 second_head: UnmergedStatusCode::from_byte(y)?,
131 }
132 .into(),
133 [x, y] => TrackedStatus {
134 index_status: StatusCode::from_byte(x)?,
135 worktree_status: StatusCode::from_byte(y)?,
136 }
137 .into(),
138 };
139 Ok(status)
140 }
141
142 pub const fn staging(self) -> StageStatus {
143 match self {
144 FileStatus::Untracked | FileStatus::Ignored | FileStatus::Unmerged { .. } => {
145 StageStatus::Unstaged
146 }
147 FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) {
148 (StatusCode::Unmodified, _) => StageStatus::Unstaged,
149 (_, StatusCode::Unmodified) => StageStatus::Staged,
150 _ => StageStatus::PartiallyStaged,
151 },
152 }
153 }
154
155 pub const fn is_conflicted(self) -> bool {
156 matches!(self, FileStatus::Unmerged { .. })
157 }
158
159 pub const fn is_ignored(self) -> bool {
160 matches!(self, FileStatus::Ignored)
161 }
162
163 pub const fn has_changes(&self) -> bool {
164 self.is_modified()
165 || self.is_created()
166 || self.is_deleted()
167 || self.is_untracked()
168 || self.is_conflicted()
169 }
170
171 pub const fn is_modified(self) -> bool {
172 match self {
173 FileStatus::Tracked(tracked) => matches!(
174 (tracked.index_status, tracked.worktree_status),
175 (StatusCode::Modified, _) | (_, StatusCode::Modified)
176 ),
177 _ => false,
178 }
179 }
180
181 pub const fn is_created(self) -> bool {
182 match self {
183 FileStatus::Tracked(tracked) => matches!(
184 (tracked.index_status, tracked.worktree_status),
185 (StatusCode::Added, _) | (_, StatusCode::Added)
186 ),
187 FileStatus::Untracked => true,
188 _ => false,
189 }
190 }
191
192 pub const fn is_deleted(self) -> bool {
193 matches!(self, FileStatus::Tracked(tracked) if matches!((tracked.index_status, tracked.worktree_status), (StatusCode::Deleted, _) | (_, StatusCode::Deleted)))
194 }
195
196 pub const fn is_untracked(self) -> bool {
197 matches!(self, FileStatus::Untracked)
198 }
199
200 pub const fn summary(self) -> GitSummary {
201 match self {
202 FileStatus::Ignored => GitSummary::UNCHANGED,
203 FileStatus::Untracked => GitSummary::UNTRACKED,
204 FileStatus::Unmerged(_) => GitSummary::CONFLICT,
205 FileStatus::Tracked(TrackedStatus {
206 index_status,
207 worktree_status,
208 }) => GitSummary {
209 index: index_status.to_summary(),
210 worktree: worktree_status.to_summary(),
211 conflict: 0,
212 untracked: 0,
213 count: 1,
214 },
215 }
216 }
217}
218
219impl StatusCode {
220 fn from_byte(byte: u8) -> anyhow::Result<Self> {
221 match byte {
222 b'M' => Ok(StatusCode::Modified),
223 b'T' => Ok(StatusCode::TypeChanged),
224 b'A' => Ok(StatusCode::Added),
225 b'D' => Ok(StatusCode::Deleted),
226 b'R' => Ok(StatusCode::Renamed),
227 b'C' => Ok(StatusCode::Copied),
228 b' ' => Ok(StatusCode::Unmodified),
229 _ => anyhow::bail!("Invalid status code: {byte}"),
230 }
231 }
232
233 const fn to_summary(self) -> TrackedSummary {
234 match self {
235 StatusCode::Modified | StatusCode::TypeChanged => TrackedSummary {
236 modified: 1,
237 ..TrackedSummary::UNCHANGED
238 },
239 StatusCode::Added => TrackedSummary {
240 added: 1,
241 ..TrackedSummary::UNCHANGED
242 },
243 StatusCode::Deleted => TrackedSummary {
244 deleted: 1,
245 ..TrackedSummary::UNCHANGED
246 },
247 StatusCode::Renamed | StatusCode::Copied | StatusCode::Unmodified => {
248 TrackedSummary::UNCHANGED
249 }
250 }
251 }
252
253 pub const fn index(self) -> FileStatus {
254 FileStatus::Tracked(TrackedStatus {
255 index_status: self,
256 worktree_status: StatusCode::Unmodified,
257 })
258 }
259
260 pub const fn worktree(self) -> FileStatus {
261 FileStatus::Tracked(TrackedStatus {
262 index_status: StatusCode::Unmodified,
263 worktree_status: self,
264 })
265 }
266}
267
268impl UnmergedStatusCode {
269 fn from_byte(byte: u8) -> anyhow::Result<Self> {
270 match byte {
271 b'A' => Ok(UnmergedStatusCode::Added),
272 b'D' => Ok(UnmergedStatusCode::Deleted),
273 b'U' => Ok(UnmergedStatusCode::Updated),
274 _ => anyhow::bail!("Invalid unmerged status code: {byte}"),
275 }
276 }
277}
278
279#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
280pub struct TrackedSummary {
281 pub added: usize,
282 pub modified: usize,
283 pub deleted: usize,
284}
285
286impl TrackedSummary {
287 pub const UNCHANGED: Self = Self {
288 added: 0,
289 modified: 0,
290 deleted: 0,
291 };
292
293 pub const ADDED: Self = Self {
294 added: 1,
295 modified: 0,
296 deleted: 0,
297 };
298
299 pub const MODIFIED: Self = Self {
300 added: 0,
301 modified: 1,
302 deleted: 0,
303 };
304
305 pub const DELETED: Self = Self {
306 added: 0,
307 modified: 0,
308 deleted: 1,
309 };
310}
311
312impl std::ops::AddAssign for TrackedSummary {
313 fn add_assign(&mut self, rhs: Self) {
314 self.added += rhs.added;
315 self.modified += rhs.modified;
316 self.deleted += rhs.deleted;
317 }
318}
319
320impl std::ops::Add for TrackedSummary {
321 type Output = Self;
322
323 fn add(self, rhs: Self) -> Self::Output {
324 TrackedSummary {
325 added: self.added + rhs.added,
326 modified: self.modified + rhs.modified,
327 deleted: self.deleted + rhs.deleted,
328 }
329 }
330}
331
332impl std::ops::Sub for TrackedSummary {
333 type Output = Self;
334
335 fn sub(self, rhs: Self) -> Self::Output {
336 TrackedSummary {
337 added: self.added - rhs.added,
338 modified: self.modified - rhs.modified,
339 deleted: self.deleted - rhs.deleted,
340 }
341 }
342}
343
344#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
345pub struct GitSummary {
346 pub index: TrackedSummary,
347 pub worktree: TrackedSummary,
348 pub conflict: usize,
349 pub untracked: usize,
350 pub count: usize,
351}
352
353impl GitSummary {
354 pub const CONFLICT: Self = Self {
355 conflict: 1,
356 count: 1,
357 ..Self::UNCHANGED
358 };
359
360 pub const UNTRACKED: Self = Self {
361 untracked: 1,
362 count: 1,
363 ..Self::UNCHANGED
364 };
365
366 pub const UNCHANGED: Self = Self {
367 index: TrackedSummary::UNCHANGED,
368 worktree: TrackedSummary::UNCHANGED,
369 conflict: 0,
370 untracked: 0,
371 count: 0,
372 };
373}
374
375impl From<FileStatus> for GitSummary {
376 fn from(status: FileStatus) -> Self {
377 status.summary()
378 }
379}
380
381impl sum_tree::ContextLessSummary for GitSummary {
382 fn zero() -> Self {
383 Default::default()
384 }
385
386 fn add_summary(&mut self, rhs: &Self) {
387 *self += *rhs;
388 }
389}
390
391impl std::ops::Add<Self> for GitSummary {
392 type Output = Self;
393
394 fn add(mut self, rhs: Self) -> Self {
395 self += rhs;
396 self
397 }
398}
399
400impl std::ops::AddAssign for GitSummary {
401 fn add_assign(&mut self, rhs: Self) {
402 self.index += rhs.index;
403 self.worktree += rhs.worktree;
404 self.conflict += rhs.conflict;
405 self.untracked += rhs.untracked;
406 self.count += rhs.count;
407 }
408}
409
410impl std::ops::Sub for GitSummary {
411 type Output = GitSummary;
412
413 fn sub(self, rhs: Self) -> Self::Output {
414 GitSummary {
415 index: self.index - rhs.index,
416 worktree: self.worktree - rhs.worktree,
417 conflict: self.conflict - rhs.conflict,
418 untracked: self.untracked - rhs.untracked,
419 count: self.count - rhs.count,
420 }
421 }
422}
423
424#[derive(Clone, Debug)]
425pub struct GitStatus {
426 pub entries: Arc<[(RepoPath, FileStatus)]>,
427}
428
429impl FromStr for GitStatus {
430 type Err = anyhow::Error;
431
432 fn from_str(s: &str) -> Result<Self> {
433 let mut entries = s
434 .split('\0')
435 .filter_map(|entry| {
436 let sep = entry.get(2..3)?;
437 if sep != " " {
438 return None;
439 };
440 let path = &entry[3..];
441 // The git status output includes untracked directories as well as untracked files.
442 // We do our own processing to compute the "summary" status of each directory,
443 // so just skip any directories in the output, since they'll otherwise interfere
444 // with our handling of nested repositories.
445 if path.ends_with('/') {
446 return None;
447 }
448 let status = entry.as_bytes()[0..2].try_into().unwrap();
449 let status = FileStatus::from_bytes(status).log_err()?;
450 // git-status outputs `/`-delimited repo paths, even on Windows.
451 let path = RepoPath(RelPath::unix(path).log_err()?.into());
452 Some((path, status))
453 })
454 .collect::<Vec<_>>();
455 entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
456 // When a file exists in HEAD, is deleted in the index, and exists again in the working copy,
457 // git produces two lines for it, one reading `D ` (deleted in index, unmodified in working copy)
458 // and the other reading `??` (untracked). Merge these two into the equivalent of `DA`.
459 entries.dedup_by(|(a, a_status), (b, b_status)| {
460 const INDEX_DELETED: FileStatus = FileStatus::index(StatusCode::Deleted);
461 if a.ne(&b) {
462 return false;
463 }
464 match (*a_status, *b_status) {
465 (INDEX_DELETED, FileStatus::Untracked) | (FileStatus::Untracked, INDEX_DELETED) => {
466 *b_status = TrackedStatus {
467 index_status: StatusCode::Deleted,
468 worktree_status: StatusCode::Added,
469 }
470 .into();
471 }
472 _ => panic!("Unexpected duplicated status entries: {a_status:?} and {b_status:?}"),
473 }
474 true
475 });
476 Ok(Self {
477 entries: entries.into(),
478 })
479 }
480}
481
482impl Default for GitStatus {
483 fn default() -> Self {
484 Self {
485 entries: Arc::new([]),
486 }
487 }
488}