Commands that run git now run away from the thread that draws the window, so a slow server no longer freezes it. ssh now notices a server that stopped answering in about 10 seconds. The sockets for shared connections go in the runtime folder, or else in a private gites folder in the cache folder, never in /tmp; without either, connections are not shared. A path that is exactly ~ now means the home folder on the server. Tests cover commit details through ssh, the message for a failed connection, and a ~/ path end to end. The stand-in ssh is now set per test, so a test can give it its own home folder.
190 lines
6.7 KiB
Rust
190 lines
6.7 KiB
Rust
//! Everything the preview panel shows about one commit.
|
|
|
|
use crate::git::git;
|
|
use serde::Serialize;
|
|
use crate::location::Location;
|
|
|
|
#[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, PartialEq)]
|
|
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(loc: &Location, 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(
|
|
loc,
|
|
&["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(loc, &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![]);
|
|
}
|
|
|
|
#[test]
|
|
fn shows_the_same_details_through_ssh() {
|
|
use std::process::Command;
|
|
let dir = std::env::temp_dir().join(format!("gites-details-ssh-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let run = |args: &[&str]| {
|
|
let out = Command::new("git")
|
|
.arg("-C")
|
|
.arg(&dir)
|
|
.args(["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false"])
|
|
.args(args)
|
|
.output()
|
|
.unwrap();
|
|
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
|
};
|
|
run(&["init", "-q", "-b", "main"]);
|
|
std::fs::write(dir.join("it's $a; b.txt"), "one\ntwo\n").unwrap();
|
|
run(&["add", "-A"]);
|
|
run(&["commit", "-q", "-m", "first"]);
|
|
std::fs::write(dir.join("it's $a; b.txt"), "one\nthree\n").unwrap();
|
|
run(&["commit", "-q", "-am", "second\n\nwith a body"]);
|
|
let hash = run(&["rev-parse", "HEAD"]);
|
|
crate::test_ssh::fake_ssh();
|
|
let remote = Location::Ssh { target: "myserver".into(), path: dir.to_string_lossy().into_owned() };
|
|
let local = load(&Location::Local(dir.clone()), &hash).unwrap();
|
|
let through_ssh = load(&remote, &hash).unwrap();
|
|
assert_eq!(local.files, vec![file("M", "it's $a; b.txt", None, Some(1), Some(1))]);
|
|
assert_eq!(local, through_ssh);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
}
|