//! 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}, }; use serde::{Deserialize, Serialize}; /// Runs git in `path`, returning stdout, or stderr as the error. pub fn git(path: &Path, args: &[&str]) -> Result { run(Command::new("git").arg("-C").arg(path).args(args)) } fn run(command: &mut Command) -> Result { 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, pub branches: Vec, 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 { 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::), fields.next().map(str::parse::)) { 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 { 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, Serialize, Deserialize)] pub struct FileChange { pub path: String, /// `None` for binary files. pub added: Option, pub removed: Option, } /// The files one turn changed: the difference between two snapshots. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TurnChanges { pub base: String, pub head: String, pub files: Vec, } 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 { 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, 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, 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 { let mut files: Vec = 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]); } }