//! 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, /// Where the line sits in the old and the new file. Added lines have no /// old number, removed ones no new number, hunk rows neither. pub old_line: Option, pub new_line: Option, } #[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. /// `ignore_whitespace` leaves out whitespace-only edits, and with them the /// files that changed nothing else. pub fn diff_between( cwd: &Path, base: &str, head: &str, path: Option<&str>, ignore_whitespace: bool, ) -> Result, String> { let mut args = vec!["diff", "--no-color", "--no-ext-diff", "--no-renames", "-U3"]; if ignore_whitespace { args.push("--ignore-all-space"); } args.extend([base, head]); if let Some(path) = path { args.extend(["--", path]); } let mut files = parse_diff(&git(cwd, &args)?); if ignore_whitespace { // Newer gits drop such a file; older ones keep its header, hunkless. files.retain(|file| file.binary || !file.lines.is_empty()); } Ok(files) } /// The first old and new line of a hunk, from `@@ -a,b +c,d @@ context`. fn parse_hunk(line: &str) -> Option<(u32, u32)> { let mut ranges = line.strip_prefix("@@ ")?.split(' '); let start = |range: Option<&str>, sign: char| range?.strip_prefix(sign)?.split(',').next()?.parse().ok(); Some((start(ranges.next(), '-')?, start(ranges.next(), '+')?)) } fn parse_diff(text: &str) -> Vec { let mut files: Vec = Vec::new(); // The numbers the next old and new line will have. let (mut old, mut new) = (0, 0); 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, text, old_line, new_line) = if line.starts_with("@@") { (old, new) = parse_hunk(line).unwrap_or_default(); (LineKind::Hunk, line, None, None) } 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('+') { new += 1; (LineKind::Added, text, None, Some(new - 1)) } else if let Some(text) = line.strip_prefix('-') { old += 1; (LineKind::Removed, text, Some(old - 1), None) } else if let Some(text) = line.strip_prefix(' ') { (old, new) = (old + 1, new + 1); (LineKind::Context, text, Some(old - 1), Some(new - 1)) } else { // Headers: index, mode and new/deleted file lines. continue; }; file.lines.push(DiffLine { kind, text: text.to_string(), old_line, new_line }); } files } #[cfg(test)] mod tests { use super::{LineKind, parse_diff, parse_hunk}; #[::core::prelude::v1::test] fn parses_hunk_headers() { assert_eq!(parse_hunk("@@ -1,2 +3,4 @@ fn a() {"), Some((1, 3))); assert_eq!(parse_hunk("@@ -0,0 +1 @@"), Some((0, 1))); assert_eq!(parse_hunk("@@ -7 +7 @@"), Some((7, 7))); assert_eq!(parse_hunk("@@@ -1,2 -1,2 +1,3 @@@"), None); } #[::core::prelude::v1::test] fn numbers_lines() { let diff = "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -10,4 +12,5 @@ fn a() {\n keep\n-gone\n+one\n+two\n keep\n@@ -40 +43 @@\n-x\n+y\n"; let files = parse_diff(diff); let numbers: Vec<_> = files[0].lines.iter().map(|line| (line.old_line, line.new_line)).collect(); assert_eq!( numbers, [ (None, None), (Some(10), Some(12)), (Some(11), None), (None, Some(13)), (None, Some(14)), (Some(12), Some(15)), (None, None), (Some(40), None), (None, Some(43)), ] ); assert_eq!(files[0].lines[0].text, "@@ -10,4 +12,5 @@ fn a() {"); } #[::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]); } }