//! The git plumbing behind the project panel and the per-turn change
//! overview. Everything here blocks, so call it from a background task.
use std::{
path::{Path, PathBuf},
process::{Command, Stdio},
};
/// Runs git in `path`, returning stdout, or stderr as the error.
pub fn git(path: &Path, args: &[&str]) -> Result<String, String> {
run(Command::new("git").arg("-C").arg(path).args(args))
}
fn run(command: &mut Command) -> Result<String, String> {
let output = command.stdin(Stdio::null()).output().map_err(|err| err.to_string())?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
}
}
/// What the project panel shows about a project's git state.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GitInfo {
pub branch: Option<String>,
pub branches: Vec<String>,
pub added: u64,
pub removed: u64,
}
/// Reads the branch and the size of the working-tree changes, counting the
/// lines of untracked text files as added. `None` outside a repository.
pub fn read_info(path: &Path) -> Option<GitInfo> {
git(path, &["rev-parse", "--is-inside-work-tree"]).ok()?;
let branch = git(path, &["symbolic-ref", "--short", "-q", "HEAD"])
.or_else(|_| git(path, &["rev-parse", "--short", "HEAD"]))
.ok()
.map(|branch| branch.trim().to_string())
.filter(|branch| !branch.is_empty());
let branches = git(path, &["for-each-ref", "--format=%(refname:short)", "refs/heads"])
.map(|out| out.lines().map(str::to_string).collect())
.unwrap_or_default();
let mut numstat = String::new();
if git(path, &["rev-parse", "--verify", "-q", "HEAD"]).is_ok() {
numstat.push_str(&git(path, &["diff", "--numstat", "HEAD"]).unwrap_or_default());
} else {
// No commits yet: staged against nothing, plus unstaged.
numstat.push_str(&git(path, &["diff", "--numstat", "--cached"]).unwrap_or_default());
numstat.push_str(&git(path, &["diff", "--numstat"]).unwrap_or_default());
}
let (mut added, mut removed) = (0, 0);
for line in numstat.lines() {
let mut fields = line.split('\t');
// Binary files report `-`.
if let (Some(Ok(plus)), Some(Ok(minus))) =
(fields.next().map(str::parse::<u64>), fields.next().map(str::parse::<u64>))
{
added += plus;
removed += minus;
}
}
let untracked = git(path, &["ls-files", "--others", "--exclude-standard", "-z"]).unwrap_or_default();
for file in untracked.split('\0').filter(|file| !file.is_empty()).take(5_000) {
let file = path.join(file);
if file.metadata().is_ok_and(|meta| meta.len() <= 1_048_576)
&& let Ok(bytes) = std::fs::read(&file)
&& !bytes.contains(&0)
{
let lines = bytes.iter().filter(|&&byte| byte == b'\n').count() as u64;
added += lines + u64::from(bytes.last().is_some_and(|&byte| byte != b'\n'));
}
}
Some(GitInfo { branch, branches, added, removed })
}
/// Records the whole working tree, untracked files included, as a git tree
/// and returns its id. It stages into `index`, a private index file, so the
/// user's own staging area is never touched; the blobs land in the repository
/// as unreferenced objects that `git gc` cleans up.
pub fn snapshot(cwd: &Path, index: &Path) -> Option<String> {
let git_dir = PathBuf::from(git(cwd, &["rev-parse", "--absolute-git-dir"]).ok()?.trim());
if !index.exists() {
// Seeding from the real index lets git skip rehashing unchanged files.
let _ = std::fs::copy(git_dir.join("index"), index);
}
let with_index = |args: &[&str]| {
run(Command::new("git").arg("-C").arg(cwd).args(args).env("GIT_INDEX_FILE", index))
};
with_index(&["add", "-A", "--", ":/"]).ok()?;
let tree = with_index(&["write-tree"]).ok()?;
Some(tree.trim().to_string())
}
#[derive(Clone, Debug, PartialEq)]
pub struct FileChange {
pub path: String,
/// `None` for binary files.
pub added: Option<u64>,
pub removed: Option<u64>,
}
/// The files one turn changed: the difference between two snapshots.
#[derive(Clone, Debug, PartialEq)]
pub struct TurnChanges {
pub base: String,
pub head: String,
pub files: Vec<FileChange>,
}
impl TurnChanges {
pub fn added(&self) -> u64 {
self.files.iter().filter_map(|file| file.added).sum()
}
pub fn removed(&self) -> u64 {
self.files.iter().filter_map(|file| file.removed).sum()
}
}
pub fn changes_between(cwd: &Path, base: &str, head: &str) -> Option<TurnChanges> {
let out = git(cwd, &["diff-tree", "-r", "-z", "--numstat", "--no-renames", base, head]).ok()?;
let mut files = Vec::new();
for record in out.split('\0').filter(|record| !record.is_empty()) {
let mut fields = record.splitn(3, '\t');
let (Some(added), Some(removed), Some(path)) = (fields.next(), fields.next(), fields.next()) else {
continue;
};
files.push(FileChange {
path: path.to_string(),
added: added.parse().ok(),
removed: removed.parse().ok(),
});
}
files.sort_by(|a, b| a.path.cmp(&b.path));
Some(TurnChanges { base: base.into(), head: head.into(), files })
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LineKind {
Hunk,
Context,
Added,
Removed,
}
#[derive(Clone, Debug)]
pub struct DiffLine {
pub kind: LineKind,
pub text: String,
}
#[derive(Clone, Debug)]
pub struct FileDiff {
pub path: String,
pub lines: Vec<DiffLine>,
pub binary: bool,
}
/// The unified diff between two snapshots, optionally for one path.
pub fn diff_between(cwd: &Path, base: &str, head: &str, path: Option<&str>) -> Result<Vec<FileDiff>, String> {
let mut args = vec!["diff", "--no-color", "--no-ext-diff", "--no-renames", "-U3", base, head];
if let Some(path) = path {
args.extend(["--", path]);
}
Ok(parse_diff(&git(cwd, &args)?))
}
fn parse_diff(text: &str) -> Vec<FileDiff> {
let mut files: Vec<FileDiff> = Vec::new();
for line in text.lines() {
if let Some(rest) = line.strip_prefix("diff --git ") {
// `a/path b/path`: take the new side.
let path = rest.rsplit_once(" b/").map(|(_, path)| path).unwrap_or(rest);
files.push(FileDiff { path: path.to_string(), lines: Vec::new(), binary: false });
continue;
}
let Some(file) = files.last_mut() else { continue };
let kind = if line.starts_with("@@") {
LineKind::Hunk
} else if line.starts_with("Binary files") {
file.binary = true;
continue;
} else if file.lines.is_empty() && (line.starts_with("---") || line.starts_with("+++")) {
continue;
} else if let Some(text) = line.strip_prefix('+') {
file.lines.push(DiffLine { kind: LineKind::Added, text: text.to_string() });
continue;
} else if let Some(text) = line.strip_prefix('-') {
file.lines.push(DiffLine { kind: LineKind::Removed, text: text.to_string() });
continue;
} else if let Some(text) = line.strip_prefix(' ') {
file.lines.push(DiffLine { kind: LineKind::Context, text: text.to_string() });
continue;
} else {
// Headers: index, mode and new/deleted file lines.
continue;
};
file.lines.push(DiffLine { kind, text: line.to_string() });
}
files
}
#[cfg(test)]
mod tests {
use super::{LineKind, parse_diff};
#[::core::prelude::v1::test]
fn parses_unified_diff() {
let diff = "diff --git a/src/a.rs b/src/a.rs\nindex 1..2 100644\n--- a/src/a.rs\n+++ b/src/a.rs\n@@ -1,2 +1,2 @@\n fn a() {}\n-old\n+new\n";
let files = parse_diff(diff);
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "src/a.rs");
let kinds: Vec<_> = files[0].lines.iter().map(|line| line.kind).collect();
assert_eq!(kinds, [LineKind::Hunk, LineKind::Context, LineKind::Removed, LineKind::Added]);
}
}