use std::path::Path;
use crate::error::Result;
use crate::git::cli::GitCli;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StatusEntry {
pub(crate) marker: char,
pub(crate) path: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct StatusSummary {
pub(crate) dirty: bool,
pub(crate) has_untracked: bool,
pub(crate) entries: Vec<StatusEntry>,
}
pub(crate) fn parse_status_porcelain(z: &str) -> StatusSummary {
let mut summary = StatusSummary::default();
let mut fields = z.split('\0');
while let Some(field) = fields.next() {
if field.len() < 3 {
continue;
}
let xy = &field[..2];
let path = &field[3..];
if xy == "??" {
summary.has_untracked = true;
summary.entries.push(StatusEntry {
marker: '?',
path: path.to_string(),
});
} else if xy == "!!" {
} else {
summary.dirty = true;
summary.entries.push(StatusEntry {
marker: 'M',
path: path.to_string(),
});
if xy.contains('R') || xy.contains('C') {
fields.next();
}
}
}
summary
}
pub(crate) fn status_of(git: &dyn GitCli, worktree_dir: &Path) -> Result<StatusSummary> {
let output = git.run(worktree_dir, &["status", "--porcelain=v1", "-z"])?;
Ok(parse_status_porcelain(&output))
}
#[cfg_attr(not(feature = "cli"), allow(dead_code))]
pub(crate) fn is_clean_for_removal(git: &dyn GitCli, worktree_dir: &Path) -> bool {
git.run(
worktree_dir,
&[
"status",
"--porcelain=v1",
"-z",
"--untracked-files=normal",
"--ignore-submodules=none",
],
)
.is_ok_and(|out| out.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::cli::RealGit;
use crate::testutil::TestRepo;
#[test]
fn clean_worktree_is_not_dirty() {
let s = parse_status_porcelain("");
assert!(!s.dirty);
assert!(!s.has_untracked);
assert!(s.entries.is_empty());
}
#[test]
fn parses_modified_staged_and_untracked() {
let z = " M src/a\0M src/b\0?? scratch\0";
let s = parse_status_porcelain(z);
assert!(s.dirty);
assert!(s.has_untracked);
assert_eq!(s.entries.len(), 3);
assert_eq!(
s.entries[0],
StatusEntry {
marker: 'M',
path: "src/a".into()
}
);
assert_eq!(
s.entries[2],
StatusEntry {
marker: '?',
path: "scratch".into()
}
);
}
#[test]
fn untracked_only_is_not_dirty() {
let s = parse_status_porcelain("?? new.txt\0");
assert!(!s.dirty);
assert!(s.has_untracked);
}
#[test]
fn ignored_entries_are_skipped() {
let s = parse_status_porcelain("!! target/\0");
assert!(!s.dirty);
assert!(!s.has_untracked);
assert!(s.entries.is_empty());
}
#[test]
fn rename_consumes_source_field() {
let z = "R new\0old\0?? u\0";
let s = parse_status_porcelain(z);
assert!(s.dirty);
assert!(s.has_untracked);
assert_eq!(s.entries.len(), 2);
assert_eq!(s.entries[0].path, "new");
assert_eq!(s.entries[1].path, "u");
}
#[test]
fn status_of_real_repo() {
let repo = TestRepo::init();
let s = status_of(&RealGit, repo.root()).unwrap();
assert!(!s.dirty && !s.has_untracked);
repo.write("README.md", "changed\n");
let s = status_of(&RealGit, repo.root()).unwrap();
assert!(s.dirty);
assert!(!s.has_untracked);
repo.write("scratch.txt", "x\n");
let s = status_of(&RealGit, repo.root()).unwrap();
assert!(s.dirty);
assert!(s.has_untracked);
}
#[test]
fn clean_for_removal_ignores_config_that_hides_untracked_files() {
let repo = TestRepo::init();
assert!(is_clean_for_removal(&RealGit, repo.root()));
repo.git(&["config", "status.showUntrackedFiles", "no"]);
repo.write("scratch.txt", "x\n");
assert!(!is_clean_for_removal(&RealGit, repo.root()));
repo.write(".gitignore", "scratch.txt\n");
repo.commit_all("ignore scratch");
assert!(is_clean_for_removal(&RealGit, repo.root()));
assert!(!is_clean_for_removal(
&RealGit,
&repo.root().join("no-such-dir")
));
}
}