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