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