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