#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![warn(clippy::pedantic)]
#[cfg(feature = "process")]
use std::ffi::OsString;
#[cfg(feature = "process")]
use std::path::Path;
#[cfg(feature = "process")]
use std::process::Command;
#[cfg(feature = "process")]
use std::sync::OnceLock;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Change {
Added,
Modified,
Deleted,
Renamed,
Untracked,
Conflicted,
}
impl Change {
#[must_use]
pub fn letter(self) -> char {
match self {
Change::Added => 'A',
Change::Modified => 'M',
Change::Deleted => 'D',
Change::Renamed => 'R',
Change::Untracked => '?',
Change::Conflicted => 'U',
}
}
fn from_code(c: char) -> Option<Change> {
match c {
'A' => Some(Change::Added),
'M' | 'C' => Some(Change::Modified),
'D' => Some(Change::Deleted),
'R' => Some(Change::Renamed),
'?' => Some(Change::Untracked),
'U' => Some(Change::Conflicted),
_ => None,
}
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct FileStatus {
pub path: String,
pub staged: Option<Change>,
pub unstaged: Option<Change>,
}
impl FileStatus {
#[must_use]
pub fn is_staged(&self) -> bool {
self.staged.is_some()
}
#[must_use]
pub fn primary(&self) -> Option<Change> {
self.staged.or(self.unstaged)
}
}
#[must_use]
pub fn parse_status(output: &str) -> Vec<FileStatus> {
let mut out = Vec::new();
for line in output.lines() {
if line.len() < 3 {
continue;
}
let bytes = line.as_bytes();
let x = bytes[0] as char;
let y = bytes[1] as char;
let rest = line[3..].trim_end();
let path = match rest.rsplit_once(" -> ") {
Some((_, new)) => new.to_string(),
None => rest.to_string(),
};
if path.is_empty() {
continue;
}
let (staged, unstaged) = if x == '?' && y == '?' {
(None, Some(Change::Untracked))
} else {
(Change::from_code(x), Change::from_code(y))
};
out.push(FileStatus {
path,
staged,
unstaged,
});
}
out
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum LineMark {
Added,
Modified,
Deleted,
}
const GUTTER_DIFF_MAX_LINES: usize = 50_000;
const GUTTER_DIFF_DEADLINE: std::time::Duration = std::time::Duration::from_millis(40);
#[must_use]
pub fn diff_marks(head: &str, current: &str) -> Vec<(usize, LineMark)> {
use similar::{Algorithm, DiffOp, TextDiff};
let current_lines = current.lines().count();
if current_lines.max(head.lines().count()) > GUTTER_DIFF_MAX_LINES {
return Vec::new();
}
let diff = TextDiff::configure()
.algorithm(Algorithm::Myers)
.timeout(GUTTER_DIFF_DEADLINE)
.diff_lines(head, current);
let mut marks = Vec::new();
for op in diff.ops() {
match *op {
DiffOp::Insert {
new_index, new_len, ..
} => {
for i in new_index..new_index + new_len {
marks.push((i, LineMark::Added));
}
}
DiffOp::Replace {
new_index, new_len, ..
} => {
for i in new_index..new_index + new_len {
marks.push((i, LineMark::Modified));
}
}
DiffOp::Delete { new_index, .. } => {
let at = new_index.min(current_lines.saturating_sub(1));
marks.push((at, LineMark::Deleted));
}
DiffOp::Equal { .. } => {}
}
}
marks
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Hunk {
pub current_start: usize,
pub current_end: usize,
pub head_start: usize,
pub head_text: String,
}
impl Hunk {
#[must_use]
pub fn contains(&self, line: usize) -> bool {
if self.current_start == self.current_end {
line == self.current_start
} else {
line >= self.current_start && line < self.current_end
}
}
}
#[must_use]
pub fn hunks(head: &str, current: &str) -> Vec<Hunk> {
use similar::{Algorithm, DiffOp, TextDiff};
let diff = TextDiff::configure()
.algorithm(Algorithm::Myers)
.diff_lines(head, current);
let head_lines: Vec<&str> = head.split_inclusive('\n').collect();
let mut out: Vec<Hunk> = Vec::new();
let mut run: Option<(usize, usize, usize, usize)> = None;
let flush = |run: &mut Option<(usize, usize, usize, usize)>, out: &mut Vec<Hunk>| {
if let Some((nlo, nhi, olo, ohi)) = run.take() {
let head_text = head_lines
.get(olo..ohi)
.map(<[&str]>::concat)
.unwrap_or_default();
out.push(Hunk {
current_start: nlo,
current_end: nhi,
head_start: olo,
head_text,
});
}
};
for op in diff.ops() {
let (ni, nl, oi, ol) = match *op {
DiffOp::Equal { .. } => {
flush(&mut run, &mut out);
continue;
}
DiffOp::Insert {
new_index,
new_len,
old_index,
..
} => (new_index, new_len, old_index, 0),
DiffOp::Delete {
new_index,
old_index,
old_len,
..
} => (new_index, 0, old_index, old_len),
DiffOp::Replace {
new_index,
new_len,
old_index,
old_len,
..
} => (new_index, new_len, old_index, old_len),
};
run = Some(match run {
Some((nlo, nhi, olo, ohi)) => {
(nlo.min(ni), nhi.max(ni + nl), olo.min(oi), ohi.max(oi + ol))
}
None => (ni, ni + nl, oi, oi + ol),
});
}
flush(&mut run, &mut out);
out
}
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct BlameLine {
pub hash: String,
pub author: String,
pub date: String,
pub summary: String,
}
impl BlameLine {
#[must_use]
pub fn is_uncommitted(&self) -> bool {
self.hash.chars().all(|c| c == '0')
}
}
#[must_use]
pub fn parse_blame_porcelain(output: &str) -> Option<BlameLine> {
let mut hash = None;
let mut author = String::new();
let mut summary = String::new();
let mut time: i64 = 0;
let mut tz: i32 = 0;
for line in output.lines() {
if hash.is_none() {
if let Some(h) = line.split(' ').next().filter(|h| h.len() == 40) {
hash = Some(h.chars().take(8).collect::<String>());
}
continue;
}
if let Some(name) = line.strip_prefix("author ") {
author = name.to_string();
} else if let Some(secs) = line.strip_prefix("author-time ") {
time = secs.trim().parse().unwrap_or(0);
} else if let Some(off) = line.strip_prefix("author-tz ") {
tz = parse_tz_offset(off.trim());
} else if let Some(s) = line.strip_prefix("summary ") {
summary = s.to_string();
}
}
let hash = hash?;
Some(BlameLine {
hash,
author,
date: epoch_to_date(time, tz),
summary,
})
}
fn parse_tz_offset(tz: &str) -> i32 {
let sign = if tz.starts_with('-') { -1 } else { 1 };
let digits: String = tz.chars().filter(char::is_ascii_digit).collect();
if digits.len() < 4 {
return 0;
}
let hours: i32 = digits[0..2].parse().unwrap_or(0);
let mins: i32 = digits[2..4].parse().unwrap_or(0);
sign * (hours * 3600 + mins * 60)
}
#[must_use]
fn epoch_to_date(secs: i64, tz_offset: i32) -> String {
let days = (secs + i64::from(tz_offset)).div_euclid(86_400);
let (y, m, d) = vix_civil_date::civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}")
}
#[cfg(feature = "process")]
#[must_use]
pub fn blame_line(dir: &Path, rel_path: &str, line: usize) -> Option<BlameLine> {
let spec = format!("{line},{line}");
let out = git_stdout(
dir,
&["blame", "--line-porcelain", "-L", &spec, "--", rel_path],
)?;
parse_blame_porcelain(&out)
}
#[cfg(feature = "process")]
fn git_program() -> &'static OsString {
static PROG: OnceLock<OsString> = OnceLock::new();
PROG.get_or_init(|| which_on_path("git").unwrap_or_else(|| "git".into()))
}
#[cfg(feature = "process")]
fn which_on_path(name: &str) -> Option<OsString> {
let exts: Vec<String> = if cfg!(windows) {
std::env::var("PATHEXT")
.unwrap_or_else(|_| ".EXE".into())
.split(';')
.map(str::to_string)
.collect()
} else {
vec![String::new()]
};
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
if dir.as_os_str().is_empty() {
continue; }
for ext in &exts {
let candidate = dir.join(format!("{name}{ext}"));
if candidate.is_file() {
return Some(candidate.into_os_string());
}
}
}
None
}
#[cfg(feature = "process")]
fn valid_ref_name(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with('-')
&& !name.chars().any(|c| c.is_whitespace() || c.is_control())
}
#[cfg(feature = "process")]
fn git(dir: &Path, args: &[&str]) -> std::io::Result<std::process::Output> {
Command::new(git_program())
.current_dir(dir)
.args(args)
.output()
}
#[cfg(feature = "process")]
fn git_stdout(dir: &Path, args: &[&str]) -> Option<String> {
let out = git(dir, args).ok()?;
if out.status.success() {
Some(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
} else {
None
}
}
#[cfg(feature = "process")]
#[must_use]
pub fn is_repo(dir: &Path) -> bool {
git_stdout(dir, &["rev-parse", "--is-inside-work-tree"]).as_deref() == Some("true")
}
#[cfg(feature = "process")]
#[must_use]
pub fn branch(dir: &Path) -> Option<String> {
let name = git_stdout(dir, &["rev-parse", "--abbrev-ref", "HEAD"])?;
if name == "HEAD" {
git_stdout(dir, &["rev-parse", "--short", "HEAD"])
} else {
Some(name)
}
}
#[cfg(feature = "process")]
#[must_use]
pub fn is_dirty(dir: &Path) -> bool {
git_stdout(dir, &["status", "--porcelain"]).is_some_and(|s| !s.is_empty())
}
#[cfg(feature = "process")]
#[must_use]
pub fn status(dir: &Path) -> Vec<FileStatus> {
git_stdout(dir, &["status", "--porcelain"])
.map(|s| parse_status(&s))
.unwrap_or_default()
}
#[cfg(feature = "process")]
#[must_use]
pub fn head_blob(dir: &Path, rel_path: &str) -> Option<String> {
let out = git(dir, &["show", &format!("HEAD:{rel_path}")]).ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}
#[cfg(feature = "process")]
pub fn stage_all(dir: &Path) -> Result<(), String> {
let out = git(dir, &["add", "-A"]).map_err(|e| e.to_string())?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
#[cfg(feature = "process")]
pub fn stage(dir: &Path, rel_path: &str) -> Result<(), String> {
let out = git(dir, &["add", "--", rel_path]).map_err(|e| e.to_string())?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
#[cfg(feature = "process")]
#[must_use]
pub fn index_blob(dir: &Path, rel_path: &str) -> Option<String> {
let out = git(dir, &["show", &format!(":{rel_path}")]).ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}
#[cfg(feature = "process")]
pub fn stage_content(dir: &Path, rel_path: &str, content: &str) -> Result<(), String> {
let hash = git_stdin(
dir,
&["hash-object", "-w", "--path", rel_path, "--stdin"],
content,
)?;
let sha = hash.trim();
let mode = git_stdout(dir, &["ls-files", "-s", "--", rel_path])
.and_then(|s| s.split_whitespace().next().map(str::to_string))
.unwrap_or_else(|| "100644".to_string());
let info = format!("{mode},{sha},{rel_path}");
let out =
git(dir, &["update-index", "--add", "--cacheinfo", &info]).map_err(|e| e.to_string())?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
#[cfg(feature = "process")]
fn git_stdin(dir: &Path, args: &[&str], input: &str) -> Result<String, String> {
use std::io::Write;
use std::process::Stdio;
let mut child = Command::new(git_program())
.current_dir(dir)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| e.to_string())?;
child
.stdin
.take()
.ok_or_else(|| "no stdin".to_string())?
.write_all(input.as_bytes())
.map_err(|e| e.to_string())?;
let out = child.wait_with_output().map_err(|e| e.to_string())?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
#[cfg(feature = "process")]
pub fn unstage(dir: &Path, rel_path: &str) -> Result<(), String> {
let out = git(dir, &["restore", "--staged", "--", rel_path]).map_err(|e| e.to_string())?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
#[cfg(feature = "process")]
#[must_use]
pub fn staged_diff(dir: &Path) -> Option<String> {
git_stdout(dir, &["diff", "--staged"]).filter(|d| !d.is_empty())
}
#[cfg(feature = "process")]
#[must_use]
pub fn commit_count(dir: &Path) -> Option<u64> {
git_stdout(dir, &["rev-list", "--count", "HEAD"])?
.trim()
.parse()
.ok()
}
#[cfg(feature = "process")]
#[must_use]
pub fn local_branches(dir: &Path) -> Vec<String> {
let out = git_stdout(dir, &["branch", "--format=%(refname:short)"]).unwrap_or_default();
let mut names: Vec<String> = out
.lines()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
if let Some(cur) = branch(dir)
&& let Some(pos) = names.iter().position(|n| *n == cur)
{
names.swap(0, pos);
}
names
}
#[cfg(feature = "process")]
pub fn checkout(dir: &Path, branch: &str) -> Result<(), String> {
if !valid_ref_name(branch) {
return Err(format!("invalid branch name: {branch:?}"));
}
match git(dir, &["switch", "--end-of-options", branch]) {
Ok(o) if o.status.success() => Ok(()),
Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
Err(e) => Err(e.to_string()),
}
}
#[cfg(feature = "process")]
pub fn create_branch(dir: &Path, name: &str) -> Result<(), String> {
if !valid_ref_name(name) {
return Err(format!("invalid branch name: {name:?}"));
}
match git(dir, &["switch", "-c", name]) {
Ok(o) if o.status.success() => Ok(()),
Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
Err(e) => Err(e.to_string()),
}
}
#[cfg(feature = "process")]
pub fn commit(dir: &Path, message: &str) -> Result<(), String> {
run_ok(dir, &["commit", "-m", message])
}
#[must_use]
pub fn pinentry_wants_tty(agent_conf: &str) -> bool {
agent_conf
.lines()
.filter_map(|l| {
let l = l.trim();
if l.starts_with('#') {
return None;
}
l.strip_prefix("pinentry-program")
.filter(|rest| rest.starts_with(char::is_whitespace))
.map(str::trim)
})
.next_back()
.is_some_and(|program| {
let name = program.rsplit(['/', '\\']).next().unwrap_or(program);
name.contains("curses") || name.contains("tty")
})
}
#[cfg(feature = "process")]
#[must_use]
pub fn commit_signing_needs_tty(dir: &Path) -> bool {
if git_stdout(dir, &["config", "--bool", "--get", "commit.gpgsign"]).as_deref() != Some("true")
{
return false;
}
if git_stdout(dir, &["config", "--get", "gpg.format"]).as_deref() == Some("ssh") {
return false;
}
let home = std::env::var_os("GNUPGHOME")
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".gnupg")));
home.and_then(|h| std::fs::read_to_string(h.join("gpg-agent.conf")).ok())
.is_some_and(|conf| pinentry_wants_tty(&conf))
}
#[cfg(feature = "process")]
pub fn commit_amend(dir: &Path) -> Result<(), String> {
run_ok(dir, &["commit", "--amend", "--no-edit"])
}
#[cfg(feature = "process")]
pub fn stash_push(dir: &Path) -> Result<(), String> {
run_ok(dir, &["stash", "push"])
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogEntry {
pub sha: String,
pub abbrev: String,
pub author: String,
pub date: String,
pub subject: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LogScope {
Repo,
File(String),
}
pub struct LogPanel {
pub entries: Vec<LogEntry>,
pub scope: LogScope,
pub selected: usize,
pub scroll: usize,
}
impl LogPanel {
#[must_use]
pub fn new(entries: Vec<LogEntry>, scope: LogScope) -> Self {
LogPanel {
entries,
scope,
selected: 0,
scroll: 0,
}
}
pub fn up(&mut self) {
self.selected = vix_list_state::up(self.selected);
}
pub fn down(&mut self) {
self.selected = vix_list_state::down(self.selected, self.entries.len());
}
pub fn page_up(&mut self, page: usize) {
self.selected = vix_list_state::page_up(self.selected, page);
}
pub fn page_down(&mut self, page: usize) {
self.selected = vix_list_state::page_down(self.selected, page, self.entries.len());
}
pub fn ensure_visible(&mut self, height: usize) {
self.scroll =
vix_list_state::ensure_visible(self.selected, self.scroll, height, self.entries.len());
}
#[must_use]
pub fn selected_sha(&self) -> Option<&str> {
self.entries.get(self.selected).map(|e| e.sha.as_str())
}
#[must_use]
pub fn selected_abbrev(&self) -> Option<&str> {
self.entries.get(self.selected).map(|e| e.abbrev.as_str())
}
}
#[cfg(feature = "process")]
const LOG_FIELD_SEP: &str = "\u{1f}";
#[cfg(feature = "process")]
const LOG_RECORD_SEP: &str = "\u{1e}";
#[cfg(feature = "process")]
fn parse_log(output: &str) -> Vec<LogEntry> {
output
.split(LOG_RECORD_SEP)
.filter_map(|record| {
let record = record.trim_start_matches('\n');
let mut fields = record.split(LOG_FIELD_SEP);
let sha = fields.next()?.to_string();
let abbrev = fields.next()?.to_string();
let author = fields.next()?.to_string();
let date = fields.next()?.to_string();
let subject = fields.next()?.to_string();
if sha.is_empty() {
return None;
}
Some(LogEntry {
sha,
abbrev,
author,
date,
subject,
})
})
.collect()
}
#[cfg(feature = "process")]
fn log_format_arg() -> String {
format!(
"--format=%H{LOG_FIELD_SEP}%h{LOG_FIELD_SEP}%an{LOG_FIELD_SEP}%ad{LOG_FIELD_SEP}%s{LOG_RECORD_SEP}"
)
}
#[cfg(feature = "process")]
#[must_use]
pub fn log(dir: &Path, max: usize) -> Vec<LogEntry> {
let n = max.to_string();
let fmt = log_format_arg();
git_stdout(dir, &["log", &fmt, "--date=short", "-n", &n])
.map(|s| parse_log(&s))
.unwrap_or_default()
}
#[cfg(feature = "process")]
#[must_use]
pub fn file_log(dir: &Path, rel_path: &str, max: usize) -> Vec<LogEntry> {
let n = max.to_string();
let fmt = log_format_arg();
git_stdout(
dir,
&[
"log",
&fmt,
"--date=short",
"--follow",
"-n",
&n,
"--",
rel_path,
],
)
.map(|s| parse_log(&s))
.unwrap_or_default()
}
#[cfg(feature = "process")]
#[must_use]
pub fn show_commit(dir: &Path, sha: &str, rel_path: Option<&str>) -> Option<String> {
if !valid_ref_name(sha) {
return None;
}
let mut args = vec!["show", "--end-of-options", sha];
if let Some(p) = rel_path {
args.push("--");
args.push(p);
}
git_stdout(dir, &args)
}
#[cfg(feature = "process")]
#[must_use]
pub fn show_file_at(dir: &Path, revision: &str, rel_path: &str) -> Option<String> {
if !valid_ref_name(revision) {
return None;
}
let out = git(
dir,
&[
"show",
"--end-of-options",
&format!("{revision}:{rel_path}"),
],
)
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}
#[cfg(feature = "process")]
#[must_use]
pub fn resolve_short_sha(dir: &Path, revision: &str) -> Option<String> {
if !valid_ref_name(revision) {
return None;
}
git_stdout(dir, &["rev-parse", "--short", "--end-of-options", revision])
}
#[cfg(feature = "process")]
pub fn stash_pop(dir: &Path) -> Result<(), String> {
run_ok(dir, &["stash", "pop"])
}
#[cfg(feature = "process")]
fn run_ok(dir: &Path, args: &[&str]) -> Result<(), String> {
match git(dir, args) {
Ok(o) if o.status.success() => Ok(()),
Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
Err(e) => Err(e.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "process")]
#[test]
fn valid_ref_name_rejects_option_shaped_and_control_input() {
assert!(valid_ref_name("main"));
assert!(valid_ref_name("feature/x-y"));
assert!(!valid_ref_name("--detach"));
assert!(!valid_ref_name("-oProxyCommand=x"));
assert!(!valid_ref_name("--upload-pack=evil"));
assert!(!valid_ref_name(""));
assert!(!valid_ref_name("a b"));
assert!(!valid_ref_name("a\nb"));
}
#[cfg(feature = "process")]
#[test]
fn checkout_and_create_branch_refuse_dash_prefixed_names() {
let dir = std::env::temp_dir();
assert!(checkout(&dir, "--detach").is_err());
assert!(create_branch(&dir, "-c").is_err());
assert!(create_branch(&dir, "--orphan").is_err());
}
#[cfg(feature = "process")]
#[test]
fn show_commit_and_show_file_at_refuse_option_shaped_revisions() {
let dir = std::env::temp_dir();
assert!(show_commit(&dir, "--upload-pack=evil", None).is_none());
assert!(show_file_at(&dir, "--upload-pack=evil", "a.rs").is_none());
assert!(resolve_short_sha(&dir, "--upload-pack=evil").is_none());
}
#[cfg(feature = "process")]
#[test]
fn stage_and_unstage_report_the_real_git_error() {
let dir = std::env::temp_dir().join(format!("vix-git-notrepo-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let err = stage(&dir, "a.txt").expect_err("not a repo, so this must fail");
assert!(!err.is_empty(), "a real message, not silently discarded");
let err = unstage(&dir, "a.txt").expect_err("not a repo, so this must fail");
assert!(!err.is_empty(), "a real message, not silently discarded");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(feature = "process")]
#[test]
fn parse_log_splits_records_and_fields() {
let output = format!(
"abc123{LOG_FIELD_SEP}abc{LOG_FIELD_SEP}Ada Lovelace{LOG_FIELD_SEP}2026-01-02\
{LOG_FIELD_SEP}Add the analytical engine{LOG_RECORD_SEP}\
def456{LOG_FIELD_SEP}def{LOG_FIELD_SEP}Grace Hopper{LOG_FIELD_SEP}2026-01-01\
{LOG_FIELD_SEP}Initial commit{LOG_RECORD_SEP}"
);
let entries = parse_log(&output);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].sha, "abc123");
assert_eq!(entries[0].abbrev, "abc");
assert_eq!(entries[0].author, "Ada Lovelace");
assert_eq!(entries[0].date, "2026-01-02");
assert_eq!(entries[0].subject, "Add the analytical engine");
assert_eq!(entries[1].author, "Grace Hopper");
}
#[cfg(feature = "process")]
#[test]
fn parse_log_skips_a_trailing_empty_record() {
let output = format!(
"abc123{LOG_FIELD_SEP}abc{LOG_FIELD_SEP}Ada Lovelace{LOG_FIELD_SEP}2026-01-02\
{LOG_FIELD_SEP}subject{LOG_RECORD_SEP}"
);
assert_eq!(parse_log(&output).len(), 1);
}
#[cfg(feature = "process")]
#[test]
fn parse_log_ignores_a_truncated_record() {
let output = format!("abc123{LOG_FIELD_SEP}abc{LOG_FIELD_SEP}only three fields");
assert_eq!(parse_log(&output), [] as [LogEntry; 0]);
}
#[test]
fn log_panel_navigation_reports_the_selected_hashes() {
let mut p = LogPanel::new(
vec![
LogEntry {
sha: "aaa111".into(),
abbrev: "aaa".into(),
author: "A".into(),
date: "2026-01-01".into(),
subject: "first".into(),
},
LogEntry {
sha: "bbb222".into(),
abbrev: "bbb".into(),
author: "B".into(),
date: "2026-01-02".into(),
subject: "second".into(),
},
],
LogScope::Repo,
);
assert_eq!(p.selected_sha(), Some("aaa111"));
assert_eq!(p.selected_abbrev(), Some("aaa"));
p.down();
assert_eq!(p.selected_sha(), Some("bbb222"));
p.down(); assert_eq!(p.selected_sha(), Some("bbb222"));
p.up();
assert_eq!(p.selected_sha(), Some("aaa111"));
}
#[cfg(feature = "process")]
#[test]
fn which_on_path_never_returns_a_relative_or_cwd_path() {
if let Some(p) = which_on_path("git") {
assert!(
Path::new(&p).is_absolute(),
"git resolved to non-absolute: {p:?}"
);
}
}
#[test]
fn hunks_groups_a_modified_line() {
let h = hunks("a\nb\nc\n", "a\nB\nc\n");
assert_eq!(h.len(), 1);
assert_eq!(h[0].current_start, 1);
assert_eq!(h[0].current_end, 2);
assert_eq!(h[0].head_text, "b\n");
assert!(h[0].contains(1));
assert!(!h[0].contains(0));
}
#[test]
fn hunks_addition_has_empty_head_text() {
let h = hunks("a\nc\n", "a\nb1\nb2\nc\n");
assert_eq!(h.len(), 1);
assert_eq!((h[0].current_start, h[0].current_end), (1, 3));
assert_eq!(h[0].head_text, ""); }
#[test]
fn hunks_deletion_anchors_a_zero_width_range() {
let h = hunks("a\nb\nc\n", "a\nc\n");
assert_eq!(h.len(), 1);
assert_eq!(h[0].current_start, h[0].current_end); assert_eq!(h[0].head_text, "b\n"); assert!(h[0].contains(h[0].current_start));
}
#[test]
fn hunks_finds_multiple_regions() {
let h = hunks("a\nb\nc\nd\ne\n", "A\nb\nc\nD\ne\n");
assert_eq!(h.len(), 2);
assert_eq!(h[0].current_start, 0);
assert_eq!(h[1].current_start, 3);
}
#[test]
fn hunks_identical_text_is_empty() {
assert_eq!(hunks("x\ny\n", "x\ny\n"), [] as [Hunk; 0]);
}
#[test]
fn parse_status_reads_xy_codes_and_paths() {
let out = " M src/app.rs\nA src/new.rs\n?? notes.txt\nMM both.rs\nD gone.rs\n";
let rows = parse_status(out);
assert_eq!(rows.len(), 5);
assert_eq!(rows[0].path, "src/app.rs");
assert_eq!(rows[0].staged, None);
assert_eq!(rows[0].unstaged, Some(Change::Modified));
assert_eq!(rows[1].path, "src/new.rs");
assert_eq!(rows[1].staged, Some(Change::Added));
assert_eq!(rows[1].unstaged, None);
assert_eq!(rows[2].path, "notes.txt");
assert_eq!(rows[2].primary(), Some(Change::Untracked));
assert_eq!(rows[3].staged, Some(Change::Modified));
assert_eq!(rows[3].unstaged, Some(Change::Modified));
assert_eq!(rows[4].path, "gone.rs");
assert_eq!(rows[4].staged, Some(Change::Deleted));
}
#[test]
fn parse_status_records_rename_destination() {
let rows = parse_status("R old/name.rs -> new/name.rs\n");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].path, "new/name.rs");
assert_eq!(rows[0].staged, Some(Change::Renamed));
}
#[test]
fn parse_status_skips_blank_and_short_lines() {
assert_eq!(parse_status("\n\nx\n"), [] as [FileStatus; 0]);
}
#[test]
fn diff_marks_flags_added_modified_and_deleted() {
let head = "alpha\nbeta\ngamma\n";
let current = "alpha\nBETA\ngamma\ndelta\n";
let marks = diff_marks(head, current);
assert!(
marks.contains(&(1, LineMark::Modified)),
"line 2 modified: {marks:?}"
);
assert!(
marks.contains(&(3, LineMark::Added)),
"line 4 added: {marks:?}"
);
}
#[test]
fn diff_marks_flags_deletion() {
let head = "a\nb\nc\n";
let current = "a\nc\n"; let marks = diff_marks(head, current);
assert!(
marks.iter().any(|&(_, m)| m == LineMark::Deleted),
"a deletion is marked: {marks:?}"
);
}
#[test]
fn diff_marks_skips_huge_buffers_and_finishes_fast_on_rewrites() {
let big = "x\n".repeat(GUTTER_DIFF_MAX_LINES + 1);
assert_eq!(diff_marks("", &big), vec![]);
let head = (0..20_000).fold(String::new(), |a, i| a + &format!("a{i}\n"));
let cur = (0..20_000).fold(String::new(), |a, i| {
a + &format!(
"b{}
",
i * 7 % 20_000
)
});
let start = std::time::Instant::now();
let _ = diff_marks(&head, &cur);
assert!(start.elapsed() < std::time::Duration::from_secs(2));
}
#[test]
fn pinentry_wants_tty_reads_the_last_program_line() {
assert!(pinentry_wants_tty(
"pinentry-program /usr/bin/pinentry-curses\n"
));
assert!(pinentry_wants_tty(
"pinentry-program /opt/homebrew/bin/pinentry-tty"
));
assert!(!pinentry_wants_tty(
"pinentry-program /usr/bin/pinentry-mac\n"
));
assert!(!pinentry_wants_tty("default-cache-ttl 600\n"));
assert!(!pinentry_wants_tty("# pinentry-program pinentry-curses\n"));
assert!(!pinentry_wants_tty(
"pinentry-program /usr/bin/pinentry-curses\npinentry-program /usr/bin/pinentry-gtk-2\n"
));
assert!(pinentry_wants_tty(
"pinentry-program /usr/bin/pinentry-gtk-2\npinentry-program /usr/bin/pinentry-curses\n"
));
assert!(!pinentry_wants_tty(""));
}
#[test]
fn diff_marks_empty_when_identical() {
assert_eq!(diff_marks("x\ny\n", "x\ny\n"), [] as [(usize, LineMark); 0]);
}
#[test]
fn parse_blame_porcelain_extracts_fields() {
let out = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b 12 12 1\n\
author Ada Lovelace\n\
author-mail <ada@example.com>\n\
author-time 1700000000\n\
author-tz +0000\n\
committer Ada Lovelace\n\
summary Add the analytical engine\n\
filename engine.rs\n\
\tlet x = 1;\n";
let b = parse_blame_porcelain(out).expect("parsed");
assert_eq!(b.hash, "1a2b3c4d");
assert_eq!(b.author, "Ada Lovelace");
assert_eq!(b.date, "2023-11-14");
assert_eq!(b.summary, "Add the analytical engine");
assert!(!b.is_uncommitted());
}
#[test]
fn parse_blame_porcelain_flags_uncommitted() {
let out = "0000000000000000000000000000000000000000 1 1 1\n\
author Not Committed Yet\n\
author-time 1700000000\n\
author-tz +0000\n\
summary Version of engine.rs from engine.rs\n\
\tlet y = 2;\n";
let b = parse_blame_porcelain(out).expect("parsed");
assert!(b.is_uncommitted());
assert_eq!(b.author, "Not Committed Yet");
}
#[test]
fn parse_blame_porcelain_none_without_header() {
assert!(parse_blame_porcelain("not a blame\n").is_none());
}
#[test]
fn epoch_to_date_applies_tz_offset() {
assert_eq!(epoch_to_date(1_700_000_000, 0), "2023-11-14");
assert_eq!(epoch_to_date(1_700_000_000, 2 * 3600), "2023-11-15");
assert_eq!(epoch_to_date(0, 0), "1970-01-01");
}
#[test]
fn change_letters() {
assert_eq!(Change::Modified.letter(), 'M');
assert_eq!(Change::Untracked.letter(), '?');
}
#[cfg(feature = "process")]
#[test]
#[ignore = "needs git and an in-tree checkout"]
fn runners_report_branch_in_this_repo() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR"));
assert!(is_repo(dir));
assert!(branch(dir).is_some());
}
}