1use std::collections::BTreeMap;
2use std::io::{Read, Seek};
3use std::os::unix::process::CommandExt;
4use std::path::{Component, Path, PathBuf};
5use std::process::{Child, Command, Stdio};
6use std::time::{Duration, Instant};
7
8use thiserror::Error;
9
10#[derive(Clone, Debug, Default, Eq, PartialEq)]
11pub struct GitSnapshot {
12 pub repositories: Vec<GitRepository>,
13 pub scanning: bool,
14 pub error: Option<String>,
15}
16
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct GitRepository {
19 pub root: PathBuf,
20 pub changes: Vec<GitChange>,
21 pub error: Option<String>,
22 pub loading: bool,
23}
24
25impl GitRepository {
26 pub(crate) fn unavailable(root: PathBuf, message: String) -> Self {
27 Self {
28 root,
29 changes: Vec::new(),
30 error: Some(message),
31 loading: false,
32 }
33 }
34}
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37pub enum GitChangeKind {
38 Modified,
39 Added,
40 Deleted,
41 Renamed,
42 Copied,
43 TypeChanged,
44}
45
46impl GitChangeKind {
47 #[must_use]
48 pub fn symbol(self) -> &'static str {
49 match self {
50 Self::Modified => "M",
51 Self::Added => "A",
52 Self::Deleted => "D",
53 Self::Renamed => "R",
54 Self::Copied => "C",
55 Self::TypeChanged => "T",
56 }
57 }
58
59 fn parse(byte: u8) -> Result<Option<Self>, StatusError> {
60 Ok(match byte {
61 b'.' => None,
62 b'M' => Some(Self::Modified),
63 b'A' => Some(Self::Added),
64 b'D' => Some(Self::Deleted),
65 b'R' => Some(Self::Renamed),
66 b'C' => Some(Self::Copied),
67 b'T' => Some(Self::TypeChanged),
68 _ => return Err(StatusError::InvalidOutput),
69 })
70 }
71}
72
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct GitChange {
75 pub path: PathBuf,
76 pub original_path: Option<PathBuf>,
77 pub staged: Option<GitChangeKind>,
78 pub unstaged: Option<GitChangeKind>,
79 pub conflicted: bool,
80 pub untracked: bool,
81}
82
83#[derive(Clone, Debug, Default, Eq, PartialEq)]
84pub struct GitDecoration {
85 pub change: Option<GitChange>,
86 pub contains_changes: bool,
87 pub contains_conflicts: bool,
88 pub deleted: bool,
89}
90
91#[derive(Debug, Error)]
92pub(crate) enum StatusError {
93 #[error("Could not read Git status.")]
94 Read(#[from] std::io::Error),
95 #[error("Git status timed out. It will retry automatically.")]
96 Timeout,
97 #[error("Git status exceeds the supported size. Status is unavailable.")]
98 TooLarge,
99 #[error("Git could not read this repository. Check its permissions and configuration.")]
100 Failed,
101 #[error("Git returned an unsupported status or filename.")]
102 InvalidOutput,
103}
104
105pub(crate) fn read(root: &Path) -> Result<GitRepository, StatusError> {
106 read_command(root, Command::new("git"))
107}
108
109fn read_command(root: &Path, mut command: Command) -> Result<GitRepository, StatusError> {
110 command
111 .args(["status", "--porcelain=v2", "-z"])
112 .env("GIT_OPTIONAL_LOCKS", "0")
113 .env_remove("GIT_DIR")
114 .env_remove("GIT_WORK_TREE")
115 .env_remove("GIT_COMMON_DIR")
116 .env_remove("GIT_INDEX_FILE")
117 .env_remove("GIT_OBJECT_DIRECTORY")
118 .env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES");
119 parse(root, &run(root, command, Duration::from_secs(2))?)
120}
121
122struct StatusChild(Child);
125
126impl Drop for StatusChild {
127 fn drop(&mut self) {
128 if let Ok(pid) = i32::try_from(self.0.id()) {
129 unsafe {
131 libc::kill(-pid, libc::SIGKILL);
132 }
133 }
134 let _ = self.0.kill();
135 let _ = self.0.wait();
136 }
137}
138
139fn run(root: &Path, mut command: Command, timeout: Duration) -> Result<Vec<u8>, StatusError> {
140 const LIMIT: u64 = 8 * 1024 * 1024;
141 let mut output = tempfile::tempfile()?;
142 let mut child = StatusChild(
143 command
144 .process_group(0)
145 .current_dir(root)
146 .stdin(Stdio::null())
147 .stdout(output.try_clone()?)
148 .stderr(Stdio::null())
149 .spawn()?,
150 );
151 let deadline = Instant::now() + timeout;
152 loop {
153 if output.metadata()?.len() > LIMIT {
154 return Err(StatusError::TooLarge);
155 }
156 if let Some(status) = child.0.try_wait()? {
157 if !status.success() {
158 return Err(StatusError::Failed);
159 }
160 break;
161 }
162 if Instant::now() >= deadline {
163 return Err(StatusError::Timeout);
164 }
165 std::thread::sleep(Duration::from_millis(10));
166 }
167 output.rewind()?;
168 let mut bytes = Vec::new();
169 output.take(LIMIT + 1).read_to_end(&mut bytes)?;
170 if bytes.len() as u64 > LIMIT {
171 return Err(StatusError::TooLarge);
172 }
173 Ok(bytes)
174}
175
176fn path(root: &Path, value: &str) -> Result<PathBuf, StatusError> {
177 let relative = Path::new(value);
178 if value.is_empty()
179 || relative
180 .components()
181 .any(|part| !matches!(part, Component::Normal(_)))
182 {
183 return Err(StatusError::InvalidOutput);
184 }
185 Ok(root.join(relative))
186}
187
188fn parse(root: &Path, bytes: &[u8]) -> Result<GitRepository, StatusError> {
189 let text = std::str::from_utf8(bytes).map_err(|_| StatusError::InvalidOutput)?;
190 let mut records = text.split_terminator('\0');
191 let mut changes = BTreeMap::<PathBuf, GitChange>::new();
192 for_record(&mut records, root, &mut changes)?;
193 Ok(GitRepository {
194 root: root.to_owned(),
195 changes: changes.into_values().collect(),
196 error: None,
197 loading: false,
198 })
199}
200
201fn for_record<'a>(
202 records: &mut impl Iterator<Item = &'a str>,
203 root: &Path,
204 changes: &mut BTreeMap<PathBuf, GitChange>,
205) -> Result<(), StatusError> {
206 while let Some(record) = records.next() {
207 if record.starts_with('#') {
208 continue;
209 }
210 let tag = record
211 .as_bytes()
212 .first()
213 .copied()
214 .ok_or(StatusError::InvalidOutput)?;
215 let fields: Vec<_> = record
216 .splitn(
217 match tag {
218 b'1' => 9,
219 b'2' => 10,
220 b'u' => 11,
221 b'?' => 2,
222 _ => return Err(StatusError::InvalidOutput),
223 },
224 ' ',
225 )
226 .collect();
227 let expected = match tag {
228 b'1' => 9,
229 b'2' => 10,
230 b'u' => 11,
231 _ => 2,
232 };
233 if fields.len() != expected {
234 return Err(StatusError::InvalidOutput);
235 }
236 let file = path(root, fields[expected - 1])?;
237 let change = changes.entry(file.clone()).or_insert(GitChange {
238 path: file,
239 original_path: None,
240 staged: None,
241 unstaged: None,
242 conflicted: false,
243 untracked: false,
244 });
245 if tag == b'?' {
246 change.untracked = true;
247 } else if tag == b'u' {
248 change.conflicted = true;
249 } else {
250 let xy = fields[1].as_bytes();
251 if xy.len() != 2 {
252 return Err(StatusError::InvalidOutput);
253 }
254 change.staged = GitChangeKind::parse(xy[0])?;
255 change.unstaged = GitChangeKind::parse(xy[1])?;
256 if fields[2].starts_with('S') && fields[2].as_bytes().iter().skip(1).any(|b| *b != b'.')
258 {
259 change.unstaged.get_or_insert(GitChangeKind::Modified);
260 }
261 }
262 if tag == b'2' {
263 change.original_path = Some(path(
264 root,
265 records.next().ok_or(StatusError::InvalidOutput)?,
266 )?);
267 }
268 if changes.len() > 50_000 {
269 return Err(StatusError::TooLarge);
270 }
271 }
272 Ok(())
273}
274
275#[cfg(test)]
276pub(crate) mod tests {
277 use super::*;
278 use std::fs;
279
280 pub(crate) fn git(root: &Path, args: &[&str]) {
281 let output = Command::new("git")
282 .args([
283 "-c",
284 "user.name=Twine Test",
285 "-c",
286 "user.email=test@example.invalid",
287 "-c",
288 "commit.gpgsign=false",
289 ])
290 .args(args)
291 .current_dir(root)
292 .output()
293 .unwrap();
294 assert!(
295 output.status.success(),
296 "{}",
297 String::from_utf8_lossy(&output.stderr)
298 );
299 }
300
301 pub(crate) fn init(root: &Path) {
302 fs::create_dir_all(root).unwrap();
303 git(root, &["init", "-b", "main"]);
304 fs::write(root.join("README.md"), "initial\n").unwrap();
305 git(root, &["add", "."]);
306 git(root, &["commit", "-m", "initial"]);
307 }
308
309 #[test]
310 fn reads_both_columns_renames_deletions_untracked_and_preserves_index() {
311 let temp = tempfile::tempdir().unwrap();
312 let root = temp.path();
313 init(root);
314 fs::write(root.join("README.md"), "staged\n").unwrap();
315 git(root, &["add", "README.md"]);
316 fs::write(root.join("README.md"), "unstaged\n").unwrap();
317 fs::write(root.join("new 🌲\nfile.txt"), "new").unwrap();
318 fs::write(root.join(".gitignore"), "ignored\n").unwrap();
319 fs::write(root.join("ignored"), "ignored").unwrap();
320 let index = fs::read(root.join(".git/index")).unwrap();
321 let snapshot = read(root).unwrap();
322 let change = snapshot
323 .changes
324 .iter()
325 .find(|c| c.path.ends_with("README.md"))
326 .unwrap();
327 assert_eq!(change.staged, Some(GitChangeKind::Modified));
328 assert_eq!(change.unstaged, Some(GitChangeKind::Modified));
329 assert!(
330 snapshot
331 .changes
332 .iter()
333 .any(|c| c.path.ends_with("new 🌲\nfile.txt") && c.untracked)
334 );
335 assert!(!snapshot.changes.iter().any(|c| c.path.ends_with("ignored")));
336 assert_eq!(fs::read(root.join(".git/index")).unwrap(), index);
337 git(root, &["reset", "--hard", "HEAD"]);
338 git(root, &["mv", "README.md", "renamed 🌲.md"]);
339 let snapshot = read(root).unwrap();
340 let rename = snapshot
341 .changes
342 .iter()
343 .find(|c| c.path.ends_with("renamed 🌲.md"))
344 .unwrap();
345 assert_eq!(rename.staged, Some(GitChangeKind::Renamed));
346 assert_eq!(
347 rename.original_path.as_deref(),
348 Some(root.join("README.md").as_path())
349 );
350 fs::remove_file(root.join("renamed 🌲.md")).unwrap();
351 assert_eq!(
352 read(root)
353 .unwrap()
354 .changes
355 .iter()
356 .find(|c| c.path.ends_with("renamed 🌲.md"))
357 .unwrap()
358 .unstaged,
359 Some(GitChangeKind::Deleted)
360 );
361 }
362
363 #[test]
364 fn ignores_inherited_repository_storage_but_preserves_configuration() {
365 let temp = tempfile::tempdir().unwrap();
366 let root = temp.path();
367 init(root);
368 let alternate = root.join(".git/alternate-index");
369 fs::copy(root.join(".git/index"), &alternate).unwrap();
370 fs::write(root.join("README.md"), "changed\n").unwrap();
371 fs::write(root.join("new.txt"), "untracked\n").unwrap();
372 let output = Command::new("git")
373 .env("GIT_INDEX_FILE", &alternate)
374 .args(["add", "README.md"])
375 .current_dir(root)
376 .output()
377 .unwrap();
378 assert!(output.status.success());
379 let index = fs::read(root.join(".git/index")).unwrap();
380 let mut command = Command::new("git");
381 command
382 .env("GIT_DIR", root.join("missing"))
383 .env("GIT_WORK_TREE", root.join("missing"))
384 .env("GIT_COMMON_DIR", root.join("missing"))
385 .env("GIT_INDEX_FILE", &alternate)
386 .env("GIT_OBJECT_DIRECTORY", root.join("missing"))
387 .env("GIT_ALTERNATE_OBJECT_DIRECTORIES", root.join("missing"))
388 .env("GIT_CONFIG_COUNT", "1")
389 .env("GIT_CONFIG_KEY_0", "status.showUntrackedFiles")
390 .env("GIT_CONFIG_VALUE_0", "no");
391 let snapshot = read_command(root, command).unwrap();
392 assert_eq!(snapshot.changes.len(), 1);
393 assert_eq!(snapshot.changes[0].path, root.join("README.md"));
394 assert_eq!(snapshot.changes[0].staged, None);
395 assert_eq!(snapshot.changes[0].unstaged, Some(GitChangeKind::Modified));
396 assert_eq!(fs::read(root.join(".git/index")).unwrap(), index);
397 }
398
399 #[test]
400 fn follows_repository_rename_and_untracked_settings() {
401 let temp = tempfile::tempdir().unwrap();
402 let root = temp.path();
403 init(root);
404 git(root, &["mv", "README.md", "renamed.md"]);
405 git(root, &["config", "status.renames", "true"]);
406 assert_eq!(
407 read(root).unwrap().changes[0].staged,
408 Some(GitChangeKind::Renamed)
409 );
410 git(root, &["config", "status.renames", "false"]);
411 let snapshot = read(root).unwrap();
412 assert_eq!(snapshot.changes.len(), 2);
413 assert!(
414 snapshot
415 .changes
416 .iter()
417 .any(|c| c.staged == Some(GitChangeKind::Added))
418 );
419 assert!(
420 snapshot
421 .changes
422 .iter()
423 .any(|c| c.staged == Some(GitChangeKind::Deleted))
424 );
425 git(root, &["reset", "--hard", "HEAD"]);
426 fs::create_dir(root.join("new-directory")).unwrap();
427 fs::write(root.join("new-directory/file.txt"), "new\n").unwrap();
428 git(root, &["config", "status.showUntrackedFiles", "no"]);
429 assert_eq!(read(root).unwrap().changes, []);
430 git(root, &["config", "status.showUntrackedFiles", "normal"]);
431 assert_eq!(
432 read(root).unwrap().changes[0].path,
433 root.join("new-directory")
434 );
435 git(root, &["config", "status.showUntrackedFiles", "all"]);
436 assert_eq!(
437 read(root).unwrap().changes[0].path,
438 root.join("new-directory/file.txt")
439 );
440 }
441
442 #[test]
443 fn follows_repository_submodule_ignore_settings() {
444 let temp = tempfile::tempdir().unwrap();
445 let source = tempfile::tempdir().unwrap();
446 let root = temp.path();
447 init(root);
448 init(source.path());
449 git(
450 root,
451 &[
452 "-c",
453 "protocol.file.allow=always",
454 "submodule",
455 "add",
456 source.path().to_str().unwrap(),
457 "module",
458 ],
459 );
460 git(root, &["commit", "-am", "add module"]);
461 fs::write(root.join("module/README.md"), "dirty\n").unwrap();
462 git(root, &["config", "submodule.module.ignore", "none"]);
463 assert_eq!(read(root).unwrap().changes[0].path, root.join("module"));
464 git(root, &["config", "submodule.module.ignore", "all"]);
465 assert_eq!(read(root).unwrap().changes, []);
466 }
467
468 #[test]
469 fn reads_changes_in_unborn_detached_and_conflicted_checkouts() {
470 let temp = tempfile::tempdir().unwrap();
471 let root = temp.path();
472 git(root, &["init", "-b", "main"]);
473 assert_eq!(read(root).unwrap().changes, []);
474 fs::write(root.join("file"), "base\n").unwrap();
475 git(root, &["add", "."]);
476 git(root, &["commit", "-m", "base"]);
477 git(root, &["branch", "upstream"]);
478 fs::write(root.join("file"), "main\n").unwrap();
479 git(root, &["commit", "-am", "main"]);
480 let clean = read(root).unwrap();
481 assert_eq!(clean.changes, []);
482 git(root, &["checkout", "--detach"]);
483 assert_eq!(read(root).unwrap().changes, []);
484 git(root, &["checkout", "upstream"]);
485 fs::write(root.join("file"), "other\n").unwrap();
486 git(root, &["commit", "-am", "other"]);
487 git(root, &["checkout", "main"]);
488 let result = Command::new("git")
489 .args([
490 "-c",
491 "user.name=Test",
492 "-c",
493 "user.email=test@example.invalid",
494 "merge",
495 "upstream",
496 ])
497 .current_dir(root)
498 .output()
499 .unwrap();
500 assert!(!result.status.success());
501 let conflict = read(root).unwrap();
502 assert!(conflict.changes.iter().any(|c| c.conflicted));
503 }
504
505 #[test]
506 fn invalid_output_and_stalled_commands_are_unavailable() {
507 let root = tempfile::tempdir().unwrap();
508 assert!(parse(root.path(), b"? ../escape\0").is_err());
509 assert!(parse(root.path(), b"2 R. N... 1 1 1 a b R100 dest\0").is_err());
510 assert!(parse(root.path(), b"? invalid\xff\0").is_err());
511 let mut command = Command::new("/bin/sleep");
512 command.arg("10");
513 assert!(matches!(
514 run(root.path(), command, Duration::from_millis(30)),
515 Err(StatusError::Timeout)
516 ));
517 assert!(read(root.path()).is_err());
518 }
519
520 #[test]
521 fn timeout_terminates_descendants_as_well_as_the_git_process() {
522 let root = tempfile::tempdir().unwrap();
523 let mut command = Command::new("/bin/sh");
524 command.args(["-c", "/bin/sleep 30 & echo $! > child.pid; wait"]);
525 assert!(matches!(
526 run(root.path(), command, Duration::from_millis(100)),
527 Err(StatusError::Timeout)
528 ));
529 let pid = fs::read_to_string(root.path().join("child.pid")).unwrap();
530 let deadline = Instant::now() + Duration::from_secs(3);
531 loop {
532 let output = Command::new("ps")
533 .args(["-o", "stat=", "-p", pid.trim()])
534 .output()
535 .unwrap();
536 let state = String::from_utf8_lossy(&output.stdout);
537 if state.trim().is_empty() || state.trim().starts_with('Z') {
539 break;
540 }
541 assert!(
542 Instant::now() < deadline,
543 "descendant still running: {state}"
544 );
545 std::thread::sleep(Duration::from_millis(10));
546 }
547 }
548}