git-vet 0.1.1

Git-based review gate for tracked file contents
Documentation
use std::env;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};

use gix::bstr::ByteSlice;
use gix::diff::blob::platform::prepare_diff::Operation;
use gix::diff::blob::unified_diff::{ConsumeBinaryHunk, ContextSize};
use gix::diff::blob::{ResourceKind, UnifiedDiff};
use gix::objs::tree::EntryKind;

use crate::error::{AppError, git_error};
use crate::git_types::{BlobOid, CommitOid, FileMode, HistoricalBlob, TrackedFile};
use crate::path::{
    RepoPath, normalize_lexically, prefix_from_cwd, repo_path_from_bstr, repo_path_from_relative,
};
use crate::review::Vetter;

pub struct Git {
    pub(crate) repo: gix::Repository,
    pub(crate) root: PathBuf,
    prefix: PathBuf,
}

impl Git {
    pub(crate) fn discover() -> Result<Self, AppError> {
        let cwd = env::current_dir()?;
        let repo = gix::discover_with_environment_overrides(&cwd)
            .map_err(|err| git_error("discovering repository", err))?;
        let root = repo
            .workdir()
            .ok_or(AppError::MissingWorktree)?
            .to_path_buf();
        let prefix = match env::var_os("GIT_PREFIX") {
            Some(value) if !value.is_empty() => PathBuf::from(value),
            _ => prefix_from_cwd(&root, &cwd)?,
        };

        Ok(Self { repo, root, prefix })
    }

    pub(crate) fn normalize_user_path(&self, input: &Path) -> Result<RepoPath, AppError> {
        let joined = if input.is_absolute() {
            input.to_path_buf()
        } else {
            self.root.join(&self.prefix).join(input)
        };
        let normalized = normalize_lexically(&joined);
        let root = normalize_lexically(&self.root);
        let relative = normalized
            .strip_prefix(&root)
            .map_err(|_| AppError::PathOutsideRepo(input.display().to_string()))?;
        let path = repo_path_from_relative(relative)?;
        RepoPath::from_git_path(&path).map_err(AppError::from)
    }

    pub(crate) fn tracked_files_at_head(&self) -> Result<Vec<TrackedFile>, AppError> {
        let tree = self
            .repo
            .head_tree()
            .map_err(|err| git_error("reading HEAD tree", err))?;
        let mut files = tree
            .traverse()
            .breadthfirst
            .files()
            .map_err(|err| git_error("walking HEAD tree", err))?
            .into_iter()
            .filter(|entry| entry.mode.is_blob_or_symlink())
            .map(|entry| {
                Ok(TrackedFile {
                    path: repo_path_from_bstr(entry.filepath.as_bstr())?,
                    blob: BlobOid::new(entry.oid),
                    mode: FileMode::new(entry.mode),
                })
            })
            .collect::<Result<Vec<_>, AppError>>()?;
        files.sort_by(|left, right| left.path.cmp(&right.path));
        Ok(files)
    }

    pub(crate) fn blob_at_head(&self, path: &RepoPath) -> Result<TrackedFile, AppError> {
        let tree = self
            .repo
            .head_tree()
            .map_err(|err| git_error("reading HEAD tree", err))?;
        Self::lookup_file_in_tree(&tree, path)?
            .ok_or_else(|| AppError::PathNotTracked(path.clone()))
    }

    pub(crate) fn head_commit(&self) -> Result<CommitOid, AppError> {
        self.repo
            .head_id()
            .map(|id| CommitOid::new(id.detach()))
            .map_err(|err| git_error("reading HEAD", err))
    }

    pub(crate) fn vetter(&self) -> Result<Vetter, AppError> {
        let config = self.repo.config_snapshot();
        let name = config
            .string("user.name")
            .ok_or(AppError::MissingUserName)?;
        let email = config
            .string("user.email")
            .ok_or(AppError::MissingUserEmail)?;
        let name = name
            .to_str()
            .map_err(|err| AppError::NonUtf8Path(err.to_string()))?
            .trim()
            .to_owned();
        let email = email
            .to_str()
            .map_err(|err| AppError::NonUtf8Path(err.to_string()))?
            .trim()
            .to_owned();
        match (name.is_empty(), email.is_empty()) {
            (true, _) => Err(AppError::MissingUserName),
            (_, true) => Err(AppError::MissingUserEmail),
            (false, false) => Ok(Vetter::new(name, email)),
        }
    }

    pub(crate) fn historical_blobs(
        &self,
        path: &RepoPath,
        current: &BlobOid,
    ) -> Result<Vec<HistoricalBlob>, AppError> {
        let head = self
            .repo
            .head_id()
            .map_err(|err| git_error("reading HEAD", err))?
            .detach();
        let commits = self
            .repo
            .rev_walk([head])
            .sorting(gix::revision::walk::Sorting::ByCommitTime(
                gix::traverse::commit::simple::CommitTimeOrder::NewestFirst,
            ))
            .all()
            .map_err(|err| git_error("walking commit history", err))?;
        let mut followed_path = path.clone();
        let mut previous_blob = None;
        let mut history = Vec::new();

        for info in commits {
            let info = info.map_err(|err| git_error("walking commit history", err))?;
            let commit = info
                .object()
                .map_err(|err| git_error("reading historical commit", err))?;
            let tree = commit
                .tree()
                .map_err(|err| git_error("reading historical tree", err))?;
            if let Some(file) = Self::lookup_file_in_tree(&tree, &followed_path)? {
                if file.blob != *current && previous_blob != Some(file.blob) {
                    history.push(HistoricalBlob {
                        blob: file.blob,
                        mode: file.mode,
                    });
                }
                previous_blob = Some(file.blob);
            }

            if let Some(parent_id) = info.parent_ids().next() {
                let parent = parent_id
                    .object()
                    .map_err(|err| git_error("reading historical parent commit", err))?
                    .into_commit();
                let parent_tree = parent
                    .tree()
                    .map_err(|err| git_error("reading historical parent tree", err))?;
                if let Some(source_path) =
                    self.rename_source(&parent_tree, &tree, &followed_path)?
                {
                    followed_path = source_path;
                }
            }
        }

        Ok(history)
    }

    pub(crate) fn diff_empty_to_head(&self, file: &TrackedFile) -> Result<String, AppError> {
        self.render_blob_diff(None, file)
    }

    pub(crate) fn diff_blobs_with_path_label(
        &self,
        baseline: &HistoricalBlob,
        current: &TrackedFile,
    ) -> Result<String, AppError> {
        self.render_blob_diff(Some(baseline), current)
    }

    fn render_blob_diff(
        &self,
        baseline: Option<&HistoricalBlob>,
        current: &TrackedFile,
    ) -> Result<String, AppError> {
        let old_label =
            baseline.map_or_else(|| "/dev/null".to_owned(), |_| format!("a/{}", current.path));
        let new_label = format!("b/{}", current.path);
        let old_blob = baseline.map(|baseline| baseline.blob);
        let old_mode = baseline.map(|baseline| baseline.mode);
        let old_id = old_blob.map_or_else(
            || gix::ObjectId::null(self.repo.object_hash()),
            |oid| oid.as_object_id(),
        );
        let old_kind = old_mode.map_or(EntryKind::Blob, FileMode::kind);
        let mut cache = self
            .repo
            .diff_resource_cache_for_tree_diff()
            .map_err(|err| git_error("creating diff resource cache", err))?;

        cache
            .set_resource(
                old_id,
                old_kind,
                current.path.as_bstr(),
                ResourceKind::OldOrSource,
                &self.repo.objects,
            )
            .map_err(|err| git_error("setting diff source", err))?;
        cache
            .set_resource(
                current.blob.as_object_id(),
                current.mode.kind(),
                current.path.as_bstr(),
                ResourceKind::NewOrDestination,
                &self.repo.objects,
            )
            .map_err(|err| git_error("setting diff destination", err))?;
        cache.options.skip_internal_diff_if_external_is_configured = false;

        let mut output = self.diff_header(baseline, current)?;

        let prepared = cache
            .prepare_diff()
            .map_err(|err| git_error("preparing blob diff", err))?;
        match prepared.operation {
            Operation::InternalDiff { algorithm } => {
                writeln!(&mut output, "--- {old_label}\n+++ {new_label}")
                    .map_err(|err| git_error("rendering diff header", err))?;
                let input = prepared.interned_input();
                let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input);
                let hunk = UnifiedDiff::new(
                    &diff,
                    &input,
                    ConsumeBinaryHunk::new(Vec::<u8>::new(), "\n"),
                    ContextSize::default(),
                )
                .consume()?;
                output.push_str(&String::from_utf8_lossy(&hunk));
            }
            Operation::SourceOrDestinationIsBinary => {
                writeln!(
                    &mut output,
                    "Binary files {old_label} and {new_label} differ"
                )
                .map_err(|err| git_error("rendering binary diff", err))?;
            }
            Operation::ExternalCommand { .. } => {
                return Err(git_error(
                    "rendering blob diff",
                    "external diffs are disabled but gix selected an external command",
                ));
            }
        }

        Ok(output)
    }

    fn diff_header(
        &self,
        baseline: Option<&HistoricalBlob>,
        current: &TrackedFile,
    ) -> Result<String, AppError> {
        let mut output = String::new();
        writeln!(
            &mut output,
            "diff --git a/{path} b/{path}",
            path = current.path
        )
        .map_err(|err| git_error("rendering diff header", err))?;
        match baseline {
            Some(baseline) if baseline.mode != current.mode => {
                writeln!(&mut output, "old mode {}", baseline.mode.as_octal())
                    .map_err(|err| git_error("rendering diff header", err))?;
                writeln!(&mut output, "new mode {}", current.mode.as_octal())
                    .map_err(|err| git_error("rendering diff header", err))?;
                writeln!(
                    &mut output,
                    "index {}..{}",
                    baseline.blob.short(),
                    current.blob.short()
                )
                .map_err(|err| git_error("rendering diff header", err))?;
            }
            Some(baseline) => {
                writeln!(
                    &mut output,
                    "index {}..{} {}",
                    baseline.blob.short(),
                    current.blob.short(),
                    current.mode.as_octal()
                )
                .map_err(|err| git_error("rendering diff header", err))?;
            }
            None => {
                writeln!(&mut output, "new file mode {}", current.mode.as_octal())
                    .map_err(|err| git_error("rendering diff header", err))?;
                writeln!(
                    &mut output,
                    "index {}..{}",
                    zero_oid(self.repo.object_hash()),
                    current.blob.short()
                )
                .map_err(|err| git_error("rendering diff header", err))?;
            }
        }
        Ok(output)
    }

    fn lookup_file_in_tree(
        tree: &gix::Tree<'_>,
        path: &RepoPath,
    ) -> Result<Option<TrackedFile>, AppError> {
        let entry = tree
            .lookup_entry_by_path(path.to_path_buf())
            .map_err(|err| git_error("looking up path in tree", err))?;
        match entry {
            Some(entry) => {
                let mode = FileMode::new(entry.mode());
                if mode.is_submodule() {
                    return Err(AppError::PathIsSubmodule(path.clone()));
                }
                if mode.is_reviewable_file() {
                    Ok(Some(TrackedFile {
                        path: path.clone(),
                        blob: BlobOid::new(entry.object_id()),
                        mode,
                    }))
                } else {
                    Ok(None)
                }
            }
            None => Ok(None),
        }
    }

    fn rename_source(
        &self,
        old_tree: &gix::Tree<'_>,
        new_tree: &gix::Tree<'_>,
        destination: &RepoPath,
    ) -> Result<Option<RepoPath>, AppError> {
        let changes = self
            .repo
            .diff_tree_to_tree(Some(old_tree), Some(new_tree), None)
            .map_err(|err| git_error("detecting renames", err))?;
        for change in changes {
            match change {
                gix::diff::tree_with_rewrites::Change::Rewrite {
                    source_location,
                    location,
                    copy: false,
                    ..
                } if repo_path_from_bstr(location.as_bstr())? == *destination => {
                    return repo_path_from_bstr(source_location.as_bstr())
                        .map(Some)
                        .map_err(Into::into);
                }
                _ => {}
            }
        }
        Ok(None)
    }
}

fn zero_oid(kind: gix::hash::Kind) -> String {
    "0".repeat(kind.len_in_hex())
}