//! 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, /// Line counts; `None` for binary files. pub added: Option, pub deleted: Option, } #[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, /// The full message, subject line included. message: String, files: Vec, } /// 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 { 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 { // 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 = 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, d: Option) -> 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![]); } }