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