#![cfg_attr(coverage_nightly, coverage(off))]
use std::path::Path;
use std::process::Command;
use std::sync::Mutex;
static PRE_RUN_STATUS: Mutex<Option<String>> = Mutex::new(None);
#[must_use = "the snapshot is cleared as soon as this guard is dropped"]
pub struct TreeSnapshot {
owns: bool,
}
impl Drop for TreeSnapshot {
fn drop(&mut self) {
if self.owns {
if let Ok(mut slot) = PRE_RUN_STATUS.lock() {
*slot = None;
}
}
}
}
pub fn snapshot_working_tree(project_path: &Path) -> TreeSnapshot {
let Ok(mut slot) = PRE_RUN_STATUS.lock() else {
return TreeSnapshot { owns: false };
};
if slot.is_some() {
return TreeSnapshot { owns: false };
}
*slot = read_porcelain_status(project_path);
TreeSnapshot {
owns: slot.is_some(),
}
}
pub(crate) fn pre_run_status() -> Option<String> {
PRE_RUN_STATUS.lock().ok().and_then(|slot| slot.clone())
}
pub(crate) fn read_porcelain_status(project_path: &Path) -> Option<String> {
let output = Command::new("git")
.args(["status", "--porcelain", "-b"])
.current_dir(project_path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub(crate) fn count_dirty_files(status: &str) -> usize {
status
.lines()
.skip(1)
.filter(|l| !l.is_empty() && !l.starts_with("??"))
.filter(|l| !super::pmat_owned_state::is_pmat_owned_state(l))
.count()
}
pub(crate) fn dirty_file_paths(status: &str) -> Vec<String> {
status
.lines()
.skip(1)
.filter(|l| !l.is_empty() && !l.starts_with("??"))
.filter(|l| !super::pmat_owned_state::is_pmat_owned_state(l))
.filter_map(|l| l.get(3..).map(display_path))
.collect()
}
fn display_path(field: &str) -> String {
let path = field.trim();
let path = path.rsplit(" -> ").next().unwrap_or(path).trim();
super::pmat_owned_state::unquote_porcelain_path(path).to_string()
}
pub(crate) fn has_upstream(status: &str) -> bool {
status
.lines()
.next()
.is_some_and(|header| header.contains("..."))
}
pub(crate) fn parse_ahead_count(status: &str) -> usize {
let Some(header) = status.lines().next() else {
return 0;
};
let Some(divergence) = header
.rsplit_once('[')
.map(|(_, tail)| tail)
.filter(|tail| tail.starts_with("ahead"))
else {
return 0;
};
divergence
.split_whitespace()
.nth(1)
.map(|n| n.trim_matches(|c: char| !c.is_ascii_digit()))
.and_then(|n| n.parse::<usize>().ok())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ahead_count_on_a_simple_lead() {
assert_eq!(
parse_ahead_count("## master...origin/master [ahead 3]\n"),
3
);
}
#[test]
fn ahead_count_on_a_diverged_branch() {
assert_eq!(
parse_ahead_count("## main...origin/main [ahead 1, behind 2]\n"),
1
);
assert_eq!(
parse_ahead_count("## main...origin/main [ahead 12, behind 40]\n"),
12
);
}
#[test]
fn ahead_count_is_zero_when_only_behind() {
assert_eq!(parse_ahead_count("## main...origin/main [behind 3]\n"), 0);
}
#[test]
fn ahead_count_is_zero_when_in_sync_or_headerless() {
assert_eq!(parse_ahead_count("## main...origin/main\n"), 0);
assert_eq!(parse_ahead_count(""), 0);
}
#[test]
fn ahead_count_is_read_from_the_bracket_not_the_branch_name() {
assert_eq!(
parse_ahead_count("## read-ahead...origin/read-ahead [ahead 1]\n"),
1
);
assert_eq!(
parse_ahead_count("## feat/lookahead...origin/feat/lookahead [ahead 4, behind 2]\n"),
4
);
assert_eq!(parse_ahead_count("## ahead...origin/ahead\n"), 0);
}
#[test]
fn dirty_paths_are_rendered_readably() {
let status = concat!(
"## main...origin/main\n",
"R src/old.rs -> src/new.rs\n",
" M \"src/we ird.rs\"\n",
);
assert_eq!(
dirty_file_paths(status),
vec!["src/new.rs", "src/we ird.rs"]
);
}
#[test]
fn dirty_count_excludes_untracked_and_pmat_state() {
let status = concat!(
"## master...origin/master\n",
"M .pmat-work/ledger.jsonl\n",
" M .pmat/context.db\n",
"A .pmat-metrics/commit-abc123-meta.json\n",
" M src/lib.rs\n",
"?? untracked.txt\n",
);
assert_eq!(count_dirty_files(status), 1);
}
#[test]
fn upstream_detected_from_the_header() {
assert!(has_upstream("## master...origin/master\n"));
assert!(has_upstream("## main...origin/main [ahead 1, behind 2]\n"));
}
#[test]
fn missing_upstream_detected() {
assert!(!has_upstream("## solo\n M src/lib.rs\n"));
assert!(!has_upstream("## No commits yet on master\n"));
assert!(!has_upstream(""));
}
#[test]
fn dirty_paths_are_reported_for_the_verdict() {
let status = concat!(
"## master...origin/master\n",
" M src/lib.rs\n",
"A src/new.rs\n",
" M .pmat/context.db\n",
"?? untracked.txt\n",
);
assert_eq!(dirty_file_paths(status), vec!["src/lib.rs", "src/new.rs"]);
}
#[test]
fn dirty_paths_agree_with_the_count() {
let status = concat!(
"## main...origin/main\n",
" M docs/roadmaps/roadmap.yaml\n",
" M CHANGELOG.md\n",
"M .pmat-work/ledger.jsonl\n",
);
assert_eq!(dirty_file_paths(status).len(), count_dirty_files(status));
}
#[test]
fn dirty_count_is_zero_on_a_clean_tree() {
assert_eq!(count_dirty_files("## master...origin/master\n"), 0);
assert_eq!(count_dirty_files(""), 0);
}
#[test]
fn dirty_count_sees_user_owned_roadmap_writes() {
let status = concat!(
"## main...origin/main\n",
" M docs/roadmaps/roadmap.yaml\n",
" M CHANGELOG.md\n",
);
assert_eq!(count_dirty_files(status), 2);
}
fn repo_with_one_commit() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
run_git(dir.path(), &["init", "-q"]);
run_git(dir.path(), &["config", "user.email", "t@example.com"]);
run_git(dir.path(), &["config", "user.name", "t"]);
std::fs::write(dir.path().join("tracked.txt"), "v1").unwrap();
run_git(dir.path(), &["add", "."]);
run_git(dir.path(), &["commit", "-qm", "init"]);
dir
}
#[test]
#[serial_test::serial(pmat_pre_run_tree)]
fn snapshot_is_taken_once_and_never_moves() {
let dir = repo_with_one_commit();
let _guard = snapshot_working_tree(dir.path());
let first = pre_run_status().expect("a git repo must record a baseline");
assert_eq!(
count_dirty_files(&first),
0,
"tree was clean when snapshotted"
);
std::fs::write(dir.path().join("tracked.txt"), "v2").unwrap();
let _nested = snapshot_working_tree(dir.path());
assert_eq!(
pre_run_status().unwrap(),
first,
"a write after the snapshot must not move the baseline"
);
assert_eq!(count_dirty_files(&pre_run_status().unwrap()), 0);
}
#[test]
#[serial_test::serial(pmat_pre_run_tree)]
fn snapshot_records_genuinely_dirty_user_work() {
let dir = repo_with_one_commit();
std::fs::write(dir.path().join("tracked.txt"), "uncommitted edit").unwrap();
let _guard = snapshot_working_tree(dir.path());
assert_eq!(count_dirty_files(&pre_run_status().unwrap()), 1);
}
#[test]
#[serial_test::serial(pmat_pre_run_tree)]
fn snapshot_is_cleared_when_the_command_ends() {
let dir = repo_with_one_commit();
{
let _guard = snapshot_working_tree(dir.path());
assert!(pre_run_status().is_some());
}
assert!(
pre_run_status().is_none(),
"the snapshot must not outlive the command that took it"
);
let other = repo_with_one_commit();
std::fs::write(other.path().join("tracked.txt"), "dirty").unwrap();
let _guard = snapshot_working_tree(other.path());
assert_eq!(count_dirty_files(&pre_run_status().unwrap()), 1);
}
#[test]
#[serial_test::serial(pmat_pre_run_tree)]
fn no_snapshot_outside_a_git_repo() {
let dir = tempfile::tempdir().unwrap();
let _guard = snapshot_working_tree(dir.path());
assert!(
pre_run_status().is_none(),
"a non-repo must not record an empty baseline"
);
}
fn run_git(dir: &Path, args: &[&str]) {
let ok = Command::new("git")
.args([
"-c",
"core.hooksPath=/dev/null",
"-c",
"commit.gpgsign=false",
])
.args(args)
.current_dir(dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
assert!(ok, "git {:?} failed", args);
}
}