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