157 lines
5.0 KiB
Rust
157 lines
5.0 KiB
Rust
//! Everything the preview panel shows about one commit.
|
|
|
|
use crate::git::git;
|
|
use serde::Serialize;
|
|
use std::path::Path;
|
|
|
|
#[derive(Serialize, Debug, PartialEq)]
|
|
pub struct ChangedFile {
|
|
/// One letter: A added, M modified, D deleted, R renamed, C copied, T type changed.
|
|
pub status: String,
|
|
pub path: String,
|
|
/// The path before a rename or copy.
|
|
pub old_path: Option<String>,
|
|
/// Line counts; `None` for binary files.
|
|
pub added: Option<u32>,
|
|
pub deleted: Option<u32>,
|
|
}
|
|
|
|
#[derive(Serialize, Debug)]
|
|
pub struct CommitDetails {
|
|
hash: String,
|
|
author: String,
|
|
author_email: String,
|
|
author_time: i64,
|
|
committer: String,
|
|
committer_email: String,
|
|
commit_time: i64,
|
|
parents: Vec<String>,
|
|
/// The full message, subject line included.
|
|
message: String,
|
|
files: Vec<ChangedFile>,
|
|
}
|
|
|
|
/// Parses `git diff-tree -z --name-status` and `-z --numstat` output for the same diff.
|
|
pub fn parse_files(name_status: &str, numstat: &str) -> Vec<ChangedFile> {
|
|
let mut counts = Vec::new();
|
|
let mut num = numstat.split('\0').filter(|s| !s.is_empty());
|
|
while let Some(head) = num.next() {
|
|
let mut f = head.splitn(3, '\t');
|
|
let added = f.next().and_then(|n| n.parse().ok());
|
|
let deleted = f.next().and_then(|n| n.parse().ok());
|
|
// A rename has an empty path here, followed by the old and new paths.
|
|
if f.next().unwrap_or("").is_empty() {
|
|
num.next();
|
|
num.next();
|
|
}
|
|
counts.push((added, deleted));
|
|
}
|
|
|
|
let mut files = Vec::new();
|
|
let mut ns = name_status.split('\0').filter(|s| !s.is_empty());
|
|
while let Some(code) = ns.next() {
|
|
let status = code[..1].to_string();
|
|
let first = ns.next().unwrap_or_default().to_string();
|
|
let (path, old_path) = if status == "R" || status == "C" {
|
|
(ns.next().unwrap_or_default().to_string(), Some(first))
|
|
} else {
|
|
(first, None)
|
|
};
|
|
let (added, deleted) = counts.get(files.len()).copied().unwrap_or((None, None));
|
|
files.push(ChangedFile { status, path, old_path, added, deleted });
|
|
}
|
|
files
|
|
}
|
|
|
|
pub fn load(dir: &Path, hash: &str) -> Result<CommitDetails, String> {
|
|
// The hash comes from the window; refuse anything git could read as an option.
|
|
if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
return Err(format!("not a commit hash: {hash}"));
|
|
}
|
|
let info = git(
|
|
dir,
|
|
&["show", "-s", "--format=%H%x1f%an%x1f%ae%x1f%at%x1f%cn%x1f%ce%x1f%ct%x1f%P%x1f%B", hash, "--"],
|
|
)?;
|
|
let f: Vec<&str> = info.splitn(9, '\x1f').collect();
|
|
if f.len() < 9 {
|
|
return Err(format!("unexpected git show output for {hash}"));
|
|
}
|
|
let parents: Vec<String> = f[7].split_whitespace().map(String::from).collect();
|
|
|
|
// A merge is compared with its first parent; other commits with their only parent,
|
|
// or with nothing when they are the first commit.
|
|
let mut args = vec!["diff-tree", "-r", "-M", "-z"];
|
|
if let Some(first) = parents.first().filter(|_| parents.len() > 1) {
|
|
args.push(first);
|
|
} else {
|
|
args.extend(["--root", "--no-commit-id"]);
|
|
}
|
|
args.push(hash);
|
|
let with = |flag: &'static str| {
|
|
let mut a = args.clone();
|
|
a.insert(1, flag);
|
|
git(dir, &a)
|
|
};
|
|
let files = parse_files(&with("--name-status")?, &with("--numstat")?);
|
|
|
|
Ok(CommitDetails {
|
|
hash: f[0].to_string(),
|
|
author: f[1].to_string(),
|
|
author_email: f[2].to_string(),
|
|
author_time: f[3].parse().unwrap_or(0),
|
|
committer: f[4].to_string(),
|
|
committer_email: f[5].to_string(),
|
|
commit_time: f[6].parse().unwrap_or(0),
|
|
parents,
|
|
message: f[8].trim_end().to_string(),
|
|
files,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn file(status: &str, path: &str, old: Option<&str>, a: Option<u32>, d: Option<u32>) -> ChangedFile {
|
|
ChangedFile {
|
|
status: status.into(),
|
|
path: path.into(),
|
|
old_path: old.map(String::from),
|
|
added: a,
|
|
deleted: d,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_plain_changes() {
|
|
let ns = "M\0src/a.ts\0A\0src/b.ts\0D\0old.txt\0";
|
|
let num = "3\t1\tsrc/a.ts\09\t0\tsrc/b.ts\00\t40\told.txt\0";
|
|
assert_eq!(
|
|
parse_files(ns, num),
|
|
vec![
|
|
file("M", "src/a.ts", None, Some(3), Some(1)),
|
|
file("A", "src/b.ts", None, Some(9), Some(0)),
|
|
file("D", "old.txt", None, Some(0), Some(40)),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_renames_and_binary_files() {
|
|
let ns = "R087\0lib/old.rs\0lib/new.rs\0M\0logo.png\0";
|
|
let num = "2\t2\t\0lib/old.rs\0lib/new.rs\0-\t-\tlogo.png\0";
|
|
assert_eq!(
|
|
parse_files(ns, num),
|
|
vec![
|
|
file("R", "lib/new.rs", Some("lib/old.rs"), Some(2), Some(2)),
|
|
file("M", "logo.png", None, None, None),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_an_empty_diff() {
|
|
assert_eq!(parse_files("", ""), vec![]);
|
|
}
|
|
}
|