use crate::extensions::p10k::gitconfig::GitConfig;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::fs;
use std::io::Read;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime};
#[derive(Debug, Clone, Default)]
pub struct GitStatus {
pub branch: String,
pub commit: String,
pub action: String, pub ahead: i64,
pub behind: i64,
pub staged: i64,
pub unstaged: i64,
pub untracked: i64,
pub conflicted: i64,
pub stashes: i64,
pub tag: String,
pub remote_branch: String,
pub remote_url: String,
pub workdir: String,
pub remote_name: String,
pub push_remote_name: String,
pub push_remote_url: String,
pub push_ahead: i64,
pub push_behind: i64,
pub index_size: i64,
pub skip_worktree: i64,
pub assume_unchanged: i64,
pub staged_new: i64,
pub staged_deleted: i64,
pub unstaged_deleted: i64,
pub dirty_unknown: bool,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GitLimits {
pub max_staged: i64,
pub max_unstaged: i64,
pub max_untracked: i64,
pub max_conflicted: i64,
pub dirty_max_index_size: i64,
pub recurse_untracked_dirs: bool,
}
impl Default for GitLimits {
fn default() -> Self {
GitLimits {
max_staged: 1,
max_unstaged: 1,
max_untracked: 1,
max_conflicted: 1,
dirty_max_index_size: -1,
recurse_untracked_dirs: false,
}
}
}
impl GitLimits {
pub fn from_params() -> Self {
use crate::extensions::p10k::shared::{global_bool, global_int};
GitLimits {
max_staged: global_int("VCS_STAGED_MAX_NUM", 1),
max_unstaged: global_int("VCS_UNSTAGED_MAX_NUM", 1),
max_untracked: global_int("VCS_UNTRACKED_MAX_NUM", 1),
max_conflicted: global_int("VCS_CONFLICTED_MAX_NUM", 1),
dirty_max_index_size: global_int("VCS_MAX_INDEX_SIZE_DIRTY", -1),
recurse_untracked_dirs: global_bool("VCS_RECURSE_UNTRACKED_DIRS", false),
}
}
fn clamp(count: i64, limit: i64) -> i64 {
if limit < 0 {
count
} else {
count.min(limit)
}
}
fn apply(&self, s: &mut GitStatus, index_size: i64) {
let too_large = self.dirty_max_index_size >= 0 && index_size > self.dirty_max_index_size;
if too_large {
s.unstaged = 0;
s.untracked = 0;
s.unstaged_deleted = 0;
}
s.dirty_unknown = too_large;
s.staged = Self::clamp(s.staged, self.max_staged);
s.unstaged = Self::clamp(s.unstaged, self.max_unstaged);
s.conflicted = Self::clamp(s.conflicted, self.max_conflicted);
s.untracked = Self::clamp(s.untracked, self.max_untracked);
s.staged_new = s.staged_new.min(s.staged);
s.staged_deleted = s.staged_deleted.min(s.staged);
s.unstaged_deleted = s.unstaged_deleted.min(s.unstaged);
}
}
const CACHE_TTL: Duration = Duration::from_secs(2);
const SUBPROCESS_BUDGET: Duration = Duration::from_millis(45);
const NATIVE_SCAN_MAX: usize = 20_000;
const PAR_STAT_MIN: usize = 512;
const MAX_STAT_THREADS: usize = 8;
struct CacheEntry {
at: Instant,
head_mtime: Option<SystemTime>,
index_mtime: Option<SystemTime>,
limits: GitLimits,
status: GitStatus,
}
fn cache() -> &'static Mutex<HashMap<PathBuf, CacheEntry>> {
static CACHE: OnceLock<Mutex<HashMap<PathBuf, CacheEntry>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
struct SubEntry {
at: Instant,
mode: UntrackedMode,
status: GitStatus,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum UntrackedMode {
No,
Normal,
All,
}
impl UntrackedMode {
fn arg(self) -> &'static str {
match self {
UntrackedMode::No => "--untracked-files=no",
UntrackedMode::Normal => "--untracked-files=normal",
UntrackedMode::All => "--untracked-files=all",
}
}
}
fn sub_cache() -> &'static Mutex<HashMap<PathBuf, SubEntry>> {
static SUB: OnceLock<Mutex<HashMap<PathBuf, SubEntry>>> = OnceLock::new();
SUB.get_or_init(|| Mutex::new(HashMap::new()))
}
fn sub_inflight() -> &'static Mutex<std::collections::HashSet<PathBuf>> {
static IN: OnceLock<Mutex<std::collections::HashSet<PathBuf>>> = OnceLock::new();
IN.get_or_init(|| Mutex::new(std::collections::HashSet::new()))
}
const CACHE_CAP: usize = 64;
fn refresh_porcelain(
repo: &Repo,
mode: UntrackedMode,
wait: Option<Duration>,
) -> Option<GitStatus> {
{
let mut set = sub_inflight().lock().ok()?;
if !set.insert(repo.git_dir.clone()) {
return None; }
}
let work_dir = repo.work_dir.clone();
let git_dir = repo.git_dir.clone();
let (tx, rx) = mpsc::channel();
crate::signal_thread::spawn(move || {
let out = run_porcelain(&work_dir, mode);
if let Some(out) = out {
let mut sub = GitStatus::default();
parse_porcelain_v2(&out, &mut sub);
if let Ok(mut map) = sub_cache().lock() {
if map.len() >= CACHE_CAP && !map.contains_key(&git_dir) {
evict_oldest(&mut map, |e: &SubEntry| e.at);
}
map.insert(
git_dir.clone(),
SubEntry {
at: Instant::now(),
mode,
status: sub.clone(),
},
);
}
let _ = tx.send(sub);
}
if let Ok(mut set) = sub_inflight().lock() {
set.remove(&git_dir);
}
});
match wait {
Some(d) => rx.recv_timeout(d).ok(),
None => None,
}
}
fn evict_oldest<V>(map: &mut HashMap<PathBuf, V>, at: impl Fn(&V) -> Instant) {
if let Some(oldest) = map
.iter()
.max_by_key(|(_, v)| at(v).elapsed())
.map(|(k, _)| k.clone())
{
map.remove(&oldest);
}
}
pub fn git_status_for(dir: &Path) -> Option<GitStatus> {
git_status_with(dir, &GitLimits::from_params())
}
pub fn git_status_with(dir: &Path, limits: &GitLimits) -> Option<GitStatus> {
let repo = discover_repo(dir)?;
let head_mtime = mtime_of(&repo.git_dir.join("HEAD"));
let index_mtime = mtime_of(&repo.git_dir.join("index"));
if let Ok(map) = cache().lock() {
if let Some(e) = map.get(&repo.git_dir) {
if e.at.elapsed() < CACHE_TTL
&& e.limits == *limits
&& e.head_mtime == head_mtime
&& e.index_mtime == index_mtime
{
return Some(e.status.clone());
}
}
}
let mut status = GitStatus::default();
read_head(&repo, &mut status);
status.workdir = repo.work_dir.to_string_lossy().into_owned();
status.action = repo_action(&repo.git_dir);
let cfg = GitConfig::load_stack(
&repo.common_dir,
&repo.git_dir,
Some(status.branch.as_str()).filter(|b| !b.is_empty()),
);
let caps = RepoCaps::from_config(&cfg);
let mut lim = *limits;
let off = |section: &str, key: &str| cfg.bool_value(section, key) == Some(false);
if off("status", "showuntrackedfiles") || off("bash", "showuntrackedfiles") {
lim.max_untracked = 0;
}
if off("bash", "showdirtystate") {
lim.max_staged = 0;
lim.max_unstaged = 0;
lim.max_conflicted = 0;
}
let untracked_mode = if lim.max_untracked == 0 {
UntrackedMode::No
} else if lim.recurse_untracked_dirs {
UntrackedMode::All
} else {
UntrackedMode::Normal
};
let mut ab_native = false;
if !status.branch.is_empty() {
match tracking_of(&cfg, &repo.common_dir, &status.branch) {
Some(t) => {
status.remote_branch = t.branch;
status.remote_name = t.remote.clone();
if t.remote != "." {
status.remote_url =
cfg.get("remote", &t.remote, "url").unwrap_or_default().to_string();
}
if t.tip == status.commit {
ab_native = true;
}
}
None => ab_native = true,
}
if let Some(p) = push_target_of(&cfg, &repo.common_dir, &status.branch) {
status.push_remote_name = p.remote;
status.push_remote_url = p.url;
if p.tip != status.commit {
if let Some((ahead, behind)) = rev_list_counts(&repo.common_dir, &status.commit, &p.refname) {
status.push_ahead = ahead;
status.push_behind = behind;
}
}
}
} else {
ab_native = true; }
let native_stash = stash_count(&repo.common_dir);
if let Some(n) = native_stash {
status.stashes = n;
}
if !status.commit.is_empty() {
status.tag = tag_for_commit(&repo.common_dir, &status.commit);
}
let index_size = index_entry_count(&repo.git_dir);
status.index_size = index_size;
let native_counts = read_index_counts(&repo, &caps);
if let Some(ic) = &native_counts {
status.unstaged = ic.unstaged;
status.conflicted = ic.conflicted;
status.skip_worktree = ic.skip_worktree;
status.assume_unchanged = ic.assume_unchanged;
}
let prev_sub = sub_cache()
.lock()
.ok()
.and_then(|m| {
m.get(&repo.git_dir)
.filter(|e| e.mode == untracked_mode)
.map(|e| e.status.clone())
});
let sub = match prev_sub {
Some(s) => {
refresh_porcelain(&repo, untracked_mode, None);
Some(s)
}
None => refresh_porcelain(&repo, untracked_mode, Some(SUBPROCESS_BUDGET)),
};
let sub = match sub {
Some(s) => s,
None => return None,
};
status.staged = sub.staged;
status.untracked = sub.untracked;
status.staged_new = sub.staged_new;
status.staged_deleted = sub.staged_deleted;
status.unstaged_deleted = sub.unstaged_deleted;
if native_counts.is_none() {
status.unstaged = sub.unstaged;
status.conflicted = sub.conflicted;
}
if !ab_native {
status.ahead = sub.ahead;
status.behind = sub.behind;
}
if native_stash.is_none() {
status.stashes = sub.stashes; }
if status.commit.is_empty() {
status.commit = sub.commit;
}
if status.branch.is_empty() {
status.branch = sub.branch;
}
if status.remote_branch.is_empty() && !sub.remote_branch.is_empty() {
status.remote_branch = match sub.remote_branch.split_once('/') {
Some((_, b)) => b.to_string(),
None => sub.remote_branch,
};
}
lim.apply(&mut status, index_size);
if let Ok(mut map) = cache().lock() {
if map.len() >= CACHE_CAP && !map.contains_key(&repo.git_dir) {
evict_oldest(&mut map, |e: &CacheEntry| e.at);
}
map.insert(
repo.git_dir.clone(),
CacheEntry {
at: Instant::now(),
head_mtime,
index_mtime,
limits: *limits,
status: status.clone(),
},
);
}
Some(status)
}
struct Repo {
git_dir: PathBuf,
common_dir: PathBuf,
work_dir: PathBuf,
}
fn discover_repo(dir: &Path) -> Option<Repo> {
let mut cur = if dir.is_absolute() {
dir.to_path_buf()
} else {
std::env::current_dir().ok()?.join(dir)
};
loop {
let dotgit = cur.join(".git");
if let Ok(meta) = fs::symlink_metadata(&dotgit) {
let git_dir = if meta.is_dir() {
dotgit
} else {
let text = fs::read_to_string(&dotgit).ok()?;
let target = text.strip_prefix("gitdir:")?.trim();
let p = Path::new(target);
if p.is_absolute() {
p.to_path_buf()
} else {
cur.join(p)
}
};
let common_dir = match fs::read_to_string(git_dir.join("commondir")) {
Ok(text) => {
let t = text.trim();
let p = Path::new(t);
if p.is_absolute() {
p.to_path_buf()
} else {
git_dir.join(p)
}
}
Err(_) => git_dir.clone(),
};
return Some(Repo {
git_dir,
common_dir,
work_dir: cur,
});
}
if !cur.pop() {
return None;
}
}
}
fn mtime_of(p: &Path) -> Option<SystemTime> {
fs::metadata(p).ok().and_then(|m| m.modified().ok())
}
fn read_head(repo: &Repo, status: &mut GitStatus) {
let head = match fs::read_to_string(repo.git_dir.join("HEAD")) {
Ok(s) => s,
Err(e) => {
tracing::warn!("p10k git: unreadable HEAD in {:?}: {e}", repo.git_dir);
return;
}
};
let head = head.trim();
if let Some(refname) = head.strip_prefix("ref: ") {
let refname = refname.trim();
status.branch = refname
.strip_prefix("refs/heads/")
.unwrap_or(refname)
.to_string();
if let Some(oid) = resolve_ref(&repo.common_dir, refname) {
status.commit = oid;
}
} else if head.len() >= 40 && head.bytes().all(|b| b.is_ascii_hexdigit()) {
status.commit = head.to_string(); }
}
fn resolve_ref(common_dir: &Path, refname: &str) -> Option<String> {
if let Ok(s) = fs::read_to_string(common_dir.join(refname)) {
let s = s.trim();
if !s.is_empty() {
return Some(s.to_string());
}
}
let packed = fs::read_to_string(common_dir.join("packed-refs")).ok()?;
for line in packed.lines() {
if line.starts_with('#') || line.starts_with('^') {
continue;
}
if let Some((oid, name)) = line.split_once(' ') {
if name == refname {
return Some(oid.to_string());
}
}
}
None
}
fn repo_action(git_dir: &Path) -> String {
let exists = |name: &str| git_dir.join(name).exists();
let read1 = |name: &str| -> String {
fs::read_to_string(git_dir.join(name))
.ok()
.and_then(|s| s.split_whitespace().next().map(str::to_string))
.unwrap_or_default()
};
let state: &str = if exists("rebase-merge") {
if exists("rebase-merge/interactive") {
"rebase-i" } else {
"rebase-m" }
} else if exists("rebase-apply") {
if exists("rebase-apply/rebasing") {
"rebase" } else if exists("rebase-apply/applying") {
"am" } else {
"am/rebase" }
} else if exists("MERGE_HEAD") {
"merge" } else if exists("REVERT_HEAD") {
if exists("sequencer/todo") {
"revert-seq" } else {
"revert" }
} else if exists("CHERRY_PICK_HEAD") {
if exists("sequencer/todo") {
"cherry-seq" } else {
"cherry" }
} else if exists("BISECT_LOG") {
"bisect" } else {
"" };
let (next, last) = if exists("rebase-merge") {
(read1("rebase-merge/msgnum"), read1("rebase-merge/end"))
} else if exists("rebase-apply") {
(read1("rebase-apply/next"), read1("rebase-apply/last"))
} else {
(String::new(), String::new())
};
if !next.is_empty() && !last.is_empty() {
format!("{state} {next}/{last}")
} else {
state.to_string()
}
}
fn refspec_transform(spec: &str, refname: &str) -> Option<String> {
let spec = spec.strip_prefix('+').unwrap_or(spec);
let (src, dst) = spec.split_once(':')?;
match src.split_once('*') {
Some((pre, post)) => {
let middle = refname.strip_prefix(pre)?.strip_suffix(post)?;
let (dst_pre, dst_post) = dst.split_once('*')?;
Some(format!("{dst_pre}{middle}{dst_post}"))
}
None => (src == refname).then(|| dst.to_string()),
}
}
fn remote_tracking_ref(cfg: &GitConfig, remote: &str, refname: &str) -> Option<String> {
if !cfg.has_group("remote", remote) {
return None;
}
cfg.get_all("remote", remote, "fetch")
.iter()
.find_map(|spec| refspec_transform(spec, refname))
}
struct Tracking {
remote: String,
branch: String,
tip: String,
}
fn tracking_of(cfg: &GitConfig, common_dir: &Path, branch: &str) -> Option<Tracking> {
let remote = cfg.get("branch", branch, "remote").filter(|r| !r.is_empty())?;
let merge = cfg.get("branch", branch, "merge").filter(|m| !m.is_empty())?;
let refname = if remote == "." {
merge.to_string()
} else {
remote_tracking_ref(cfg, remote, merge)?
};
let tip = resolve_ref(common_dir, &refname)?;
let short = refname
.strip_prefix("refs/heads/")
.or_else(|| refname.strip_prefix("refs/remotes/"))
.unwrap_or(&refname);
let name = if remote == "." {
short
} else {
short.strip_prefix(remote).and_then(|s| s.strip_prefix('/')).unwrap_or(short)
};
Some(Tracking { remote: remote.to_string(), branch: name.to_string(), tip })
}
struct PushTarget {
remote: String,
url: String,
refname: String,
tip: String,
}
fn push_target_of(cfg: &GitConfig, common_dir: &Path, branch: &str) -> Option<PushTarget> {
let remote = cfg
.get("branch", branch, "pushremote")
.or_else(|| cfg.get("remote", "", "pushdefault"))
.filter(|r| !r.is_empty())?;
let local = format!("refs/heads/{branch}");
let refname = if remote == "." {
local
} else {
remote_tracking_ref(cfg, remote, &local)?
};
let tip = resolve_ref(common_dir, &refname)?;
let url = if remote == "." {
String::new()
} else {
cfg.get("remote", remote, "url").unwrap_or_default().to_string()
};
Some(PushTarget { remote: remote.to_string(), url, refname, tip })
}
fn rev_list_counts(common_dir: &Path, head: &str, other: &str) -> Option<(i64, i64)> {
let out = Command::new("git")
.arg(format!("--git-dir={}", common_dir.display()))
.args(["rev-list", "--left-right", "--count"])
.arg(format!("{head}...{other}"))
.env("GIT_OPTIONAL_LOCKS", "0")
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let mut it = text.split_whitespace().map(|n| n.parse::<i64>());
match (it.next(), it.next()) {
(Some(Ok(a)), Some(Ok(b))) => Some((a, b)),
_ => None,
}
}
#[derive(Clone, Copy)]
struct RepoCaps {
trust_filemode: bool, has_symlinks: bool, hash_len: usize, }
impl RepoCaps {
fn from_config(cfg: &GitConfig) -> RepoCaps {
RepoCaps {
trust_filemode: cfg.get_bool("core", "filemode", true),
has_symlinks: cfg.get_bool("core", "symlinks", true),
hash_len: match cfg.get("extensions", "", "objectformat") {
Some("sha256") => 32,
_ => 20,
},
}
}
}
struct IndexCounts {
unstaged: i64,
conflicted: i64,
skip_worktree: i64,
assume_unchanged: i64,
}
fn index_entry_count(git_dir: &Path) -> i64 {
use std::io::Read as _;
let mut head = [0u8; 12];
let read = fs::File::open(git_dir.join("index")).and_then(|mut f| f.read_exact(&mut head));
match read {
Ok(()) if &head[0..4] == b"DIRC" => i64::from(u32::from_be_bytes([head[8], head[9], head[10], head[11]])),
_ => 0,
}
}
fn be32(data: &[u8], pos: usize) -> Option<u32> {
data.get(pos..pos + 4)
.map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
}
fn be16(data: &[u8], pos: usize) -> Option<u16> {
data.get(pos..pos + 2)
.map(|b| u16::from_be_bytes([b[0], b[1]]))
}
fn decode_varint(data: &[u8], pos: &mut usize) -> Option<u64> {
let mut c = *data.get(*pos)?;
*pos += 1;
let mut val = u64::from(c & 0x7f);
let mut rounds = 0;
while c & 0x80 != 0 {
rounds += 1;
if rounds > 9 {
return None; }
c = *data.get(*pos)?;
*pos += 1;
val = val.checked_add(1)?.checked_shl(7)? | u64::from(c & 0x7f);
}
Some(val)
}
#[derive(Clone, Copy)]
struct EntryStat {
mode: u32,
ino: u32,
size: u32,
mt_s: u32,
mt_ns: u32,
}
const S_IFMT: u32 = 0o170000;
const S_IFREG: u32 = 0o100000;
const S_IFLNK: u32 = 0o120000;
const S_IFDIR: u32 = 0o040000;
fn is_modified(e: &EntryStat, st: &fs::Metadata, caps: &RepoCaps) -> bool {
use std::os::unix::fs::MetadataExt;
let st_mode = st.mode();
let mode = if st_mode & S_IFMT == S_IFREG {
if !caps.has_symlinks && e.mode & S_IFMT == S_IFLNK {
e.mode
} else if !caps.trust_filemode {
e.mode
} else {
S_IFREG | if st_mode & 0o100 != 0 { 0o755 } else { 0o644 }
}
} else {
st_mode & S_IFMT
};
if e.ino != 0 && e.ino != st.ino() as u32 {
return true;
}
if u64::from(e.size) != st.size() {
return true;
}
if e.mt_s as i64 != st.mtime() || !(e.mt_ns as i64 == st.mtime_nsec() || e.mt_ns == 0) {
return true;
}
if e.mode != mode {
return true;
}
false
}
fn count_modified(entries: &[(EntryStat, PathBuf)], caps: &RepoCaps) -> i64 {
fn scan(chunk: &[(EntryStat, PathBuf)], caps: &RepoCaps) -> i64 {
chunk
.iter()
.filter(|(e, path)| match fs::symlink_metadata(path) {
Err(_) => true,
Ok(st) => is_modified(e, &st, caps),
})
.count() as i64
}
let nthreads = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.min(MAX_STAT_THREADS);
if entries.len() < PAR_STAT_MIN || nthreads < 2 {
return scan(entries, caps);
}
let caps = *caps;
let chunk_len = entries.len().div_ceil(nthreads);
std::thread::scope(|s| {
let handles: Vec<_> = entries
.chunks(chunk_len)
.map(|c| s.spawn(move || scan(c, &caps)))
.collect();
handles.into_iter().map(|h| h.join().unwrap_or(0)).sum()
})
}
fn read_index_counts(repo: &Repo, caps: &RepoCaps) -> Option<IndexCounts> {
let data = fs::read(repo.git_dir.join("index")).ok()?;
if data.len() < 12 + caps.hash_len || &data[0..4] != b"DIRC" {
tracing::debug!(
"p10k git: index missing/short/bad magic in {:?}",
repo.git_dir
);
return None;
}
let version = be32(&data, 4)?;
if !(2..=4).contains(&version) {
tracing::debug!("p10k git: unsupported index version {version}");
return None;
}
let nentries = be32(&data, 8)? as usize;
if nentries > NATIVE_SCAN_MAX {
tracing::debug!(
"p10k git: index has {nentries} entries > {NATIVE_SCAN_MAX}, deferring to subprocess"
);
return None;
}
let mut pos = 12usize;
let mut prev_path: Vec<u8> = Vec::new(); let mut last_conflict: Vec<u8> = Vec::new();
let mut unstaged = 0i64;
let mut conflicted = 0i64;
let (mut skip_count, mut assume_count) = (0i64, 0i64);
let mut to_stat: Vec<(EntryStat, PathBuf)> = Vec::with_capacity(nentries);
for _ in 0..nentries {
let start = pos;
if pos + 40 + caps.hash_len + 2 > data.len() {
return None;
}
let e = EntryStat {
mt_s: be32(&data, pos + 8)?,
mt_ns: be32(&data, pos + 12)?,
ino: be32(&data, pos + 20)?,
mode: be32(&data, pos + 24)?,
size: be32(&data, pos + 36)?,
};
let flags = be16(&data, pos + 40 + caps.hash_len)?;
pos += 40 + caps.hash_len + 2;
let assume_valid = flags & 0x8000 != 0;
let extended = flags & 0x4000 != 0;
let stage = (flags >> 12) & 0x3;
let name_len = (flags & 0xFFF) as usize;
let mut skip_worktree = false;
let mut intent_to_add = false;
if extended {
if version < 3 {
return None; }
let ext = be16(&data, pos)?;
pos += 2;
skip_worktree = ext & 0x4000 != 0;
intent_to_add = ext & 0x2000 != 0;
}
if version == 4 {
let strip = decode_varint(&data, &mut pos)? as usize;
if strip > prev_path.len() {
return None;
}
let keep = prev_path.len() - strip;
prev_path.truncate(keep);
let nul = data.get(pos..)?.iter().position(|&b| b == 0)?;
prev_path.extend_from_slice(&data[pos..pos + nul]);
pos += nul + 1; } else {
let actual_len = if name_len < 0xFFF {
name_len
} else {
data.get(pos..)?.iter().position(|&b| b == 0)?
};
prev_path.clear();
prev_path.extend_from_slice(data.get(pos..pos + actual_len)?);
let fixed = pos - start;
pos = start + ((fixed + actual_len + 8) & !7);
if pos > data.len() {
return None;
}
}
if stage != 0 {
if prev_path != last_conflict {
conflicted += 1;
last_conflict.clear();
last_conflict.extend_from_slice(&prev_path);
}
continue;
}
skip_count += i64::from(skip_worktree);
assume_count += i64::from(assume_valid);
if skip_worktree || assume_valid {
continue;
}
if intent_to_add {
unstaged += 1;
continue;
}
if e.mode & S_IFMT == S_IFDIR {
tracing::debug!(
"p10k git: sparse index in {:?}, deferring to subprocess",
repo.git_dir
);
return None;
}
to_stat.push((e, repo.work_dir.join(OsStr::from_bytes(&prev_path))));
}
unstaged += count_modified(&to_stat, caps);
Some(IndexCounts {
unstaged,
conflicted,
skip_worktree: skip_count,
assume_unchanged: assume_count,
})
}
fn stash_count(common_dir: &Path) -> Option<i64> {
match fs::read(common_dir.join("logs/refs/stash")) {
Ok(bytes) => Some(bytes.iter().filter(|&&b| b == b'\n').count() as i64),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(0),
Err(e) => {
tracing::debug!("p10k git: stash reflog unreadable: {e}");
None
}
}
}
fn peel_objects<'a>(
common_dir: &Path,
oids: impl Iterator<Item = &'a str>,
) -> HashMap<String, String> {
use std::io::Write;
static MEMO: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
let memo = MEMO.get_or_init(Default::default);
let wanted: Vec<&str> = oids.collect();
let unseen: Vec<&str> = {
let m = memo.lock().unwrap();
let mut u: Vec<&str> = wanted.iter().copied().filter(|o| !m.contains_key(*o)).collect();
u.sort_unstable();
u.dedup();
u
};
if !unseen.is_empty() {
let child = Command::new("git")
.arg(format!("--git-dir={}", common_dir.display()))
.args(["cat-file", "--batch-check=%(objectname)"])
.env("GIT_OPTIONAL_LOCKS", "0")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn();
let mut answers: Vec<String> = Vec::new();
if let Ok(mut child) = child {
if let Some(mut stdin) = child.stdin.take() {
let input: String = unseen.iter().map(|o| format!("{o}^{{}}\n")).collect();
let _ = stdin.write_all(input.as_bytes());
}
if let Ok(out) = child.wait_with_output() {
answers = String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::to_string)
.collect();
}
}
let mut m = memo.lock().unwrap();
for (i, oid) in unseen.iter().enumerate() {
let peeled = answers
.get(i)
.filter(|a| !a.contains(' '))
.cloned()
.unwrap_or_else(|| (*oid).to_string());
m.insert((*oid).to_string(), peeled);
}
}
let m = memo.lock().unwrap();
wanted
.iter()
.filter_map(|o| m.get(*o).map(|p| ((*o).to_string(), p.clone())))
.collect()
}
fn tag_for_commit(common_dir: &Path, commit: &str) -> String {
let mut best = String::new();
let mut loose_names: Vec<String> = Vec::new();
let mut loose_candidates: Vec<(String, String)> = Vec::new();
if let Ok(rd) = fs::read_dir(common_dir.join("refs/tags")) {
for entry in rd.flatten() {
let is_file = entry.file_type().map(|t| t.is_file()).unwrap_or(false);
if !is_file {
continue;
}
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
continue;
};
if let Ok(oid) = fs::read_to_string(entry.path()) {
let oid = oid.trim();
if oid == commit {
if best < name {
best = name.clone();
}
} else if !oid.is_empty() {
loose_candidates.push((name.clone(), oid.to_string()));
}
}
loose_names.push(name);
}
}
if !loose_candidates.is_empty() {
let peeled = peel_objects(common_dir, loose_candidates.iter().map(|(_, o)| o.as_str()));
for (name, oid) in &loose_candidates {
if peeled.get(oid).is_some_and(|t| t == commit) && best < *name {
best = name.clone();
}
}
}
let Ok(packed) = fs::read_to_string(common_dir.join("packed-refs")) else {
return best;
};
if !packed.starts_with('#') {
tracing::warn!("p10k git: packed-refs doesn't have a header. Won't resolve packed tags.");
return best;
}
if let Some(header) = packed.lines().next() {
if !header.contains(" fully-peeled") || !header.contains(" sorted") {
tracing::warn!("p10k git: packed-refs has unexpected header."); }
}
let mut lines = packed.lines().peekable();
while let Some(line) = lines.next() {
if line.starts_with('#') || line.starts_with('^') {
continue;
}
let Some((oid, refname)) = line.split_once(' ') else {
continue;
};
let effective = match lines.peek() {
Some(peel) if peel.starts_with('^') => lines
.next()
.and_then(|p| p.strip_prefix('^'))
.unwrap_or(oid),
_ => oid,
};
let Some(name) = refname.strip_prefix("refs/tags/") else {
continue;
};
if effective == commit
&& !loose_names.iter().any(|l| l == name) && best.as_str() < name
{
best = name.to_string();
}
}
best
}
const MAX_COMMIT_SUMMARY_LEN: usize = 256;
fn commit_summary_of(message: &str) -> String {
let msg = message.trim_start_matches('\n').as_bytes();
let mut out: Vec<u8> = Vec::new();
let mut space: Option<usize> = None;
let mut space_has_newline = false;
for (i, &c) in msg.iter().enumerate() {
if c == b'\n' {
let rest = &msg[i + 1..];
let next_blank = rest
.iter()
.find(|&&b| b == b'\n' || !b.is_ascii_whitespace())
.is_none_or(|&b| b == b'\n');
if next_blank {
break;
}
}
if c.is_ascii_whitespace() {
if space.is_none() {
space = Some(i);
space_has_newline = false;
}
space_has_newline |= c == b'\n';
} else {
if let Some(start) = space.take() {
if space_has_newline {
out.push(b' ');
} else {
out.extend_from_slice(&msg[start..i]);
}
}
out.push(c);
}
}
out.truncate(MAX_COMMIT_SUMMARY_LEN);
String::from_utf8_lossy(&out).into_owned()
}
pub fn commit_message(workdir: &Path, commit: &str) -> (String, String) {
static MEMO: OnceLock<Mutex<HashMap<String, (String, String)>>> = OnceLock::new();
let memo = MEMO.get_or_init(Default::default);
if commit.is_empty() {
return Default::default();
}
if let Some(hit) = memo.lock().ok().and_then(|m| m.get(commit).cloned()) {
return hit;
}
let out = Command::new("git")
.arg("-C")
.arg(workdir)
.args(["cat-file", "commit", commit])
.env("GIT_OPTIONAL_LOCKS", "0")
.stdin(Stdio::null())
.stderr(Stdio::null())
.output();
let result = match out {
Ok(o) if o.status.success() => {
let raw = String::from_utf8_lossy(&o.stdout).into_owned();
let (headers, message) = raw.split_once("\n\n").unwrap_or((raw.as_str(), ""));
let encoding = headers
.lines()
.find_map(|l| l.strip_prefix("encoding "))
.unwrap_or_default()
.to_string();
(encoding, commit_summary_of(message))
}
_ => Default::default(),
};
if let Ok(mut m) = memo.lock() {
if m.len() > 256 {
m.clear();
}
m.insert(commit.to_string(), result.clone());
}
result
}
fn run_porcelain(work_dir: &Path, mode: UntrackedMode) -> Option<String> {
let mut child = match Command::new("git")
.arg("-C")
.arg(work_dir)
.args(["status", "--porcelain=v2", "--branch", "--show-stash"])
.arg(mode.arg())
.env("GIT_OPTIONAL_LOCKS", "0")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
{
Ok(c) => c,
Err(e) => {
tracing::warn!("p10k git: failed to spawn git status: {e}");
return None;
}
};
let mut stdout = child.stdout.take()?;
let mut out = String::new();
let read = stdout.read_to_string(&mut out);
let _ = child.wait();
match read {
Ok(_) => Some(out),
Err(e) => {
tracing::warn!("p10k git: reading git status output failed: {e}");
None
}
}
}
fn parse_porcelain_v2(out: &str, status: &mut GitStatus) {
for line in out.lines() {
if let Some(rest) = line.strip_prefix("# ") {
if let Some(oid) = rest.strip_prefix("branch.oid ") {
if status.commit.is_empty() && oid != "(initial)" {
status.commit = oid.to_string();
}
} else if let Some(head) = rest.strip_prefix("branch.head ") {
if status.branch.is_empty() && head != "(detached)" {
status.branch = head.to_string();
}
} else if let Some(up) = rest.strip_prefix("branch.upstream ") {
status.remote_branch = up.to_string();
} else if let Some(ab) = rest.strip_prefix("branch.ab ") {
for tok in ab.split_whitespace() {
if let Some(a) = tok.strip_prefix('+') {
status.ahead = a.parse().unwrap_or(0);
} else if let Some(b) = tok.strip_prefix('-') {
status.behind = b.parse().unwrap_or(0);
}
}
} else if let Some(n) = rest.strip_prefix("stash ") {
status.stashes = n.trim().parse().unwrap_or(0);
}
} else if line.starts_with("1 ") || line.starts_with("2 ") {
let bytes = line.as_bytes();
if bytes.len() >= 4 {
if bytes[2] != b'.' {
status.staged += 1;
}
if bytes[3] != b'.' {
status.unstaged += 1;
}
if bytes[2] == b'A' {
status.staged_new += 1;
}
if bytes[2] == b'D' {
status.staged_deleted += 1;
}
if bytes[3] == b'D' {
status.unstaged_deleted += 1;
}
}
} else if line.starts_with("u ") {
status.conflicted += 1;
} else if line.starts_with("? ") {
status.untracked += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn porcelain_headers_and_counts() {
let out = "\
# branch.oid 47a386b42798b5ed5d89bc617920d9152479013e
# branch.head main
# branch.upstream origin/main
# branch.ab +3 -2
# stash 1
1 .M N... 100644 100644 100644 aaaa bbbb src/lib.rs
1 M. N... 100644 100644 100644 aaaa bbbb src/exec.rs
1 MM N... 100644 100644 100644 aaaa bbbb src/hist.rs
2 R. N... 100644 100644 100644 aaaa bbbb R100 new.rs\tnew_old.rs
u UU N... 100644 100644 100644 100644 aaaa bbbb cccc conflict.rs
? untracked_a.rs
? untracked_b.rs
";
let mut s = GitStatus::default();
parse_porcelain_v2(out, &mut s);
assert_eq!(s.commit, "47a386b42798b5ed5d89bc617920d9152479013e");
assert_eq!(s.branch, "main");
assert_eq!(s.remote_branch, "origin/main");
assert_eq!(s.ahead, 3);
assert_eq!(s.behind, 2);
assert_eq!(s.stashes, 1);
assert_eq!(s.staged, 3);
assert_eq!(s.unstaged, 2);
assert_eq!(s.conflicted, 1);
assert_eq!(s.untracked, 2);
}
#[test]
fn porcelain_detached_and_initial() {
let out = "\
# branch.oid (initial)
# branch.head (detached)
";
let mut s = GitStatus::default();
parse_porcelain_v2(out, &mut s);
assert_eq!(s.commit, "");
assert_eq!(s.branch, "");
assert_eq!(s.remote_branch, "");
assert_eq!(s.ahead, 0);
assert_eq!(s.behind, 0);
assert_eq!(s.stashes, 0);
}
#[test]
fn porcelain_does_not_clobber_head_read() {
let out = "\
# branch.oid ffffffffffffffffffffffffffffffffffffffff
# branch.head porcelain-branch
";
let mut s = GitStatus {
branch: "head-branch".into(),
commit: "1111111111111111111111111111111111111111".into(),
..GitStatus::default()
};
parse_porcelain_v2(out, &mut s);
assert_eq!(s.branch, "head-branch");
assert_eq!(s.commit, "1111111111111111111111111111111111111111");
}
#[test]
fn porcelain_no_upstream_no_stash_line() {
let out = "\
# branch.oid aaaa
# branch.head feature/x
1 A. N... 000000 100644 100644 0000 aaaa new_file.rs
";
let mut s = GitStatus::default();
parse_porcelain_v2(out, &mut s);
assert_eq!(s.branch, "feature/x");
assert_eq!(s.remote_branch, "");
assert_eq!(s.ahead, 0);
assert_eq!(s.behind, 0);
assert_eq!(s.staged, 1);
assert_eq!(s.unstaged, 0);
}
#[test]
fn porcelain_ignores_ignored_entries_and_short_lines() {
let out = "! ignored.log\n1\n\n? u.rs\n";
let mut s = GitStatus::default();
parse_porcelain_v2(out, &mut s);
assert_eq!(s.staged, 0);
assert_eq!(s.unstaged, 0);
assert_eq!(s.untracked, 1);
}
fn caps() -> RepoCaps {
RepoCaps {
trust_filemode: true,
has_symlinks: true,
hash_len: 20,
}
}
fn v2_entry(
path: &[u8],
mode: u32,
ino: u32,
size: u32,
mt_s: u32,
mt_ns: u32,
stage: u16,
) -> Vec<u8> {
let mut e = Vec::new();
for v in [0u32, 0, mt_s, mt_ns, 0, ino, mode, 0, 0, size] {
e.extend_from_slice(&v.to_be_bytes());
}
e.extend_from_slice(&[0u8; 20]); let flags: u16 = ((stage & 0x3) << 12) | (path.len().min(0xFFF) as u16);
e.extend_from_slice(&flags.to_be_bytes());
e.extend_from_slice(path);
let total = (62 + path.len() + 8) & !7;
e.resize(total, 0);
e
}
fn v2_index(entries: &[Vec<u8>]) -> Vec<u8> {
let mut d = b"DIRC".to_vec();
d.extend_from_slice(&2u32.to_be_bytes());
d.extend_from_slice(&(entries.len() as u32).to_be_bytes());
for e in entries {
d.extend_from_slice(e);
}
d.extend_from_slice(&[0u8; 20]); d
}
fn temp_repo(index: &[u8]) -> (tempfile::TempDir, Repo) {
let td = tempfile::tempdir().expect("tempdir");
let git_dir = td.path().join(".git");
fs::create_dir_all(&git_dir).expect("mkdir .git");
fs::write(git_dir.join("index"), index).expect("write index");
let repo = Repo {
common_dir: git_dir.clone(),
git_dir,
work_dir: td.path().to_path_buf(),
};
(td, repo)
}
#[test]
fn index_header_rejects_bad_magic_and_version() {
let mut bad = b"XDIR".to_vec();
bad.extend_from_slice(&2u32.to_be_bytes());
bad.extend_from_slice(&0u32.to_be_bytes());
bad.extend_from_slice(&[0u8; 20]);
let (_td, repo) = temp_repo(&bad);
assert!(read_index_counts(&repo, &caps()).is_none());
let mut v5 = b"DIRC".to_vec();
v5.extend_from_slice(&5u32.to_be_bytes());
v5.extend_from_slice(&0u32.to_be_bytes());
v5.extend_from_slice(&[0u8; 20]);
let (_td, repo) = temp_repo(&v5);
assert!(read_index_counts(&repo, &caps()).is_none());
}
#[test]
fn index_v2_empty_and_conflicts() {
let (_td, repo) = temp_repo(&v2_index(&[]));
let ic = read_index_counts(&repo, &caps()).expect("empty index parses");
assert_eq!(ic.unstaged, 0);
assert_eq!(ic.conflicted, 0);
let e1 = v2_entry(b"c.rs", 0o100644, 1, 5, 1, 0, 1);
let e2 = v2_entry(b"c.rs", 0o100644, 1, 5, 1, 0, 2);
let (_td, repo) = temp_repo(&v2_index(&[e1, e2]));
let ic = read_index_counts(&repo, &caps()).expect("parses");
assert_eq!(ic.conflicted, 1);
assert_eq!(ic.unstaged, 0);
}
#[test]
fn index_v2_lstat_clean_deleted_modified() {
use std::os::unix::fs::MetadataExt;
let td = tempfile::tempdir().expect("tempdir");
let f = td.path().join("a.rs");
fs::write(&f, b"x").expect("write file");
let st = fs::symlink_metadata(&f).expect("stat");
let mode = S_IFREG | if st.mode() & 0o100 != 0 { 0o755 } else { 0o644 };
let clean = v2_entry(
b"a.rs",
mode,
st.ino() as u32,
st.size() as u32,
st.mtime() as u32,
st.mtime_nsec() as u32,
0,
);
let dirty = v2_entry(
b"b.rs",
mode,
0,
st.size() as u32 + 7,
st.mtime() as u32,
0,
0,
);
fs::write(td.path().join("b.rs"), b"x").expect("write b");
let gone = v2_entry(b"gone.rs", mode, 0, 1, 1, 0, 0);
let git_dir = td.path().join(".git");
fs::create_dir_all(&git_dir).expect("mkdir");
fs::write(git_dir.join("index"), v2_index(&[clean, dirty, gone])).expect("write index");
let repo = Repo {
common_dir: git_dir.clone(),
git_dir,
work_dir: td.path().to_path_buf(),
};
let ic = read_index_counts(&repo, &caps()).expect("parses");
assert_eq!(ic.unstaged, 2); assert_eq!(ic.conflicted, 0);
}
#[test]
fn varint_offset_encoding() {
let mut p = 0;
assert_eq!(decode_varint(&[0x7f], &mut p), Some(127));
let mut p = 0;
assert_eq!(decode_varint(&[0x80, 0x01], &mut p), Some(129));
assert_eq!(p, 2);
let mut p = 0;
assert_eq!(decode_varint(&[0x80], &mut p), None);
}
const C: &str = "47a386b42798b5ed5d89bc617920d9152479013e";
#[test]
fn packed_refs_tag_peel_and_max_name() {
let td = tempfile::tempdir().expect("tempdir");
let packed = format!(
"# pack-refs with: peeled fully-peeled sorted \n\
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/tags/v0.5.0\n\
{C} refs/tags/v0.9.0\n\
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb refs/tags/v1.0.0\n\
^{C}\n"
);
fs::write(td.path().join("packed-refs"), packed).expect("write");
assert_eq!(tag_for_commit(td.path(), C), "v1.0.0");
let tags = td.path().join("refs/tags");
fs::create_dir_all(&tags).expect("mkdir");
fs::write(tags.join("v2.0"), format!("{C}\n")).expect("write");
assert_eq!(tag_for_commit(td.path(), C), "v2.0");
}
#[test]
fn packed_refs_without_header_drops_packed_tags() {
let td = tempfile::tempdir().expect("tempdir");
fs::write(
td.path().join("packed-refs"),
format!("{C} refs/tags/v9.9.9\n"),
)
.expect("write");
assert_eq!(tag_for_commit(td.path(), C), "");
}
#[test]
fn stash_log_count() {
let td = tempfile::tempdir().expect("tempdir");
assert_eq!(stash_count(td.path()), Some(0)); let logs = td.path().join("logs/refs");
fs::create_dir_all(&logs).expect("mkdir");
fs::write(
logs.join("stash"),
"0000 1111 user <u@example.com> 1 +0000\tWIP one\n\
1111 2222 user <u@example.com> 2 +0000\tWIP two\n",
)
.expect("write");
assert_eq!(stash_count(td.path()), Some(2));
}
#[test]
fn config_caps_and_tracking_through_fetch_refspecs() {
let td = tempfile::tempdir().expect("tempdir");
let common = td.path();
let ref_file = |name: &str, oid: &str| {
let p = common.join(name);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, format!("{oid}\n")).unwrap();
};
ref_file("refs/remotes/origin/feature/x", &"a".repeat(40));
ref_file("refs/remotes/mirror/feature/x", &"b".repeat(40));
ref_file("refs/heads/main", &"c".repeat(40));
let cfg = GitConfig::parse(
"[core]\n\
\tfilemode = false\n\
\tsymlinks = true\n\
[extensions]\n\
\tobjectformat = sha256\n\
[remote \"origin\"]\n\
\turl = git@example.com:o.git\n\
\tfetch = +refs/heads/*:refs/remotes/origin/*\n\
[remote \"mirror\"]\n\
\turl = git@example.com:m.git\n\
\tfetch = +refs/heads/feature/*:refs/remotes/mirror/feature/*\n\
[remote \"nofetch\"]\n\
\turl = x\n\
[remote]\n\
\tpushDefault = mirror\n\
[branch \"feature/x\"]\n\
\tremote = origin\n\
\tmerge = refs/heads/feature/x\n\
[branch \"local\"]\n\
\tremote = .\n\
\tmerge = refs/heads/main\n\
[branch \"gone\"]\n\
\tremote = origin\n\
\tmerge = refs/heads/gone\n\
[branch \"nofetch\"]\n\
\tremote = nofetch\n\
\tmerge = refs/heads/nofetch\n\
[branch \"pushed\"]\n\
\tremote = origin\n\
\tmerge = refs/heads/pushed\n\
\tpushRemote = origin\n",
);
let t = tracking_of(&cfg, common, "feature/x").expect("tracking");
assert_eq!((t.remote.as_str(), t.branch.as_str()), ("origin", "feature/x"));
assert_eq!(t.tip, "a".repeat(40));
let l = tracking_of(&cfg, common, "local").expect("local upstream");
assert_eq!((l.remote.as_str(), l.branch.as_str()), (".", "main"));
assert!(tracking_of(&cfg, common, "gone").is_none());
assert!(tracking_of(&cfg, common, "nofetch").is_none());
assert!(tracking_of(&cfg, common, "nope").is_none());
let p = push_target_of(&cfg, common, "feature/x").expect("push target");
assert_eq!(p.remote, "mirror");
assert_eq!(p.url, "git@example.com:m.git");
assert_eq!(p.refname, "refs/remotes/mirror/feature/x");
assert_eq!(p.tip, "b".repeat(40));
assert!(push_target_of(&cfg, common, "other").is_none());
assert!(push_target_of(&cfg, common, "pushed").is_none());
let caps = RepoCaps::from_config(&cfg);
assert!(!caps.trust_filemode);
assert!(caps.has_symlinks);
assert_eq!(caps.hash_len, 32);
}
#[test]
fn refspec_transform_handles_globs_and_exact_names() {
assert_eq!(
refspec_transform("+refs/heads/*:refs/remotes/o/*", "refs/heads/a/b").as_deref(),
Some("refs/remotes/o/a/b")
);
assert_eq!(
refspec_transform("refs/heads/main:refs/remotes/o/trunk", "refs/heads/main").as_deref(),
Some("refs/remotes/o/trunk")
);
assert_eq!(refspec_transform("refs/heads/main:x", "refs/heads/other"), None);
assert_eq!(refspec_transform("refs/tags/*:refs/tags/*", "refs/heads/a"), None);
}
#[test]
fn limits_zero_oversized_index_and_clamp_counts() {
let mut s = GitStatus {
staged: 5,
unstaged: 4,
untracked: 9,
conflicted: 3,
staged_new: 4,
staged_deleted: 2,
unstaged_deleted: 4,
..Default::default()
};
GitLimits::default().apply(&mut s, 100);
assert_eq!((s.staged, s.unstaged, s.untracked, s.conflicted), (1, 1, 1, 1));
assert_eq!((s.staged_new, s.staged_deleted, s.unstaged_deleted), (1, 1, 1));
assert!(!s.dirty_unknown);
let mut s = GitStatus { staged: 5, unstaged: 4, untracked: 9, conflicted: 3, ..Default::default() };
let lim = GitLimits {
max_staged: -1,
max_unstaged: 3,
max_untracked: -1,
max_conflicted: 0,
dirty_max_index_size: 50,
recurse_untracked_dirs: false,
};
lim.apply(&mut s, 49);
assert_eq!((s.staged, s.unstaged, s.untracked, s.conflicted), (5, 3, 9, 0));
let mut s = GitStatus { staged: 5, unstaged: 4, untracked: 9, conflicted: 0, ..Default::default() };
lim.apply(&mut s, 51);
assert_eq!((s.staged, s.unstaged, s.untracked), (5, 0, 0));
assert!(s.dirty_unknown);
}
#[test]
fn porcelain_classifies_new_and_deleted() {
let mut s = GitStatus::default();
parse_porcelain_v2(
"1 A. N... 000000 100644 100644 a b new.rs\n\
1 D. N... 100644 000000 000000 a b gone.rs\n\
1 .D N... 100644 100644 000000 a b removed.rs\n\
1 MM N... 100644 100644 100644 a b both.rs\n",
&mut s,
);
assert_eq!((s.staged, s.staged_new, s.staged_deleted), (3, 1, 1));
assert_eq!((s.unstaged, s.unstaged_deleted), (2, 1));
}
#[test]
fn commit_summary_is_the_collapsed_first_paragraph() {
assert_eq!(commit_summary_of("fix the thing\n\nlong body\n"), "fix the thing");
assert_eq!(commit_summary_of("\n\nleading blanks\n"), "leading blanks");
assert_eq!(
commit_summary_of("wrapped\nsubject line\nthird\n\nbody"),
"wrapped subject line third"
);
assert_eq!(commit_summary_of("a b \n \nbody"), "a b");
assert_eq!(commit_summary_of(&"x".repeat(400)).len(), MAX_COMMIT_SUMMARY_LEN);
assert_eq!(commit_summary_of(""), "");
}
}