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 }) => {
175 let mut summary = index_status.to_summary() + worktree_status.to_summary();
176 if summary != GitSummary::UNCHANGED {
177 summary.count = 1;
178 };
179 summary
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 /// Returns the contribution of this status code to the Git summary.
200 ///
201 /// Note that this does not include the count field, which must be set manually.
202 fn to_summary(self) -> GitSummary {
203 match self {
204 StatusCode::Modified | StatusCode::TypeChanged => GitSummary {
205 modified: 1,
206 ..GitSummary::UNCHANGED
207 },
208 StatusCode::Added => GitSummary {
209 added: 1,
210 ..GitSummary::UNCHANGED
211 },
212 StatusCode::Deleted => GitSummary {
213 deleted: 1,
214 ..GitSummary::UNCHANGED
215 },
216 StatusCode::Renamed | StatusCode::Copied | StatusCode::Unmodified => {
217 GitSummary::UNCHANGED
218 }
219 }
220 }
221}
222
223impl UnmergedStatusCode {
224 fn from_byte(byte: u8) -> anyhow::Result<Self> {
225 match byte {
226 b'A' => Ok(UnmergedStatusCode::Added),
227 b'D' => Ok(UnmergedStatusCode::Deleted),
228 b'U' => Ok(UnmergedStatusCode::Updated),
229 _ => Err(anyhow!("Invalid unmerged status code: {byte}")),
230 }
231 }
232}
233
234#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
235pub struct GitSummary {
236 pub added: usize,
237 pub modified: usize,
238 pub conflict: usize,
239 pub untracked: usize,
240 pub deleted: usize,
241 pub count: usize,
242}
243
244impl GitSummary {
245 pub const CONFLICT: Self = Self {
246 conflict: 1,
247 count: 1,
248 ..Self::UNCHANGED
249 };
250
251 pub const UNTRACKED: Self = Self {
252 untracked: 1,
253 count: 1,
254 ..Self::UNCHANGED
255 };
256
257 pub const UNCHANGED: Self = Self {
258 added: 0,
259 modified: 0,
260 conflict: 0,
261 untracked: 0,
262 deleted: 0,
263 count: 0,
264 };
265}
266
267impl From<FileStatus> for GitSummary {
268 fn from(status: FileStatus) -> Self {
269 status.summary()
270 }
271}
272
273impl sum_tree::Summary for GitSummary {
274 type Context = ();
275
276 fn zero(_: &Self::Context) -> Self {
277 Default::default()
278 }
279
280 fn add_summary(&mut self, rhs: &Self, _: &Self::Context) {
281 *self += *rhs;
282 }
283}
284
285impl std::ops::Add<Self> for GitSummary {
286 type Output = Self;
287
288 fn add(mut self, rhs: Self) -> Self {
289 self += rhs;
290 self
291 }
292}
293
294impl std::ops::AddAssign for GitSummary {
295 fn add_assign(&mut self, rhs: Self) {
296 self.added += rhs.added;
297 self.modified += rhs.modified;
298 self.conflict += rhs.conflict;
299 self.untracked += rhs.untracked;
300 self.deleted += rhs.deleted;
301 self.count += rhs.count;
302 }
303}
304
305impl std::ops::Sub for GitSummary {
306 type Output = GitSummary;
307
308 fn sub(self, rhs: Self) -> Self::Output {
309 GitSummary {
310 added: self.added - rhs.added,
311 modified: self.modified - rhs.modified,
312 conflict: self.conflict - rhs.conflict,
313 untracked: self.untracked - rhs.untracked,
314 deleted: self.deleted - rhs.deleted,
315 count: self.count - rhs.count,
316 }
317 }
318}
319
320#[derive(Clone)]
321pub struct GitStatus {
322 pub entries: Arc<[(RepoPath, FileStatus)]>,
323}
324
325impl GitStatus {
326 pub(crate) fn new(
327 git_binary: &Path,
328 working_directory: &Path,
329 path_prefixes: &[RepoPath],
330 ) -> Result<Self> {
331 let child = util::command::new_std_command(git_binary)
332 .current_dir(working_directory)
333 .args([
334 "--no-optional-locks",
335 "status",
336 "--porcelain=v1",
337 "--untracked-files=all",
338 "--no-renames",
339 "-z",
340 ])
341 .args(path_prefixes.iter().map(|path_prefix| {
342 if path_prefix.0.as_ref() == Path::new("") {
343 Path::new(".")
344 } else {
345 path_prefix
346 }
347 }))
348 .stdin(Stdio::null())
349 .stdout(Stdio::piped())
350 .stderr(Stdio::piped())
351 .spawn()
352 .map_err(|e| anyhow!("Failed to start git status process: {}", e))?;
353
354 let output = child
355 .wait_with_output()
356 .map_err(|e| anyhow!("Failed to read git blame output: {}", e))?;
357
358 if !output.status.success() {
359 let stderr = String::from_utf8_lossy(&output.stderr);
360 return Err(anyhow!("git status process failed: {}", stderr));
361 }
362 let stdout = String::from_utf8_lossy(&output.stdout);
363 let mut entries = stdout
364 .split('\0')
365 .filter_map(|entry| {
366 let sep = entry.get(2..3)?;
367 if sep != " " {
368 return None;
369 };
370 let path = &entry[3..];
371 let status = entry[0..2].as_bytes().try_into().unwrap();
372 let status = FileStatus::from_bytes(status).log_err()?;
373 let path = RepoPath(Path::new(path).into());
374 Some((path, status))
375 })
376 .collect::<Vec<_>>();
377 entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
378 Ok(Self {
379 entries: entries.into(),
380 })
381 }
382}
383
384impl Default for GitStatus {
385 fn default() -> Self {
386 Self {
387 entries: Arc::new([]),
388 }
389 }
390}