use crate::{manifest::Manifest, paths, proc};
use anyhow::Result;
#[derive(Debug, Default, PartialEq, Eq)]
pub struct GitState {
pub branch: Option<String>,
pub detached: bool,
pub upstream: Option<String>,
pub ahead: u32,
pub behind: u32,
pub dirty: u32,
}
impl GitState {
pub fn is_clean(&self) -> bool {
self.dirty == 0
}
}
pub fn parse_porcelain_v2(out: &str) -> GitState {
let mut st = GitState::default();
for line in out.lines() {
if let Some(rest) = line.strip_prefix("# branch.head ") {
let head = rest.trim();
if head == "(detached)" {
st.detached = true;
} else {
st.branch = Some(head.to_string());
}
} else if let Some(rest) = line.strip_prefix("# branch.upstream ") {
st.upstream = Some(rest.trim().to_string());
} else if let Some(rest) = line.strip_prefix("# branch.ab ") {
for tok in rest.split_whitespace() {
if let Some(n) = tok.strip_prefix('+') {
st.ahead = n.parse().unwrap_or(0);
} else if let Some(n) = tok.strip_prefix('-') {
st.behind = n.parse().unwrap_or(0);
}
}
} else if line.starts_with('#') || line.trim().is_empty() {
} else {
st.dirty += 1;
}
}
st
}
pub fn summary(st: &GitState, trunk: &str) -> String {
let mut parts = Vec::new();
if st.detached {
parts.push("detached HEAD (not on a branch)".to_string());
} else if let Some(b) = &st.branch {
if b == trunk {
parts.push(format!("on {b}"));
} else {
parts.push(format!("on {b} (NOT {trunk})"));
}
} else {
parts.push("branch unknown".to_string());
}
parts.push(if st.is_clean() {
"clean".to_string()
} else {
format!("{} uncommitted change(s)", st.dirty)
});
match &st.upstream {
Some(up) if st.ahead > 0 || st.behind > 0 => {
let mut rel = Vec::new();
if st.ahead > 0 {
rel.push(format!("{} ahead", st.ahead));
}
if st.behind > 0 {
rel.push(format!("{} behind", st.behind));
}
parts.push(format!("{} vs {up}", rel.join(", ")));
}
Some(up) => parts.push(format!("up to date with {up}")),
None => parts.push("no upstream tracked".to_string()),
}
parts.join(" · ")
}
fn git_state(m: &Manifest) -> Result<Option<GitState>> {
let container = m.sandbox_container();
let (st, _) = proc::run_capture(
"docker",
&["exec", &container, "git", "rev-parse", "--git-dir"],
)?;
if !st.success() {
return Ok(None);
}
let (_, out) = proc::run_capture(
"docker",
&[
"exec",
&container,
"git",
"status",
"--porcelain=v2",
"--branch",
],
)?;
Ok(Some(parse_porcelain_v2(&out)))
}
pub fn run(explicit: Option<String>) -> Result<()> {
let name = super::resolve_project_announced(explicit)?;
let m = Manifest::load(&paths::manifest_path(&name)?)?;
if super::running_projects(std::slice::from_ref(&name))?.is_empty() {
anyhow::bail!(
"{name} isn't running, so its git state can't be read.\n\n Next: moor up {name}"
);
}
let state = git_state(&m)?;
match state {
None => {
println!("{name}: the workspace is not a git repository yet.");
}
Some(st) => {
let trunk = st
.upstream
.as_deref()
.and_then(|u| u.rsplit('/').next())
.unwrap_or("master")
.to_string();
println!("{name}: {}", summary(&st, &trunk));
if st.detached {
println!(" ! HEAD is detached — commits here aren't on any branch.");
} else if st.branch.as_deref() != Some(trunk.as_str()) {
println!(
" ! not on {trunk} — a build here commits onto this branch, not {trunk}."
);
}
if !st.is_clean() {
println!(
" ! {} uncommitted change(s) — a build starts on top of them.",
st.dirty
);
}
if st.behind > 0 {
println!(
" ! {} commit(s) behind {} — refresh first: moor pull -p {name}",
st.behind,
st.upstream.as_deref().unwrap_or("the remote")
);
}
if st.is_clean()
&& !st.detached
&& st.branch.as_deref() == Some(trunk.as_str())
&& st.behind == 0
{
println!(" ✓ clean, on {trunk}, nothing to refresh — good to build.");
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_clean_on_branch_with_upstream() {
let out = "\
# branch.oid abc123
# branch.head master
# branch.upstream origin/master
# branch.ab +0 -0
";
let st = parse_porcelain_v2(out);
assert_eq!(st.branch.as_deref(), Some("master"));
assert!(!st.detached);
assert_eq!(st.upstream.as_deref(), Some("origin/master"));
assert_eq!((st.ahead, st.behind, st.dirty), (0, 0, 0));
assert!(st.is_clean());
}
#[test]
fn counts_dirty_entries() {
let out = "\
# branch.head feature-x
# branch.upstream origin/feature-x
# branch.ab +2 -3
1 .M N... 100644 100644 100644 aaa bbb config.yaml
2 R. N... 100644 100644 100644 ccc ddd R100 new.py\told.py
u UU N... ... foo
? untracked.txt
";
let st = parse_porcelain_v2(out);
assert_eq!(st.branch.as_deref(), Some("feature-x"));
assert_eq!((st.ahead, st.behind), (2, 3));
assert_eq!(st.dirty, 4);
assert!(!st.is_clean());
}
#[test]
fn detects_detached_head() {
let out = "# branch.oid abc123\n# branch.head (detached)\n";
let st = parse_porcelain_v2(out);
assert!(st.detached);
assert_eq!(st.branch, None);
}
#[test]
fn no_upstream_leaves_fields_none() {
let out = "# branch.head master\n";
let st = parse_porcelain_v2(out);
assert_eq!(st.upstream, None);
assert_eq!((st.ahead, st.behind), (0, 0));
}
#[test]
fn summary_flags_off_trunk_and_dirty() {
let st = GitState {
branch: Some("feature-x".into()),
upstream: Some("origin/feature-x".into()),
ahead: 1,
behind: 2,
dirty: 3,
..Default::default()
};
let s = summary(&st, "master");
assert!(s.contains("NOT master"), "{s}");
assert!(s.contains("3 uncommitted"), "{s}");
assert!(s.contains("1 ahead"), "{s}");
assert!(s.contains("2 behind"), "{s}");
}
#[test]
fn summary_clean_on_trunk_up_to_date() {
let st = GitState {
branch: Some("master".into()),
upstream: Some("origin/master".into()),
..Default::default()
};
let s = summary(&st, "master");
assert!(s.contains("on master"), "{s}");
assert!(s.contains("clean"), "{s}");
assert!(s.contains("up to date"), "{s}");
}
}