Skip to main content

twine_core/git/
status.rs

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
122// Git may invoke filters or submodule processes. Terminate the entire isolated group
123// on timeout, output-limit failures, and shutdown before releasing the output file.
124struct StatusChild(Child);
125
126impl Drop for StatusChild {
127    fn drop(&mut self) {
128        if let Ok(pid) = i32::try_from(self.0.id()) {
129            // SAFETY: process_group(0) creates a separate group led by this child.
130            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            // Submodule content can be dirty even when the gitlink's XY columns are unchanged.
257            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            // A terminated orphan may briefly remain a zombie until the system reaps it.
538            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}