majordomus_cli/environment/
vcs.rs1use std::path::Path;
15use std::process::Command;
16
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20pub const SOURCE: &str = "git status --porcelain=v2 --branch";
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26#[serde(tag = "state", rename_all = "snake_case")]
27pub enum VcsState {
28 Git(GitWorkingTree),
30 Unavailable {
32 reason: String,
34 },
35}
36
37impl VcsState {
38 pub fn tree(&self) -> Option<&GitWorkingTree> {
40 match self {
41 VcsState::Git(t) => Some(t),
42 VcsState::Unavailable { .. } => None,
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
49pub struct GitWorkingTree {
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub head: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub branch: Option<String>,
56 pub detached: bool,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub upstream: Option<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub ahead: Option<u32>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub behind: Option<u32>,
67 pub staged: u32,
69 pub modified: u32,
71 pub untracked: u32,
73 pub conflicted: u32,
75 pub clean: bool,
77 #[serde(default, skip_serializing_if = "Vec::is_empty")]
80 pub changed_paths: Vec<String>,
81}
82
83impl GitWorkingTree {
84 pub fn dirty_files(&self) -> u32 {
86 self.staged + self.modified + self.conflicted
87 }
88}
89
90pub fn inspect(root: &Path) -> VcsState {
99 let out = Command::new("git")
100 .arg("-C")
101 .arg(root)
102 .args([
105 "--no-optional-locks",
106 "status",
107 "--porcelain=v2",
108 "--branch",
109 "--untracked-files=normal",
110 "-z",
111 ])
112 .output();
113 let out = match out {
114 Ok(out) => out,
115 Err(e) => {
116 return VcsState::Unavailable {
117 reason: format!("cannot run git: {e}"),
118 }
119 }
120 };
121 if !out.status.success() {
122 return VcsState::Unavailable {
123 reason: String::from_utf8_lossy(&out.stderr).trim().to_string(),
124 };
125 }
126 match parse(&String::from_utf8_lossy(&out.stdout)) {
127 Some(tree) => VcsState::Git(tree),
128 None => VcsState::Unavailable {
129 reason: "git status --porcelain=v2 produced no branch header".into(),
130 },
131 }
132}
133
134pub fn parse(report: &str) -> Option<GitWorkingTree> {
161 let mut head = None;
162 let mut branch = None;
163 let mut detached = false;
164 let mut upstream = None;
165 let mut ahead = None;
166 let mut behind = None;
167 let (mut staged, mut modified, mut untracked, mut conflicted) = (0u32, 0u32, 0u32, 0u32);
168 let mut changed_paths: Vec<String> = Vec::new();
169 let mut saw_header = false;
170
171 let mut records = report.split('\0').filter(|r| !r.is_empty());
172 while let Some(record) = records.next() {
173 match record.as_bytes().first() {
174 Some(b'#') => {
175 saw_header = true;
176 let rest = record.strip_prefix("# ").unwrap_or(record);
177 let (key, value) = match rest.split_once(' ') {
178 Some(pair) => pair,
179 None => continue,
180 };
181 match key {
182 "branch.oid" if value != "(initial)" => head = Some(value.to_string()),
183 "branch.head" if value == "(detached)" => detached = true,
184 "branch.head" => branch = Some(value.to_string()),
185 "branch.upstream" => upstream = Some(value.to_string()),
186 "branch.ab" => {
187 let mut parts = value.split_whitespace();
188 ahead = parts.next().and_then(signed);
189 behind = parts.next().and_then(signed);
190 }
191 _ => {}
192 }
193 }
194 Some(b'1') => {
196 if let Some((x, y, path)) = ordinary(record, 8) {
197 if x != '.' {
198 staged += 1;
199 }
200 if y != '.' {
201 modified += 1;
202 }
203 changed_paths.push(path);
204 }
205 }
206 Some(b'2') => {
209 if let Some((x, y, path)) = ordinary(record, 9) {
210 if x != '.' {
211 staged += 1;
212 }
213 if y != '.' {
214 modified += 1;
215 }
216 changed_paths.push(path);
217 }
218 let _ = records.next();
220 }
221 Some(b'u') => {
222 conflicted += 1;
223 if let Some((_, _, path)) = ordinary(record, 10) {
224 changed_paths.push(path);
225 }
226 }
227 Some(b'?') => {
228 untracked += 1;
229 if let Some(path) = record.strip_prefix("? ") {
230 changed_paths.push(path.to_string());
231 }
232 }
233 _ => {}
236 }
237 }
238 if !saw_header {
239 return None;
240 }
241 changed_paths.sort();
242 changed_paths.dedup();
243 Some(GitWorkingTree {
244 head,
245 branch,
246 detached,
247 upstream,
248 ahead,
249 behind,
250 staged,
251 modified,
252 untracked,
253 conflicted,
254 clean: staged == 0 && modified == 0 && untracked == 0 && conflicted == 0,
255 changed_paths,
256 })
257}
258
259fn ordinary(record: &str, fields: usize) -> Option<(char, char, String)> {
261 let mut chars = record.split(' ').nth(1)?.chars();
262 let x = chars.next()?;
263 let y = chars.next()?;
264 let path = record.splitn(fields + 1, ' ').nth(fields)?;
265 Some((x, y, path.to_string()))
266}
267
268fn signed(text: &str) -> Option<u32> {
270 text.trim_start_matches(['+', '-']).parse().ok()
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn a_clean_checkout_is_clean() {
279 let tree = parse("# branch.oid abc\0# branch.head master\0").expect("a header");
280 assert!(tree.clean);
281 assert_eq!(tree.dirty_files(), 0);
282 assert_eq!(tree.branch.as_deref(), Some("master"));
283 assert_eq!(tree.ahead, None, "no upstream is not zero ahead");
284 }
285
286 #[test]
287 fn a_detached_head_has_no_branch_and_says_so() {
288 let tree = parse("# branch.oid abc\0# branch.head (detached)\0").expect("a header");
289 assert!(tree.detached);
290 assert_eq!(tree.branch, None);
291 }
292
293 #[test]
294 fn an_unborn_repository_has_no_head() {
295 let tree = parse("# branch.oid (initial)\0# branch.head master\0").expect("a header");
296 assert_eq!(tree.head, None);
297 assert_eq!(tree.branch.as_deref(), Some("master"));
298 }
299
300 #[test]
305 fn a_rename_consumes_its_original_path_record() {
306 let report = "# branch.oid abc\0# branch.head master\0\
307 2 R. N... 100644 100644 100644 aaa bbb R100 new.rs\0old.rs\0\
308 ? untracked.txt\0";
309 let tree = parse(report).expect("a header");
310 assert_eq!(tree.staged, 1);
311 assert_eq!(
312 tree.untracked, 1,
313 "the untracked entry after a rename is seen"
314 );
315 assert_eq!(tree.changed_paths, vec!["new.rs", "untracked.txt"]);
316 }
317
318 #[test]
319 fn a_path_with_spaces_survives_the_field_split() {
320 let report = "# branch.oid abc\0# branch.head master\0\
321 1 .M N... 100644 100644 100644 aaa bbb docs/a file.md\0";
322 let tree = parse(report).expect("a header");
323 assert_eq!(tree.changed_paths, vec!["docs/a file.md"]);
324 assert_eq!(tree.modified, 1);
325 }
326
327 #[test]
328 fn a_conflict_is_neither_staged_nor_modified() {
329 let report = "# branch.oid abc\0# branch.head master\0\
330 u UU N... 100644 100644 100644 100644 aaa bbb ccc src/x.rs\0";
331 let tree = parse(report).expect("a header");
332 assert_eq!(tree.conflicted, 1);
333 assert_eq!(tree.staged, 0);
334 assert!(!tree.clean);
335 }
336
337 #[test]
338 fn a_report_without_a_branch_header_is_not_a_report() {
339 assert!(parse("").is_none());
340 assert!(parse("? x\0").is_none());
341 }
342
343 #[test]
344 fn inspecting_a_directory_that_is_not_a_work_tree_says_why() {
345 let dir = tempfile::tempdir().expect("a temporary directory");
346 match inspect(dir.path()) {
347 VcsState::Unavailable { reason } => assert!(!reason.is_empty()),
348 VcsState::Git(_) => panic!("a bare temporary directory is not a work tree"),
349 }
350 }
351}