use serde_json::{json, Value};
#[derive(Debug, Default, Clone)]
pub struct GitState {
pub branch: String,
pub commit_short: String,
pub tag: String,
pub unstaged: u32,
pub untracked: u32,
pub staged: u32,
pub conflicts: u32,
pub ahead: u32,
pub behind: u32,
pub stashed: u32,
pub action: String,
pub is_github: bool,
}
impl GitState {
pub fn is_clean(&self) -> bool {
self.unstaged == 0
&& self.untracked == 0
&& self.staged == 0
&& self.conflicts == 0
&& self.ahead == 0
&& self.behind == 0
&& self.action.is_empty()
}
}
pub fn parse_porcelain_v2(text: &str) -> GitState {
let mut state = GitState::default();
for line in text.lines() {
if let Some(rest) = line.strip_prefix("# branch.head ") {
state.branch = rest.to_string();
} else if let Some(rest) = line.strip_prefix("# branch.ab ") {
let mut parts = rest.split_whitespace();
if let Some(a) = parts.next().and_then(|s| s.strip_prefix('+')) {
state.ahead = a.parse().unwrap_or(0);
}
if let Some(b) = parts.next().and_then(|s| s.strip_prefix('-')) {
state.behind = b.parse().unwrap_or(0);
}
} else if let Some(rest) = line.strip_prefix("1 ") {
tally_xy(rest, &mut state);
} else if let Some(rest) = line.strip_prefix("2 ") {
tally_xy(rest, &mut state);
} else if line.starts_with("u ") {
state.conflicts += 1;
} else if line.starts_with("? ") {
state.untracked += 1;
}
}
if state.branch == "(detached)" {
state.branch.clear();
}
state
}
fn tally_xy(rest: &str, state: &mut GitState) {
if let Some(xy) = rest.split_whitespace().next() {
let bytes = xy.as_bytes();
if bytes.first().is_some_and(|b| *b != b'.') {
state.staged += 1;
}
if bytes.get(1).is_some_and(|b| *b != b'.') {
state.unstaged += 1;
}
}
}
pub fn read_git_state(cwd: &str) -> Option<GitState> {
let out = std::process::Command::new("git")
.args(["-C", cwd, "status", "--porcelain=v2", "--branch"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8(out.stdout).ok()?;
let mut state = parse_porcelain_v2(&text);
if let Ok(out) = std::process::Command::new("git")
.args(["-C", cwd, "describe", "--tags", "--exact-match"])
.output()
{
if out.status.success() {
state.tag = String::from_utf8_lossy(&out.stdout).trim().to_string();
}
}
if state.branch.is_empty() {
if let Ok(out) = std::process::Command::new("git")
.args(["-C", cwd, "rev-parse", "--short", "HEAD"])
.output()
{
if let Ok(s) = String::from_utf8(out.stdout) {
state.commit_short = s.trim().to_string();
}
}
}
if let Ok(out) = std::process::Command::new("git")
.args(["-C", cwd, "rev-parse", "--git-dir"])
.output()
{
if out.status.success() {
let gitdir = String::from_utf8_lossy(&out.stdout).trim().to_string();
let dir = std::path::Path::new(&gitdir);
if dir.join("MERGE_HEAD").exists() {
state.action = "merge".to_string();
} else if dir.join("rebase-merge").exists() || dir.join("rebase-apply").exists() {
state.action = "rebase".to_string();
} else if dir.join("CHERRY_PICK_HEAD").exists() {
state.action = "cherry-pick".to_string();
} else if dir.join("REVERT_HEAD").exists() {
state.action = "revert".to_string();
} else if dir.join("BISECT_LOG").exists() {
state.action = "bisect".to_string();
}
}
}
if let Ok(out) = std::process::Command::new("git")
.args(["-C", cwd, "stash", "list"])
.output()
{
if out.status.success() {
state.stashed = String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.is_empty())
.count() as u32;
}
}
if let Ok(out) = std::process::Command::new("git")
.args(["-C", cwd, "config", "--get", "remote.origin.url"])
.output()
{
if out.status.success() {
let url = String::from_utf8_lossy(&out.stdout).to_string();
state.is_github = url.contains("github.com");
}
}
Some(state)
}
pub struct GitIcons<'a> {
pub branch: &'a str,
pub github: &'a str,
pub tag: &'a str,
pub unstaged: &'a str,
pub untracked: &'a str,
pub staged: &'a str,
pub conflict: &'a str,
pub ahead: &'a str,
pub behind: &'a str,
pub stash: &'a str,
}
fn render_git_header(state: &GitState, icons: &GitIcons<'_>, out: &mut String) {
if state.is_github && !icons.github.is_empty() {
out.push_str(icons.github);
out.push(' ');
}
if !state.branch.is_empty() {
if !icons.branch.is_empty() {
out.push_str(icons.branch);
out.push(' ');
}
out.push_str(&state.branch);
if !state.tag.is_empty() {
out.push(' ');
if !icons.tag.is_empty() {
out.push_str(icons.tag);
out.push(' ');
}
out.push_str(&state.tag);
}
} else if !state.tag.is_empty() {
if !icons.tag.is_empty() {
out.push_str(icons.tag);
out.push(' ');
}
out.push_str(&state.tag);
} else {
out.push('@');
out.push_str(&state.commit_short);
}
}
fn render_git_counters(state: &GitState, icons: &GitIcons<'_>, out: &mut String) {
if !state.action.is_empty() {
out.push(' ');
out.push_str(&state.action);
}
if state.behind > 0 {
out.push_str(&format!(" {}{}", icons.behind, state.behind));
}
if state.ahead > 0 {
out.push_str(&format!(" {}{}", icons.ahead, state.ahead));
}
if state.stashed > 0 {
out.push_str(&format!(" {}{}", icons.stash, state.stashed));
}
if state.conflicts > 0 {
out.push_str(&format!(" {}{}", icons.conflict, state.conflicts));
}
if state.staged > 0 {
out.push_str(&format!(" {}{}", icons.staged, state.staged));
}
if state.unstaged > 0 {
out.push_str(&format!(" {}{}", icons.unstaged, state.unstaged));
}
if state.untracked > 0 {
out.push_str(&format!(" {}{}", icons.untracked, state.untracked));
}
}
fn pick_highlight_groups(state: &GitState, status_colors: bool) -> Vec<Value> {
if status_colors {
let (primary, fallback) = if state.is_clean() {
("git_status_clean", "branch_clean")
} else {
("git_status_dirty", "branch_dirty")
};
vec![
Value::String(primary.into()),
Value::String("git_status".into()),
Value::String(fallback.into()),
Value::String("branch".into()),
]
} else {
vec![
Value::String("git_status".into()),
Value::String("branch".into()),
]
}
}
pub fn render_git_chunk(
state: &GitState,
icons: &GitIcons<'_>,
status_colors: bool,
) -> Option<(String, Vec<Value>)> {
if state.branch.is_empty() && state.tag.is_empty() && state.commit_short.is_empty() {
return None;
}
let mut s = String::new();
render_git_header(state, icons, &mut s);
render_git_counters(state, icons, &mut s);
let groups = pick_highlight_groups(state, status_colors);
Some((s, groups))
}
#[allow(clippy::too_many_arguments)]
pub fn git_status(
cwd: &str,
branch_icon: &str,
github_icon: &str,
tag_icon: &str,
unstaged_icon: &str,
untracked_icon: &str,
staged_icon: &str,
conflict_icon: &str,
ahead_icon: &str,
behind_icon: &str,
stash_icon: &str,
status_colors: bool,
) -> Option<Vec<Value>> {
let state = read_git_state(cwd)?;
let icons = GitIcons {
branch: branch_icon,
github: github_icon,
tag: tag_icon,
unstaged: unstaged_icon,
untracked: untracked_icon,
staged: staged_icon,
conflict: conflict_icon,
ahead: ahead_icon,
behind: behind_icon,
stash: stash_icon,
};
let (s, groups) = render_git_chunk(&state, &icons, status_colors)?;
Some(vec![json!({
"contents": s,
"highlight_groups": groups,
"divider_highlight_group": "background:divider",
})])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn git_state_default_is_clean() {
assert!(GitState::default().is_clean());
}
#[test]
fn git_state_unstaged_marks_dirty() {
let s = GitState {
unstaged: 1,
..Default::default()
};
assert!(!s.is_clean());
}
#[test]
fn git_status_outside_repo_returns_none() {
let _ = git_status("/tmp", "", "", "", "!", "?", "+", "~", "⇡", "⇣", "*", false);
}
#[test]
fn parse_branch_head_line_sets_branch() {
let s = parse_porcelain_v2("# branch.head main\n");
assert_eq!(s.branch, "main");
}
#[test]
fn parse_branch_head_detached_clears_branch() {
let s = parse_porcelain_v2("# branch.head (detached)\n");
assert!(s.branch.is_empty());
}
#[test]
fn parse_branch_ab_populates_ahead_and_behind() {
let s = parse_porcelain_v2("# branch.ab +3 -7\n");
assert_eq!(s.ahead, 3);
assert_eq!(s.behind, 7);
}
#[test]
fn parse_branch_ab_zero_means_synced() {
let s = parse_porcelain_v2("# branch.ab +0 -0\n");
assert_eq!(s.ahead, 0);
assert_eq!(s.behind, 0);
}
#[test]
fn parse_1_line_x_byte_marks_staged() {
let s = parse_porcelain_v2("1 M. N... 100644 100644 100644 a a b src/lib.rs\n");
assert_eq!(s.staged, 1);
assert_eq!(s.unstaged, 0);
}
#[test]
fn parse_1_line_y_byte_marks_unstaged() {
let s = parse_porcelain_v2("1 .M N... 100644 100644 100644 a a b src/lib.rs\n");
assert_eq!(s.staged, 0);
assert_eq!(s.unstaged, 1);
}
#[test]
fn parse_1_line_both_bytes_marks_both() {
let s = parse_porcelain_v2("1 MM N... 100644 100644 100644 a a b src/lib.rs\n");
assert_eq!(s.staged, 1);
assert_eq!(s.unstaged, 1);
}
#[test]
fn parse_2_line_rename_uses_same_xy_dispatch() {
let s = parse_porcelain_v2("2 R. N... 100644 100644 100644 a a b R100 new\told\n");
assert_eq!(s.staged, 1);
assert_eq!(s.unstaged, 0);
}
#[test]
fn parse_u_line_counts_conflict() {
let s = parse_porcelain_v2("u UU N... 100644 100644 100644 100644 a a a a path\n");
assert_eq!(s.conflicts, 1);
}
#[test]
fn parse_question_line_counts_untracked() {
let s = parse_porcelain_v2("? new_file.txt\n? another.txt\n");
assert_eq!(s.untracked, 2);
}
#[test]
fn parse_full_porcelain_v2_sample() {
let text = "\
# branch.oid abcdef0123456789
# branch.head feature/big
# branch.upstream origin/feature/big
# branch.ab +2 -1
1 M. N... 100644 100644 100644 a a b src/a.rs
1 .M N... 100644 100644 100644 a a b src/b.rs
1 MM N... 100644 100644 100644 a a b src/c.rs
2 R. N... 100644 100644 100644 a a b R100 src/d.rs\told.rs
u UU N... 100644 100644 100644 100644 a a a a src/conflict.rs
? untracked.txt
? another.txt
";
let s = parse_porcelain_v2(text);
assert_eq!(s.branch, "feature/big");
assert_eq!(s.ahead, 2);
assert_eq!(s.behind, 1);
assert_eq!(s.staged, 3); assert_eq!(s.unstaged, 2); assert_eq!(s.conflicts, 1);
assert_eq!(s.untracked, 2);
assert!(!s.is_clean());
}
#[test]
fn parse_empty_text_is_default_clean() {
let s = parse_porcelain_v2("");
assert!(s.is_clean());
assert!(s.branch.is_empty());
}
#[test]
fn is_clean_flips_for_every_dirty_field() {
let fields: &[fn(&mut GitState)] = &[
|s| s.unstaged = 1,
|s| s.untracked = 1,
|s| s.staged = 1,
|s| s.conflicts = 1,
|s| s.ahead = 1,
|s| s.behind = 1,
|s| s.action = "merge".into(),
];
for set in fields {
let mut s = GitState::default();
set(&mut s);
assert!(!s.is_clean(), "{s:?} should not be clean");
}
}
#[test]
fn is_clean_ignores_branch_and_tag_and_stash() {
let s = GitState {
branch: "main".into(),
tag: "v1.0".into(),
stashed: 5,
is_github: true,
..Default::default()
};
assert!(s.is_clean());
}
fn icons() -> GitIcons<'static> {
GitIcons {
branch: "",
github: "GH",
tag: "T",
unstaged: "!",
untracked: "?",
staged: "+",
conflict: "~",
ahead: "⇡",
behind: "⇣",
stash: "*",
}
}
#[test]
fn render_empty_state_returns_none() {
let s = GitState::default();
assert!(render_git_chunk(&s, &icons(), false).is_none());
}
#[test]
fn render_branch_only_renders_glyph_and_name() {
let s = GitState {
branch: "main".into(),
..Default::default()
};
let mut ic = icons();
ic.branch = "B";
let (text, _) = render_git_chunk(&s, &ic, false).unwrap();
assert_eq!(text, "B main");
let (text, _) = render_git_chunk(&s, &icons(), false).unwrap();
assert_eq!(text, "main");
}
#[test]
fn render_github_remote_leads_with_octocat() {
let s = GitState {
branch: "main".into(),
is_github: true,
..Default::default()
};
let (text, _) = render_git_chunk(&s, &icons(), false).unwrap();
assert!(text.starts_with("GH "), "github glyph missing: {text:?}");
assert!(text.contains("main"));
}
#[test]
fn render_branch_plus_tag_uses_combo_form() {
let s = GitState {
branch: "main".into(),
tag: "v1.0".into(),
..Default::default()
};
let (text, _) = render_git_chunk(&s, &icons(), false).unwrap();
assert!(text.contains("main"));
assert!(text.contains("T v1.0"));
}
#[test]
fn render_tag_only_no_branch() {
let s = GitState {
tag: "v1.0".into(),
..Default::default()
};
let (text, _) = render_git_chunk(&s, &icons(), false).unwrap();
assert!(text.starts_with("T v1.0"), "tag-only header: {text:?}");
}
#[test]
fn render_detached_head_uses_at_sha_fallback() {
let s = GitState {
commit_short: "abcdef1".into(),
..Default::default()
};
let (text, _) = render_git_chunk(&s, &icons(), false).unwrap();
assert_eq!(text, "@abcdef1");
}
#[test]
fn render_counter_order_matches_p10k_lean() {
let s = GitState {
branch: "main".into(),
action: "merge".into(),
behind: 1,
ahead: 2,
stashed: 3,
conflicts: 4,
staged: 5,
unstaged: 6,
untracked: 7,
..Default::default()
};
let (text, _) = render_git_chunk(&s, &icons(), false).unwrap();
let order = ["merge", "⇣1", "⇡2", "*3", "~4", "+5", "!6", "?7"];
let mut last_idx = 0usize;
for needle in order {
let i = text[last_idx..]
.find(needle)
.unwrap_or_else(|| panic!("missing {needle:?} after pos {last_idx}: {text:?}"));
last_idx += i + needle.len();
}
}
#[test]
fn render_zero_counters_omit_chunks() {
let s = GitState {
branch: "main".into(),
..Default::default()
};
let (text, _) = render_git_chunk(&s, &icons(), false).unwrap();
for glyph in ['⇡', '⇣', '*', '~', '+', '!', '?'] {
assert!(
!text.contains(glyph),
"glyph {glyph:?} should be omitted when its counter is 0: {text:?}"
);
}
}
#[test]
fn render_empty_github_icon_does_not_lead() {
let s = GitState {
branch: "main".into(),
is_github: true,
..Default::default()
};
let mut ic = icons();
ic.github = "";
let (text, _) = render_git_chunk(&s, &ic, false).unwrap();
assert!(
!text.starts_with(' '),
"no leading space when github_icon is empty: {text:?}"
);
}
#[test]
fn highlight_groups_no_status_colors_is_two_neutral() {
let s = GitState::default();
let g = pick_highlight_groups(&s, false);
assert_eq!(g.len(), 2);
assert_eq!(g[0].as_str().unwrap(), "git_status");
assert_eq!(g[1].as_str().unwrap(), "branch");
}
#[test]
fn highlight_groups_status_colors_clean_uses_clean_chain() {
let s = GitState {
branch: "main".into(),
..Default::default()
};
let g = pick_highlight_groups(&s, true);
assert_eq!(g[0].as_str().unwrap(), "git_status_clean");
assert_eq!(g[2].as_str().unwrap(), "branch_clean");
}
#[test]
fn highlight_groups_status_colors_dirty_uses_dirty_chain() {
let s = GitState {
branch: "main".into(),
unstaged: 1,
..Default::default()
};
let g = pick_highlight_groups(&s, true);
assert_eq!(g[0].as_str().unwrap(), "git_status_dirty");
assert_eq!(g[2].as_str().unwrap(), "branch_dirty");
}
#[test]
fn highlight_groups_status_colors_always_four_levels() {
let s = GitState::default();
assert_eq!(pick_highlight_groups(&s, true).len(), 4);
let s = GitState {
unstaged: 1,
..Default::default()
};
assert_eq!(pick_highlight_groups(&s, true).len(), 4);
}
}