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