1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
//! Whether a checkout holds uncommitted work, as git reports it (#8572).
//!
//! Why: `pm_guard` refuses an agent's HEAD-switching git command in a main
//! checkout that holds uncommitted work — the reported incident is a
//! `version-control` dispatch running `git checkout <branch>` over the
//! operator's unsaved edit. Only git knows whether the tree is dirty, and the
//! answer must come from [`git_command`], the crate's hardened git entry point:
//! it pins the config keys that can silence `git status --porcelain`
//! (`status.showUntrackedFiles=no`, a hostile `core.excludesFile`) and strips
//! the `GIT_DIR`/`GIT_WORK_TREE` redirects a hook inherits.
//!
//! What: [`has_uncommitted_changes`] runs `git status --porcelain` with the same
//! arguments the worktree-reclaim dirt check uses and reports whether it
//! printed anything — modified, staged, or untracked-but-not-ignored.
//!
//! The `Option` is the contract: `None` means the state is UNKNOWN, never that
//! the tree is clean. The guard refuses on `None` (the ADR-0045 distinction
//! between absent and undeterminable), so collapsing the two would let every
//! unreadable repository through.
//!
//! Test: `has_uncommitted_changes_is_false_for_a_clean_repository`,
//! `has_uncommitted_changes_reports_modified_and_untracked_files`,
//! `has_uncommitted_changes_is_none_for_a_corrupt_index`,
//! `has_uncommitted_changes_is_none_outside_a_repository`.
use std::path::Path;
use crate::session_manager::worktree_safety::{STATUS_ARGS, git_command};
/// Whether `dir`'s checkout holds uncommitted work.
///
/// What: `Some(true)` when git ran, exited 0, and listed at least one entry;
/// `Some(false)` when it ran, exited 0, and listed nothing; `None` when git
/// could not be spawned or exited non-zero (not a repository, a corrupt or
/// unreadable index). See the module doc for why `None` must stay distinct.
/// Test: as the module doc.
pub fn has_uncommitted_changes(dir: &Path) -> Option<bool> {
let out = git_command(dir, STATUS_ARGS).output().ok()?;
if !out.status.success() {
return None;
}
Some(out.stdout.iter().any(|b| !b.is_ascii_whitespace()))
}
#[cfg(test)]
mod tests {
use super::*;
/// Run git in `dir` with a fixed identity; `None` when git is unavailable.
fn git(dir: &Path, args: &[&str]) -> Option<()> {
std::process::Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@e")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@e")
.status()
.ok()?
.success()
.then_some(())
}
/// A real repository with one commit — the question is git's to answer, so
/// a fabricated `.git` directory would test nothing.
fn committed_repo() -> Option<tempfile::TempDir> {
let dir = tempfile::tempdir().expect("tempdir");
git(dir.path(), &["init", "-q", "."])?;
std::fs::write(dir.path().join("a.txt"), "a").expect("write");
git(dir.path(), &["add", "a.txt"])?;
git(dir.path(), &["commit", "-q", "-m", "init"])?;
Some(dir)
}
#[test]
fn has_uncommitted_changes_is_false_for_a_clean_repository() {
let Some(dir) = committed_repo() else { return };
assert_eq!(has_uncommitted_changes(dir.path()), Some(false));
}
#[test]
fn has_uncommitted_changes_reports_modified_and_untracked_files() {
let Some(dir) = committed_repo() else { return };
std::fs::write(dir.path().join("a.txt"), "edited").expect("write");
assert_eq!(has_uncommitted_changes(dir.path()), Some(true));
let Some(dir) = committed_repo() else { return };
std::fs::write(dir.path().join("new.md"), "draft").expect("write");
assert_eq!(has_uncommitted_changes(dir.path()), Some(true));
}
#[test]
fn has_uncommitted_changes_is_none_for_a_corrupt_index() {
// The fail-closed arm: git exits non-zero, and the answer must be
// "unknown", never "clean".
let Some(dir) = committed_repo() else { return };
std::fs::write(dir.path().join(".git/index"), "not an index").expect("write");
assert_eq!(has_uncommitted_changes(dir.path()), None);
}
#[test]
fn has_uncommitted_changes_is_none_outside_a_repository() {
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(has_uncommitted_changes(&dir.path().join("missing")), None);
}
}