use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefKind {
Head,
LocalBranch,
RemoteBranch,
Tag,
}
#[derive(Debug, Clone)]
pub struct RefLabel {
pub kind: RefKind,
pub name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GraphCell {
pub ch: char,
pub color: u8,
}
impl GraphCell {
const BLANK: GraphCell = GraphCell { ch: ' ', color: 0 };
}
#[derive(Debug, Clone)]
pub struct Commit {
pub hash: String,
pub short: String,
pub parents: Vec<String>,
pub author: String,
pub time: i64,
pub subject: String,
pub refs: Vec<RefLabel>,
pub graph: Vec<GraphCell>,
pub lane: usize,
}
pub fn load(workspace: &Path, limit: usize) -> Vec<Commit> {
load_filtered(workspace, limit, &LogFilter::default())
}
#[derive(Debug, Clone, Default)]
pub struct LogFilter {
pub branch: Option<String>,
pub since: Option<String>,
pub until: Option<String>,
pub author: Option<String>,
pub grep: Option<String>,
}
pub fn load_filtered(workspace: &Path, limit: usize, filter: &LogFilter) -> Vec<Commit> {
let refs = load_refs(workspace);
let head = head_hash(workspace);
let fmt = "%H%x1f%P%x1f%an%x1f%at%x1f%s";
let mut args: Vec<String> = vec!["log".into()];
match &filter.branch {
Some(b) if !b.is_empty() => args.push(b.clone()),
_ => args.push("--all".into()),
}
args.push("--date-order".into());
args.push(format!("-n{limit}"));
args.push(format!("--pretty=format:{fmt}"));
if let Some(s) = &filter.since {
args.push(format!("--since={s}"));
}
if let Some(u) = &filter.until {
args.push(format!("--until={u}"));
}
if let Some(a) = &filter.author {
args.push(format!("--author={a}"));
}
if let Some(g) = &filter.grep {
args.push(format!("--grep={g}"));
args.push("--regexp-ignore-case".into());
}
let out = match Command::new("git")
.args(&args)
.current_dir(workspace)
.output()
{
Ok(o) if o.status.success() => o,
_ => return Vec::new(),
};
let mut commits: Vec<Commit> = String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|line| {
let mut f = line.split('\u{1f}');
let hash = f.next()?.to_string();
let parents: Vec<String> = f.next()?.split_whitespace().map(str::to_string).collect();
let author = f.next().unwrap_or("").to_string();
let time = f.next().unwrap_or("0").parse().unwrap_or(0);
let subject = f.next().unwrap_or("").to_string();
let short: String = hash.chars().take(9).collect();
let mut refs: Vec<RefLabel> = refs
.iter()
.filter(|(h, _)| *h == hash)
.map(|(_, r)| r.clone())
.collect();
if head.as_deref() == Some(hash.as_str()) {
refs.insert(
0,
RefLabel {
kind: RefKind::Head,
name: "HEAD".to_string(),
},
);
}
Some(Commit {
hash,
short,
parents,
author,
time,
subject,
refs,
graph: Vec::new(),
lane: 0,
})
})
.collect();
layout(&mut commits);
commits
}
fn layout(commits: &mut [Commit]) {
let mut lanes: Vec<Option<String>> = Vec::new();
let mut lane_cooldown: Vec<u16> = Vec::new();
const COOLDOWN: u16 = 5;
for c in commits.iter_mut() {
for cd in lane_cooldown.iter_mut() {
*cd = cd.saturating_sub(1);
}
let my_lane = match lanes
.iter()
.position(|l| l.as_deref() == Some(c.hash.as_str()))
{
Some(i) => i,
None => {
lanes.push(None);
lane_cooldown.push(0);
lanes.len() - 1
}
};
let merging: Vec<usize> = lanes
.iter()
.enumerate()
.filter(|(i, l)| *i != my_lane && l.as_deref() == Some(c.hash.as_str()))
.map(|(i, _)| i)
.collect();
let mut branch_to: Vec<usize> = Vec::new();
for p in c.parents.iter().skip(1) {
if lanes.iter().any(|l| l.as_deref() == Some(p.as_str())) {
continue; }
let free = lanes
.iter()
.enumerate()
.find(|(i, l)| {
*i != my_lane && l.is_none() && lane_cooldown.get(*i).copied().unwrap_or(0) == 0
})
.map(|(i, _)| i);
let slot = match free {
Some(free) => free,
None => {
lanes.push(None);
lane_cooldown.push(0);
lanes.len() - 1
}
};
lanes[slot] = Some(p.clone());
branch_to.push(slot);
}
let width = lanes.len();
let mut cells = vec![GraphCell::BLANK; width];
for (i, l) in lanes.iter().enumerate() {
let color = (i % LANE_COLORS) as u8;
if i == my_lane {
cells[i] = GraphCell { ch: '●', color };
} else if merging.contains(&i) {
cells[i] = GraphCell {
ch: if i < my_lane { '╰' } else { '╯' },
color,
};
} else if branch_to.contains(&i) {
cells[i] = GraphCell {
ch: if i < my_lane { '╭' } else { '╮' },
color,
};
} else if l.is_some() {
cells[i] = GraphCell { ch: '│', color };
}
}
let mut endpoints: Vec<usize> = merging.iter().chain(branch_to.iter()).copied().collect();
if let (Some(&lo), Some(&hi)) = (
endpoints.iter().chain(std::iter::once(&my_lane)).min(),
endpoints.iter().chain(std::iter::once(&my_lane)).max(),
) {
for cell in cells.iter_mut().take(hi).skip(lo + 1) {
if cell.ch == ' ' {
*cell = GraphCell {
ch: '─',
color: (my_lane % LANE_COLORS) as u8,
};
} else if cell.ch == '│' {
*cell = GraphCell {
ch: '┼',
color: cell.color,
};
}
}
}
endpoints.clear();
c.graph = cells;
c.lane = my_lane;
for i in &merging {
lanes[*i] = None;
if let Some(cd) = lane_cooldown.get_mut(*i) {
*cd = COOLDOWN;
}
}
lanes[my_lane] = c.parents.first().cloned();
if lanes[my_lane].is_none()
&& let Some(cd) = lane_cooldown.get_mut(my_lane)
{
*cd = COOLDOWN;
}
while matches!(lanes.last(), Some(None)) {
lanes.pop();
lane_cooldown.pop();
}
}
}
pub const LANE_COLORS: usize = 6;
fn head_hash(workspace: &Path) -> Option<String> {
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(workspace)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let h = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!h.is_empty()).then_some(h)
}
fn load_refs(workspace: &Path) -> Vec<(String, RefLabel)> {
let out = match Command::new("git")
.args([
"for-each-ref",
"--format=%(objectname) %(refname)",
"refs/heads",
"refs/remotes",
"refs/tags",
])
.current_dir(workspace)
.output()
{
Ok(o) if o.status.success() => o,
_ => return Vec::new(),
};
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|line| {
let (hash, refname) = line.split_once(' ')?;
let (kind, name) = if let Some(n) = refname.strip_prefix("refs/heads/") {
(RefKind::LocalBranch, n.to_string())
} else if let Some(n) = refname.strip_prefix("refs/remotes/") {
if n.ends_with("/HEAD") {
return None; }
(RefKind::RemoteBranch, n.to_string())
} else {
let n = refname.strip_prefix("refs/tags/")?;
(RefKind::Tag, n.to_string())
};
Some((hash.to_string(), RefLabel { kind, name }))
})
.collect()
}
pub fn full_message(workspace: &Path, hash: &str) -> String {
Command::new("git")
.args(["show", "-s", "--format=%B", hash])
.current_dir(workspace)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim_end().to_string())
.unwrap_or_default()
}
#[allow(dead_code)]
pub fn file_at_commit(workspace: &Path, hash: &str, rel_path: &str) -> Option<String> {
let spec = format!("{hash}:{rel_path}");
let out = Command::new("git")
.args(["show", &spec])
.current_dir(workspace)
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
#[derive(Debug, Clone)]
pub struct FileCommit {
pub hash: String,
pub short: String,
pub author: String,
pub time: i64,
pub subject: String,
}
pub fn commits_for_file(workspace: &Path, rel: &str) -> Vec<FileCommit> {
let fmt = "%H%x1f%an%x1f%at%x1f%s";
let out = match Command::new("git")
.args([
"log",
"--follow",
"-n200",
&format!("--pretty=format:{fmt}"),
"--",
rel,
])
.current_dir(workspace)
.output()
{
Ok(o) if o.status.success() => o,
_ => return Vec::new(),
};
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|line| {
let mut f = line.split('\u{1f}');
let hash = f.next()?.to_string();
let author = f.next().unwrap_or("").to_string();
let time = f.next().unwrap_or("0").parse().unwrap_or(0);
let subject = f.next().unwrap_or("").to_string();
let short: String = hash.chars().take(9).collect();
Some(FileCommit {
hash,
short,
author,
time,
subject,
})
})
.collect()
}
pub fn changed_files(workspace: &Path, hash: &str) -> Vec<(String, String)> {
let out = match Command::new("git")
.args(["show", "--name-status", "--format=", hash])
.current_dir(workspace)
.output()
{
Ok(o) if o.status.success() => o,
_ => return Vec::new(),
};
String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| {
let mut it = l.split('\t');
let status = it.next()?.to_string();
let path = it.next_back()?.to_string();
Some((status, path))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_on_non_repo() {
let d = tempfile::tempdir().unwrap();
assert!(load(d.path(), 100).is_empty());
}
#[test]
fn lays_out_a_linear_history() {
let mut commits = vec![
Commit {
hash: "c".into(),
short: "c".into(),
parents: vec!["b".into()],
author: "x".into(),
time: 3,
subject: "third".into(),
refs: vec![],
graph: vec![],
lane: 9,
},
Commit {
hash: "b".into(),
short: "b".into(),
parents: vec!["a".into()],
author: "x".into(),
time: 2,
subject: "second".into(),
refs: vec![],
graph: vec![],
lane: 9,
},
Commit {
hash: "a".into(),
short: "a".into(),
parents: vec![],
author: "x".into(),
time: 1,
subject: "first".into(),
refs: vec![],
graph: vec![],
lane: 9,
},
];
layout(&mut commits);
for c in &commits {
assert_eq!(c.lane, 0);
assert_eq!(c.graph.len(), 1);
assert_eq!(c.graph[0].ch, '●');
}
}
#[test]
fn merge_uses_two_lanes() {
let mut commits = vec![
Commit {
hash: "m".into(),
short: "m".into(),
parents: vec!["p1".into(), "p2".into()],
author: "x".into(),
time: 4,
subject: "merge".into(),
refs: vec![],
graph: vec![],
lane: 9,
},
Commit {
hash: "p1".into(),
short: "p1".into(),
parents: vec![],
author: "x".into(),
time: 3,
subject: "p1".into(),
refs: vec![],
graph: vec![],
lane: 9,
},
Commit {
hash: "p2".into(),
short: "p2".into(),
parents: vec![],
author: "x".into(),
time: 2,
subject: "p2".into(),
refs: vec![],
graph: vec![],
lane: 9,
},
];
layout(&mut commits);
assert_eq!(commits[0].lane, 0);
assert!(commits[0].graph.len() >= 2);
assert_eq!(commits[1].lane, 0); assert_eq!(commits[2].lane, 1); }
}