Vectrum
Sign In
291 lines · 11.0 KB · Rust
//! 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<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())
    }
}

/// The repository a folder is in, named by its common directory (shared by
/// all of its worktrees), and the folder's path inside the checkout.
pub fn repository_of(path: &Path) -> Option<(PathBuf, PathBuf)> {
    let common = git(path, &["rev-parse", "--path-format=absolute", "--git-common-dir"]).ok()?;
    let top = git(path, &["rev-parse", "--show-toplevel"]).ok()?;
    let top = PathBuf::from(top.trim());
    let inside = path.strip_prefix(&top).map(Path::to_path_buf).unwrap_or_default();
    Some((PathBuf::from(common.trim()), inside))
}

/// 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, Serialize, Deserialize)]
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, Serialize, Deserialize)]
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,
    /// 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<u32>,
    pub new_line: Option<u32>,
}

#[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.
/// `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<Vec<FileDiff>, 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<FileDiff> {
    let mut files: Vec<FileDiff> = 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]);
    }
}