//! Reading a repository's data for its page: what its branches and tags point
//! at, and its readme.
//!
//! A bare repository has no working tree to read a file from, so everything
//! comes out of `git` itself: `for-each-ref` orders the refs, `log` names the
//! commit each one reaches, and `ls-tree`/`show` read the readme at the tip of
//! the default branch.
//!
//! Where a command reports several fields at once - the commit's hash, author
//! and subject - it separates them with a NUL, which none of them can contain,
//! so splitting is exact rather than a guess at some printable delimiter. A
//! listing of names needs no separator at all, since a ref name cannot contain
//! a newline.

use std::path::Path;
use std::process::Stdio;

use tokio::process::Command;

/// How many branches, or tags, a listing previews.
///
/// The repository's landing page shows this many of each; the refs browser
/// asks for all of them with `None` instead.
pub const REF_LIMIT: usize = 3;

/// The field separator of a formatted git command. A NUL byte cannot appear in
/// any of the fields it separates.
const SEP: char = '\0';

/// A commit as a listing shows it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Commit {
    /// The abbreviated object name, `%h`, which is git's own abbreviation,
    /// and what a listing shows.
    pub hash: String,
    /// The full object name, `%H`, which is what a tree address is built
    /// from: an abbreviation is only as unambiguous as git found needed.
    pub full_hash: String,
    pub author: String,
    /// The author date, ISO 8601 format (`%aI`).
    pub date: String,
    /// The first line of the commit message.
    pub subject: String,
    /// Number of files changed in this commit.
    pub files_changed: u64,
    /// Number of lines added in this commit.
    pub insertions: u64,
    /// Number of lines deleted in this commit.
    pub deletions: u64,
}

/// A branch or tag, and the commit it reaches.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ref {
    pub name: String,
    pub commit: Commit,
}

/// How many bytes a diff patch may be before we skip the content.
///
/// Colouring and rendering a patch walks every line; a page has a reader
/// waiting for it. Past this limit the file list and stats are still shown,
/// but the hunks are not.
pub const DIFF_LIMIT: usize = 1024 * 1024;

/// Whether a file was added, modified, deleted, or renamed in a diff.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileStatus {
    Added,
    Modified,
    Deleted,
    Renamed,
}

/// The kind of a line within a diff hunk.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffLineKind {
    /// A context line, unchanged between the two sides.
    Context,
    /// A line added in the new version.
    Added,
    /// A line removed from the old version.
    Removed,
}

/// One line of a diff hunk.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffLine {
    pub kind: DiffLineKind,
    pub text: String,
}

/// A hunk within a file's diff.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hunk {
    /// The `@@ ... @@` header line, kept as-is for display.
    pub header: String,
    /// The lines of the hunk, in order.
    pub lines: Vec<DiffLine>,
}

/// One file within a commit's diff.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffFile {
    pub old_path: String,
    pub new_path: String,
    pub status: FileStatus,
    /// Whether the file is binary: if so, `hunks` is empty.
    pub binary: bool,
    /// Lines added, from numstat.
    pub added: u64,
    /// Lines deleted, from numstat.
    pub deleted: u64,
    /// The hunks of the diff. Empty when the file is binary or the diff was
    /// past the size limit.
    pub hunks: Vec<Hunk>,
}

/// The readme at the tip of a branch.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Readme {
    /// The path within the tree, so the page can say which readme it shows.
    pub path: String,
    pub content: String,
}

/// The branches whose tip commit is most recent, newest first.
///
/// `limit` caps the listing for a preview; `None` asks for every branch.
#[tracing::instrument(level = "debug", skip(limit), fields(bare = %bare.display()))]
pub async fn branches(bare: &Path, limit: Option<usize>) -> std::io::Result<Vec<Ref>> {
    refs(bare, "refs/heads", "-committerdate", limit).await
}

/// The tags most recently created, newest first.
///
/// Ordered by `creatordate`, which is the tag's own date for an annotated tag
/// and the commit's for a lightweight one: the date a reader means by "when
/// the tag was made".
///
/// `limit` caps the listing for a preview; `None` asks for every tag.
#[tracing::instrument(level = "debug", skip(limit), fields(bare = %bare.display()))]
pub async fn tags(bare: &Path, limit: Option<usize>) -> std::io::Result<Vec<Ref>> {
    refs(bare, "refs/tags", "-creatordate", limit).await
}

/// The ordered names under `glob`, each with the commit it reaches.
///
/// The names come from one `for-each-ref`, ordered and limited by git; the
/// commit of each is then read with `log`, which peels an annotated tag to the
/// commit it names rather than reporting the tag object.
async fn refs(
    bare: &Path,
    glob: &str,
    sort: &str,
    limit: Option<usize>,
) -> std::io::Result<Vec<Ref>> {
    let mut args = vec!["for-each-ref".to_string(), format!("--sort={sort}")];
    // Without `--count` git lists everything under the glob, which is what a
    // full browser of the refs asks for.
    if let Some(limit) = limit {
        args.push(format!("--count={limit}"));
    }
    args.push("--format=%(refname:short)".to_string());
    args.push(glob.to_string());
    let args: Vec<&str> = args.iter().map(String::as_str).collect();

    // One field per line, so no separator is needed: a ref name cannot contain
    // a newline.
    let output = git(bare, &args).await?;

    let mut refs = Vec::new();
    for name in output.lines().filter(|line| !line.is_empty()) {
        // A ref that is unexpectedly not a commit - a tag on a blob, say - is
        // left out rather than failing the whole listing.
        let Some(commit) = commit(bare, name).await? else {
            continue;
        };
        refs.push(Ref {
            name: name.to_string(),
            commit,
        });
    }

    Ok(refs)
}

/// The commit `rev` names, or `None` when git cannot resolve it to one.
pub async fn commit(bare: &Path, rev: &str) -> std::io::Result<Option<Commit>> {
    // `%x00` is git's own escape for a NUL, which keeps the argument a string
    // while the output carries the separator `SEP` looks for.
    let format = "%h%x00%H%x00%an%x00%aI%x00%s";
    let Some(output) =
        git_optional(bare, &["log", "-1", &format!("--format={format}"), rev]).await?
    else {
        return Ok(None);
    };

    let mut fields = output.trim_end_matches('\n').split(SEP);
    let (Some(hash), Some(full_hash), Some(author), Some(date), Some(subject)) = (
        fields.next(),
        fields.next(),
        fields.next(),
        fields.next(),
        fields.next(),
    ) else {
        return Ok(None);
    };

    Ok(Some(Commit {
        hash: hash.to_string(),
        full_hash: full_hash.to_string(),
        author: author.to_string(),
        date: date.to_string(),
        subject: subject.to_string(),
        files_changed: 0,
        insertions: 0,
        deletions: 0,
    }))
}

/// Every commit reachable from `rev`, newest first.
///
/// `None` when git cannot resolve `rev` to a revision at all - a branch that
/// does not exist, say. An empty list means the revision exists but has no
/// ancestors reachable from it, which is not a thing in git's own reading:
/// a commit is always its own ancestor, so a non-empty answer is the only
/// one a reachable revision ever gives.
#[tracing::instrument(level = "debug", fields(bare = %bare.display()))]
pub async fn history(bare: &Path, rev: &str) -> std::io::Result<Option<Vec<Commit>>> {
    let format = "%h%x00%H%x00%an%x00%aI%x00%s";
    let Some(output) = git_optional(
        bare,
        &["log", &format!("--format={format}"), "--numstat", rev],
    )
    .await?
    else {
        return Ok(None);
    };

    let mut commits = Vec::new();
    let mut current_commit: Option<(String, String, String, String, String)> = None;
    let mut files_changed = 0u64;
    let mut insertions = 0u64;
    let mut deletions = 0u64;

    for line in output.lines() {
        if line.is_empty() {
            continue;
        }

        // Check if this is a commit line (contains NUL separators)
        if line.contains('\0') {
            // Flush the previous commit if there was one
            if let Some((hash, full_hash, author, date, subject)) = current_commit.take() {
                commits.push(Commit {
                    hash,
                    full_hash,
                    author,
                    date,
                    subject,
                    files_changed,
                    insertions,
                    deletions,
                });
            }

            // Parse the new commit line
            let mut fields = line.split(SEP);
            let (Some(hash), Some(full_hash), Some(author), Some(date), Some(subject)) = (
                fields.next(),
                fields.next(),
                fields.next(),
                fields.next(),
                fields.next(),
            ) else {
                // A row git could not shape into a commit is dropped rather than
                // failing the whole listing, the same way `refs` treats a ref
                // that is not a commit.
                continue;
            };

            current_commit = Some((
                hash.to_string(),
                full_hash.to_string(),
                author.to_string(),
                date.to_string(),
                subject.to_string(),
            ));
            files_changed = 0;
            insertions = 0;
            deletions = 0;
        } else if current_commit.is_some() {
            // This is a numstat line: <added>\t<deleted>\t<path>
            // Binary files show as `-` for added/deleted
            let parts: Vec<&str> = line.split('\t').collect();
            if parts.len() >= 3 {
                files_changed += 1;

                // Parse added lines
                if parts[0] != "-"
                    && let Ok(n) = parts[0].parse::<u64>()
                {
                    insertions += n;
                }

                // Parse deleted lines
                if parts[1] != "-"
                    && let Ok(n) = parts[1].parse::<u64>()
                {
                    deletions += n;
                }
            }
        }
    }

    // Flush the last commit
    if let Some((hash, full_hash, author, date, subject)) = current_commit.take() {
        commits.push(Commit {
            hash,
            full_hash,
            author,
            date,
            subject,
            files_changed,
            insertions,
            deletions,
        });
    }

    Ok(Some(commits))
}

/// The readme at the tip of `branch`, preferring a language from `languages`.
///
/// `languages` is a visitor's preference, most wanted first. For each one a
/// localised readme is looked for - `README.fr.md`, `README_fr.md` - before
/// falling back to the repository's plain `README.md`. `None` when the branch
/// has no commit, or the tree holds no readme at all.
#[tracing::instrument(level = "debug", skip(languages), fields(bare = %bare.display()))]
pub async fn readme(
    bare: &Path,
    branch: &str,
    languages: &[String],
) -> std::io::Result<Option<Readme>> {
    let Some(listing) = git_optional(bare, &["ls-tree", "--name-only", branch]).await? else {
        // No such branch, or no commit on it: nothing to show, not a fault.
        return Ok(None);
    };

    let names: Vec<&str> = listing
        .split('\n')
        .filter(|name| !name.is_empty())
        .collect();
    let Some(path) = choose_readme(&names, languages) else {
        return Ok(None);
    };

    let content = git(bare, &["show", &format!("{branch}:{path}")]).await?;

    Ok(Some(Readme {
        path: path.to_string(),
        content,
    }))
}

/// Whether a listing entry is a directory to walk into or a file to name.
///
/// A symlink and a submodule are both `File`: they are read as one name on
/// a page, and there is nowhere for either to lead yet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
    Directory,
    File,
}

/// One entry of one directory of a tree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
    pub name: String,
    pub kind: EntryKind,
}

/// The entries of one directory at `rev`, or `None` when there is no such
/// directory - a path no tree holds, or a revision git cannot reach.
///
/// `path` is the directory within the tree, empty for the root. It travels
/// as its own argument rather than as part of a shell line, so a name with
/// spaces or quotes arrives whole.
#[tracing::instrument(level = "debug", fields(bare = %bare.display()))]
pub async fn listing(bare: &Path, rev: &str, path: &str) -> std::io::Result<Option<Vec<Entry>>> {
    // A ref name may not contain a `:`, so the two halves of this argument
    // cannot be confused for one another.
    let spec = if path.is_empty() {
        rev.to_string()
    } else {
        format!("{rev}:{path}")
    };

    // `-z` ends each entry with a NUL, which no path contains, so an
    // unusual file name is read exactly rather than by line.
    let Some(output) = git_optional(bare, &["ls-tree", "-z", &spec]).await? else {
        return Ok(None);
    };

    Ok(Some(entries(&output)))
}

/// The entries in `ls-tree`'s own output.
///
/// Each entry is `<mode> <type> <object>\t<name>`, ended by a NUL under
/// `-z`. Anything that is not a directory - blob or commit alike - reads as
/// a file.
fn entries(output: &str) -> Vec<Entry> {
    output
        .split('\0')
        .filter(|entry| !entry.is_empty())
        .filter_map(|entry| {
            let (heading, name) = entry.split_once('\t')?;
            let kind = match heading.split_whitespace().nth(1)? {
                "tree" => EntryKind::Directory,
                _ => EntryKind::File,
            };
            Some(Entry {
                name: name.to_string(),
                kind,
            })
        })
        .collect()
}

/// The diff of a commit: every file it changed, with hunks and stats.
///
/// `None` when `rev` does not name a commit. For a merge commit the diff is
/// against the first parent, which is what a reader usually wants to see.
/// A root commit diffs against the empty tree.
///
/// Binary files and diffs past [`DIFF_LIMIT`] carry stats but no hunks:
/// the file list is always useful, the content is not worth rendering.
#[tracing::instrument(level = "debug", fields(bare = %bare.display()))]
pub async fn commit_diff(bare: &Path, rev: &str) -> std::io::Result<Option<Vec<DiffFile>>> {
    // `--format=` suppresses the commit header, `--patch` the diff, `--numstat`
    // the per-file counts. `-m --first-parent` makes merges diff against
    // their first parent rather than emitting an empty combined diff.
    let output = git_bytes(
        bare,
        &[
            "show",
            "--format=",
            "--patch",
            "--numstat",
            "--find-renames",
            "-m",
            "--first-parent",
            rev,
        ],
    )
    .await?;

    let Some(output) = output else {
        return Ok(None);
    };

    Ok(Some(parse_diff_output(&output)))
}

/// The diff between two revisions, using merge-base semantics (three-dot).
///
/// The diff runs from the merge-base of `base` and `head` to `head`, which
/// shows what `head` brought since it and `base` last shared a commit. This
/// is what GitHub/Gitea show on their compare pages.
///
/// `None` when either end does not resolve to a commit, or when the two have
/// no common ancestor (e.g. unrelated histories).
///
/// Binary files and diffs past [`DIFF_LIMIT`] carry stats but no hunks, same
/// as [`commit_diff`].
#[tracing::instrument(level = "debug", fields(bare = %bare.display()))]
pub async fn compare_diff(bare: &Path, range: &str) -> std::io::Result<Option<Vec<DiffFile>>> {
    // `git diff A...B` computes the diff from the merge-base of A and B to B,
    // which is the standard "compare" semantics: what B brought since A and B
    // diverged. `--numstat` and `--patch` give us both stats and hunks, same
    // format as `git show` that [`commit_diff`] already parses.
    let output = git_bytes(
        bare,
        &["diff", "--numstat", "--patch", "--find-renames", range],
    )
    .await?;

    let Some(output) = output else {
        return Ok(None);
    };

    Ok(Some(parse_diff_output(&output)))
}

/// Parses the raw git output from `git show --patch --numstat` or
/// `git diff --patch --numstat` into a list of [`DiffFile`]s.
///
/// The output is numstat lines first, then a blank line, then the patch.
/// This function handles both formats identically.
fn parse_diff_output(output: &[u8]) -> Vec<DiffFile> {
    let patch = String::from_utf8_lossy(output);
    let (patch, numstat) = split_numstat_and_patch(&patch);
    let too_large = patch.len() > DIFF_LIMIT;

    let numstat_files = parse_numstat(numstat);
    if too_large {
        numstat_files
            .into_iter()
            .map(|(old, new, added, deleted, binary)| {
                let status = file_status_from_paths(&old, &new);
                DiffFile {
                    old_path: old,
                    new_path: new,
                    status,
                    binary,
                    added,
                    deleted,
                    hunks: vec![],
                }
            })
            .collect()
    } else {
        parse_patch(patch, numstat_files)
    }
}

/// Splits the output of `git show --patch --numstat` into the two halves.
///
/// numstat lines come first, each `<added>\t<deleted>\t<path>`, followed by a
/// blank line, then the patch. Returns `(patch, numstat)`.
fn split_numstat_and_patch(output: &str) -> (&str, &str) {
    // numstat ends at the first blank line or the first `diff --git`.
    let mut numstat_end = 0;
    for line in output.lines() {
        if line.is_empty() || line.starts_with("diff --git") {
            break;
        }
        numstat_end += line.len() + 1; // +1 for the newline
    }
    let numstat = &output[..numstat_end];
    let patch = &output[numstat_end..];
    (patch.trim_start(), numstat)
}

/// Parses numstat lines into `(old_path, new_path, added, deleted, binary)`.
///
/// numstat format: `<added>\t<deleted>\t<path>` for normal files, or
/// `<added>\t<deleted>\t{old => new}` for renames, or `- - <path>` for binary.
/// The `{old => new}` form is rare (only for renames with changes); the common
/// case is a single path, in which case old and new are the same.
fn parse_numstat(numstat: &str) -> Vec<(String, String, u64, u64, bool)> {
    let mut files = vec![];
    for line in numstat.lines() {
        if line.trim().is_empty() {
            continue;
        }
        let parts: Vec<&str> = line.split('\t').collect();
        if parts.len() < 3 {
            continue;
        }
        let added_str = parts[0];
        let deleted_str = parts[1];
        let path = parts[2];

        // Binary files show `- -` for the counts.
        if added_str == "-" && deleted_str == "-" {
            files.push((path.to_string(), path.to_string(), 0, 0, true));
            continue;
        }

        let Ok(added) = added_str.parse::<u64>() else {
            continue;
        };
        let Ok(deleted) = deleted_str.parse::<u64>() else {
            continue;
        };

        // Renames with changes: `path/{old => new}`.
        if let Some((old_path, new_path)) = parse_rename_path(path) {
            files.push((old_path, new_path, added, deleted, false));
        } else {
            files.push((path.to_string(), path.to_string(), added, deleted, false));
        }
    }
    files
}

/// Parses a rename path like `dir/{old.txt => new.txt}` into `(old, new)`.
///
/// Returns `None` if the path is not a rename.
fn parse_rename_path(path: &str) -> Option<(String, String)> {
    // Git can output renames in two formats:
    // 1. `old.txt => new.txt` (simple rename)
    // 2. `dir/{old.txt => new.txt}` (rename with common prefix/suffix)

    // Try the braced format first
    if let Some((before_brace, rest)) = path.split_once('{')
        && let Some((old_new, after_brace)) = rest.split_once('}')
        && let Some((old, new)) = old_new.split_once(" => ")
    {
        let old = format!("{before_brace}{old}{after_brace}");
        let new = format!("{before_brace}{new}{after_brace}");
        return Some((old, new));
    }

    // Fall back to simple format
    if let Some((old, new)) = path.split_once(" => ") {
        return Some((old.to_string(), new.to_string()));
    }

    None
}

/// Infers the file status from the paths: if they differ, it's a rename.
fn file_status_from_paths(old: &str, new: &str) -> FileStatus {
    if old == new {
        FileStatus::Modified
    } else {
        FileStatus::Renamed
    }
}

/// Parses the unified diff patch into `DiffFile`s, enriched with numstat.
///
/// The numstat already tells us the file list, status, and counts; this
/// function fills in the hunks and marks binary files.
fn parse_patch(patch: &str, numstat_files: Vec<(String, String, u64, u64, bool)>) -> Vec<DiffFile> {
    let mut files: Vec<DiffFile> = numstat_files
        .into_iter()
        .map(|(old, new, added, deleted, binary)| {
            let status = if old == new {
                if added == 0 && !binary {
                    // Could be a mode change or an empty diff; treat as Modified.
                    FileStatus::Modified
                } else if added > 0 && deleted == 0 {
                    // Pure addition: could be a new file or just additions.
                    // We can't tell from paths alone; the diff header clarifies.
                    FileStatus::Added
                } else {
                    FileStatus::Modified
                }
            } else {
                FileStatus::Renamed
            };
            DiffFile {
                old_path: old,
                new_path: new,
                status,
                binary,
                added,
                deleted,
                hunks: vec![],
            }
        })
        .collect();

    // Walk the patch and fill in hunks for each file.
    let mut current_file_idx: Option<usize> = None;
    let mut current_hunk: Option<Hunk> = None;

    for line in patch.lines() {
        if let Some(rest) = line.strip_prefix("diff --git") {
            // Start a new file. Find it in `files` by matching the `b/` path.
            if let Some(hunk) = current_hunk.take()
                && let Some(idx) = current_file_idx
            {
                files[idx].hunks.push(hunk);
            }
            current_file_idx = find_file_in_patch_header(rest, &files);
        } else if line.starts_with("@@") {
            // Start a new hunk.
            if let Some(hunk) = current_hunk.take()
                && let Some(idx) = current_file_idx
            {
                files[idx].hunks.push(hunk);
            }
            current_hunk = Some(Hunk {
                header: line.to_string(),
                lines: vec![],
            });
        } else if let Some(hunk) = current_hunk.as_mut() {
            // A line within a hunk.
            if let Some(rest) = line.strip_prefix('+') {
                hunk.lines.push(DiffLine {
                    kind: DiffLineKind::Added,
                    text: rest.to_string(),
                });
            } else if let Some(rest) = line.strip_prefix('-') {
                hunk.lines.push(DiffLine {
                    kind: DiffLineKind::Removed,
                    text: rest.to_string(),
                });
            } else if let Some(rest) = line.strip_prefix(' ') {
                hunk.lines.push(DiffLine {
                    kind: DiffLineKind::Context,
                    text: rest.to_string(),
                });
            }
            // `\ No newline at end of file` and other metadata lines are ignored.
        }
    }

    // Flush the last hunk and file.
    if let Some(hunk) = current_hunk.take()
        && let Some(idx) = current_file_idx
    {
        files[idx].hunks.push(hunk);
    }

    // Mark binary files detected from the patch ("Binary files ... differ").
    for line in patch.lines() {
        if line.starts_with("Binary files") {
            // Find the file by name; this is a heuristic.
            // The line is "Binary files a/old and b/new differ".
            if let Some((old, new)) = parse_binary_line(line)
                && let Some(idx) = files
                    .iter()
                    .position(|f| f.old_path == old || f.new_path == new)
            {
                files[idx].binary = true;
                files[idx].hunks.clear();
            }
        }
    }

    files
}

/// Finds a file in the numstat list by matching the `b/` path from the
/// `diff --git a/old b/new` header.
fn find_file_in_patch_header(header: &str, files: &[DiffFile]) -> Option<usize> {
    // header is " a/old b/new" (leading space). Extract b/new.
    let header = header.trim();
    let (_a, b) = header.split_once(" b/")?;
    files
        .iter()
        .position(|f| f.new_path == b || f.old_path == b)
}

/// Parses "Binary files a/old and b/new differ" into (old, new).
fn parse_binary_line(line: &str) -> Option<(String, String)> {
    let rest = line.strip_prefix("Binary files ")?;
    let (a, rest) = rest.split_once(" and ")?;
    let (b, _) = rest.split_once(" differ")?;
    let old = a.strip_prefix("a/")?.to_string();
    let new = b.strip_prefix("b/")?.to_string();
    Some((old, new))
}

/// Every ref a tree address may resolve against: the branches and the tags,
/// short-named as a reader writes them.
///
/// This is what tells a branch `feature/x` from a directory `x` on a branch
/// `feature`: the tail of an address is matched against these, longest name
/// first.
#[tracing::instrument(level = "debug", fields(bare = %bare.display()))]
pub async fn ref_names(bare: &Path) -> std::io::Result<Vec<String>> {
    let output = git(
        bare,
        &[
            "for-each-ref",
            "--format=%(refname:short)",
            "refs/heads",
            "refs/tags",
        ],
    )
    .await?;

    Ok(output
        .lines()
        .filter(|line| !line.is_empty())
        .map(str::to_string)
        .collect())
}

/// Whether `rev` reaches a commit, which is how an address naming no ref -
/// a raw commit hash, say - is answered rather than refused.
#[tracing::instrument(level = "debug", fields(bare = %bare.display()))]
pub async fn resolves_to_commit(bare: &Path, rev: &str) -> std::io::Result<bool> {
    let target = format!("{rev}^{{commit}}");
    Ok(git_optional(bare, &["rev-parse", "--verify", &target])
        .await?
        .is_some())
}

/// What git says the object at a path is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectKind {
    Tree,
    Blob,
    /// A commit or tag object: somewhere a tree address could name, but not
    /// somewhere to walk into or a file to read.
    Other,
}

/// What the object at `spec` is, or `None` when there is no such object.
///
/// `spec` is a tree-ish alone or with a path after a `:` - `main:src` - and
/// the answer is what tells a directory page from a file page.
#[tracing::instrument(level = "debug", fields(bare = %bare.display()))]
pub async fn object_kind(bare: &Path, spec: &str) -> std::io::Result<Option<ObjectKind>> {
    let Some(output) = git_optional(bare, &["cat-file", "-t", spec]).await? else {
        return Ok(None);
    };

    Ok(Some(match output.trim() {
        "tree" => ObjectKind::Tree,
        "blob" => ObjectKind::Blob,
        _ => ObjectKind::Other,
    }))
}

/// The bytes of the blob at `spec`, or `None` when there is no such blob.
///
/// `cat-file blob` writes the object's own bytes: nothing is added, nothing
/// is trimmed, and nothing is decoded - which is what a raw file address
/// promises.
#[tracing::instrument(level = "debug", fields(bare = %bare.display()))]
pub async fn blob(bare: &Path, spec: &str) -> std::io::Result<Option<Vec<u8>>> {
    git_bytes(bare, &["cat-file", "blob", spec]).await
}

/// Whether `bytes` read as text or as binary: a NUL within the first few
/// kilobytes, the same hint git itself gives on.
pub fn looks_binary(bytes: &[u8]) -> bool {
    bytes.iter().take(8_000).any(|byte| *byte == 0)
}

/// The readme in `names` to show a visitor who reads `languages`.
///
/// A localised readme wins over the plain one. The language is matched both as
/// the visitor sent it and as its primary subtag, so `fr-CA` finds `README.fr.md`
/// as well as `README.fr-CA.md`, and both the `.` and `_` separators are tried
/// because repositories use both. Only the top level of the tree is looked at.
fn choose_readme<'a>(names: &[&'a str], languages: &[String]) -> Option<&'a str> {
    let mut candidates: Vec<String> = Vec::new();

    for language in languages {
        for variant in language_variants(language) {
            for separator in ['.', '_'] {
                candidates.push(format!("README{separator}{variant}.md"));
                candidates.push(format!("readme{separator}{variant}.md"));
                candidates.push(format!("README{separator}{variant}.markdown"));
            }
        }
    }

    // The plain readme, in the spellings repositories actually use.
    for name in [
        "README.md",
        "README.markdown",
        "readme.md",
        "readme.markdown",
        "README",
        "readme",
    ] {
        candidates.push(name.to_string());
    }

    candidates
        .iter()
        .find_map(|candidate| names.iter().copied().find(|name| name == candidate))
}

/// The tags to try for a language: as the visitor wrote it, lowercased, and
/// its primary subtag, without repeats.
///
/// Language tags are case-insensitive, and repositories spell their readmes
/// both ways - `README.fr-CA.md` and `README.fr-ca.md` are both seen - so both
/// are tried rather than guessing which a repository chose.
fn language_variants(language: &str) -> Vec<String> {
    let lower = language.to_ascii_lowercase();
    let primary = lower
        .split_once('-')
        .map_or(lower.as_str(), |(primary, _)| primary)
        .to_string();

    let mut variants = Vec::new();
    for variant in [language.to_string(), lower, primary] {
        if !variants.contains(&variant) {
            variants.push(variant);
        }
    }

    variants
}

/// Runs `git -C <bare> <args>`, returning its standard output, and treats a
/// non-zero exit as an error.
async fn git(bare: &Path, args: &[&str]) -> std::io::Result<String> {
    git_optional(bare, args).await?.ok_or_else(|| {
        std::io::Error::other(format!(
            "git {} in {} failed",
            args.join(" "),
            bare.display()
        ))
    })
}

/// Runs `git -C <bare> <args>` and returns its standard output, or `None` when
/// git exits non-zero.
///
/// The distinction matters for the commands whose failure is an answer rather
/// than a fault - a branch that does not exist has no readme - but the stderr
/// is dropped either way, so it is never what a caller acts on.
///
/// What comes back is decoded lossily, which is right for everything git says
/// but wrong for a file it merely carries: [`blob`] asks [`git_bytes`] for the
/// bytes themselves.
async fn git_optional(bare: &Path, args: &[&str]) -> std::io::Result<Option<String>> {
    Ok(git_bytes(bare, args)
        .await?
        .map(|stdout| String::from_utf8_lossy(&stdout).into_owned()))
}

/// Like [`git_optional`], but with git's standard output undecoded.
///
/// This is the one place a `git` process is started for anything the webui
/// or the SSH server reads - every listing, ref name, object kind and blob
/// comes through here - so the span over it is the span over a git command
/// being run: what was asked, of which repository, and how git answered.
/// The exit code is recorded either way, because a non-zero exit is an
/// answer as often as a fault here (`rev-parse` naming a revision no
/// repository reaches), and which command refused is what tells the two
/// apart.
#[tracing::instrument(
    level = "debug",
    fields(bare = %bare.display(), args = ?args, exit_code = tracing::field::Empty)
)]
async fn git_bytes(bare: &Path, args: &[&str]) -> std::io::Result<Option<Vec<u8>>> {
    let output = Command::new("git")
        .arg("-C")
        .arg(bare)
        .args(args)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .await?;

    tracing::Span::current().record("exit_code", output.status.code());

    if !output.status.success() {
        return Ok(None);
    }

    Ok(Some(output.stdout))
}

/// Runs `git upload-pack` for one HTTP request, returning what it wrote.
///
/// `advertise` asks for the ref advertisement the `info/refs` endpoint sends;
/// otherwise `request` is the client's negotiation, written to git's standard
/// input before the pipe is closed - git reads to end-of-file before it
/// answers. `protocol` is the `GIT_PROTOCOL` the client asked for, so that a
/// version 2 client gets a version 2 server, the same way the SSH server
/// passes it through.
#[tracing::instrument(level = "debug", skip(request, advertise), fields(bare = %bare.display()))]
pub async fn upload_pack(
    bare: &Path,
    request: &[u8],
    protocol: Option<&str>,
    advertise: bool,
) -> std::io::Result<Vec<u8>> {
    let mut command = Command::new("git");
    command.arg("upload-pack");
    if advertise {
        command.arg("--advertise-refs");
    }
    command
        .arg("--stateless-rpc")
        .arg(bare)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        // If the request ends early the child must not be left behind, since
        // it is only reachable through this response.
        .kill_on_drop(true);

    if let Some(protocol) = protocol {
        command.env("GIT_PROTOCOL", protocol);
    }

    let mut child = command.spawn()?;

    {
        use tokio::io::AsyncWriteExt as _;

        let mut stdin = child
            .stdin
            .take()
            .expect("standard input was asked to be piped");
        stdin.write_all(request).await?;
        stdin.shutdown().await?;
    }

    let output = child.wait_with_output().await?;
    if !output.status.success() {
        return Err(std::io::Error::other(format!(
            "git upload-pack in {} failed: {}",
            bare.display(),
            String::from_utf8_lossy(&output.stderr).trim_end()
        )));
    }

    Ok(output.stdout)
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::{Path, PathBuf};
    use std::process::Command;
    use std::sync::atomic::{AtomicU64, Ordering};

    use super::*;

    /// Names the scratch directories, so several tests can run at once.
    static COUNTER: AtomicU64 = AtomicU64::new(0);

    /// A directory of the test's own, removed when it goes out of scope.
    struct Scratch {
        dir: PathBuf,
    }

    impl Scratch {
        fn new(label: &str) -> Self {
            let count = COUNTER.fetch_add(1, Ordering::Relaxed);
            let dir = std::env::temp_dir()
                .join(format!("frogue-git-{label}-{}-{count}", std::process::id()));
            fs::create_dir_all(&dir).expect("the scratch directory should be creatable");
            Scratch { dir }
        }

        fn path(&self, name: &str) -> PathBuf {
            self.dir.join(name)
        }
    }

    impl Drop for Scratch {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.dir);
        }
    }

    /// A bare repository created the way the server creates one, with a working
    /// tree beside it to make commits in.
    async fn scaffold(label: &str) -> (Scratch, PathBuf, PathBuf) {
        let scratch = Scratch::new(label);
        let bare = scratch.path("repo.git");
        super::super::init_bare(&bare)
            .await
            .expect("the bare repository should be created");

        let work = scratch.path("work");
        fs::create_dir_all(&work).expect("the working tree should be creatable");
        git_in(&work, &[], &["init", "-q", "-b", "main"]);

        (scratch, bare, work)
    }

    /// Runs `git` in `dir` with the test's own identity, and returns stdout.
    fn git_in(dir: &Path, env: &[(&str, &str)], args: &[&str]) -> String {
        let mut command = Command::new("git");
        command
            .current_dir(dir)
            .args(["-c", "user.name=Test", "-c", "user.email=test@example.org"])
            .args(args)
            .env("GIT_CONFIG_GLOBAL", "/dev/null")
            .env("GIT_CONFIG_SYSTEM", "/dev/null");
        for (name, value) in env {
            command.env(name, value);
        }

        let output = command.output().expect("git should run");
        assert!(
            output.status.success(),
            "git {args:?} failed:\n{}",
            String::from_utf8_lossy(&output.stderr)
        );

        String::from_utf8_lossy(&output.stdout).into_owned()
    }

    #[tokio::test]
    async fn branches_are_ordered_by_their_latest_commit_and_limited() {
        let (scratch, bare, work) = scaffold("branches").await;

        // One commit per branch, dated so the order is the test's to decide
        // rather than the clock's.
        for (name, date, subject) in [
            ("one", "2021-01-01T00:00:00+00:00", "first"),
            ("two", "2021-01-02T00:00:00+00:00", "second"),
            ("three", "2021-01-03T00:00:00+00:00", "third"),
            ("four", "2021-01-04T00:00:00+00:00", "fourth"),
        ] {
            let dates = [("GIT_AUTHOR_DATE", date), ("GIT_COMMITTER_DATE", date)];
            git_in(&work, &dates, &["checkout", "-q", "-B", name]);
            git_in(
                &work,
                &dates,
                &["commit", "-q", "--allow-empty", "-m", subject],
            );
        }

        git_in(&work, &[], &["push", "-q", "--all", bare.to_str().unwrap()]);

        let branches = branches(&bare, Some(REF_LIMIT))
            .await
            .expect("git should answer");
        let names: Vec<&str> = branches.iter().map(|branch| branch.name.as_str()).collect();
        assert_eq!(
            names,
            ["four", "three", "two"],
            "newest first, at most three"
        );

        assert_eq!(branches[0].commit.subject, "fourth");
        assert_eq!(branches[0].commit.author, "Test");
        assert!(
            !branches[0].commit.hash.is_empty(),
            "the commit should carry a hash"
        );
        assert_eq!(
            branches[0].commit.full_hash.len(),
            40,
            "and the full hash a tree address is built from"
        );
        assert!(
            branches[0]
                .commit
                .full_hash
                .starts_with(&branches[0].commit.hash),
            "the short hash is the head of the full one"
        );

        // The scratch is only here to keep the other two owned through the
        // test; dropping it removes them.
        drop(scratch);
    }

    #[tokio::test]
    async fn a_listing_reads_one_directory_at_a_time() {
        let (scratch, bare, work) = scaffold("listing").await;

        fs::write(work.join("README.md"), "# Hello\n").expect("writing the readme");
        fs::create_dir_all(work.join("src/deep")).expect("the directories should be creatable");
        fs::write(work.join("src/lib.rs"), "pub fn lib() {}\n").expect("writing a file");
        fs::write(work.join("src/deep/nested.txt"), "deep\n").expect("writing a file");
        git_in(&work, &[], &["add", "-A"]);
        git_in(&work, &[], &["commit", "-q", "-m", "Add files"]);
        git_in(&work, &[], &["push", "-q", bare.to_str().unwrap(), "main"]);

        let root = listing(&bare, "main", "")
            .await
            .expect("git should answer")
            .expect("the root should be listed");
        let names: Vec<(&str, bool)> = root
            .iter()
            .map(|entry| (entry.name.as_str(), entry.kind == EntryKind::Directory))
            .collect();
        assert_eq!(
            names,
            [("README.md", false), ("src", true),],
            "the root's entries and which of them are directories"
        );

        let nested = listing(&bare, "main", "src")
            .await
            .expect("git should answer")
            .expect("`src` should be listed");
        let names: Vec<(&str, bool)> = nested
            .iter()
            .map(|entry| (entry.name.as_str(), entry.kind == EntryKind::Directory))
            .collect();
        assert_eq!(names, [("deep", true), ("lib.rs", false)]);

        assert_eq!(
            listing(&bare, "main", "nowhere")
                .await
                .expect("git should answer"),
            None,
            "a path that is not in the tree"
        );
        assert_eq!(
            listing(&bare, "nowhere", "")
                .await
                .expect("git should answer"),
            None,
            "a revision that does not exist"
        );

        drop(scratch);
    }

    #[tokio::test]
    async fn ref_names_lists_the_branches_and_tags() {
        let (scratch, bare, work) = scaffold("ref-names").await;

        git_in(
            &work,
            &[],
            &["commit", "-q", "--allow-empty", "-m", "Start"],
        );
        git_in(&work, &[], &["checkout", "-q", "-b", "topic"]);
        git_in(
            &work,
            &[],
            &["commit", "-q", "--allow-empty", "-m", "On topic"],
        );
        git_in(&work, &[], &["tag", "-a", "v1", "-m", "First"]);
        git_in(&work, &[], &["push", "-q", "--all", bare.to_str().unwrap()]);
        git_in(
            &work,
            &[],
            &["push", "-q", bare.to_str().unwrap(), "--tags"],
        );

        let mut names = ref_names(&bare).await.expect("git should answer");
        names.sort();
        assert_eq!(names, ["main", "topic", "v1"], "branches and tags alike");

        drop(scratch);
    }

    #[tokio::test]
    async fn an_object_kind_says_what_a_path_is() {
        let (scratch, bare, work) = scaffold("object-kind").await;

        fs::write(work.join("README.md"), "# Hello\n").expect("writing the readme");
        fs::create_dir_all(work.join("src")).expect("the directory should be creatable");
        fs::write(work.join("src/lib.rs"), "pub fn lib() {}\n").expect("writing a file");
        git_in(&work, &[], &["add", "-A"]);
        git_in(&work, &[], &["commit", "-q", "-m", "Add files"]);
        git_in(&work, &[], &["push", "-q", bare.to_str().unwrap(), "main"]);

        assert_eq!(
            object_kind(&bare, "main:src")
                .await
                .expect("git should answer"),
            Some(ObjectKind::Tree),
            "a directory"
        );
        assert_eq!(
            object_kind(&bare, "main:README.md")
                .await
                .expect("git should answer"),
            Some(ObjectKind::Blob),
            "a file"
        );
        // A bare revision is a commit, which is neither somewhere to walk
        // into nor a file to read.
        assert_eq!(
            object_kind(&bare, "main").await.expect("git should answer"),
            Some(ObjectKind::Other),
            "a commit"
        );
        assert_eq!(
            object_kind(&bare, "main:nowhere")
                .await
                .expect("git should answer"),
            None,
            "a path no tree holds"
        );

        drop(scratch);
    }

    #[tokio::test]
    async fn a_blob_returns_the_bytes_git_stored() {
        let (scratch, bare, work) = scaffold("blob").await;

        let odd = [0x00u8, 0x01, 0xFF, b'a', b'b'];
        fs::write(work.join("odd.bin"), odd).expect("writing the file");
        git_in(&work, &[], &["add", "-A"]);
        git_in(&work, &[], &["commit", "-q", "-m", "Add the file"]);
        git_in(&work, &[], &["push", "-q", bare.to_str().unwrap(), "main"]);

        assert_eq!(
            blob(&bare, "main:odd.bin")
                .await
                .expect("git should answer")
                .expect("the blob should be read"),
            odd,
            "the bytes git stored, NULs and all, with nothing added or trimmed"
        );

        assert_eq!(
            blob(&bare, "main").await.expect("git should answer"),
            None,
            "a commit is not a blob"
        );
        assert_eq!(
            blob(&bare, "main:nowhere")
                .await
                .expect("git should answer"),
            None,
            "a path no tree holds"
        );

        drop(scratch);
    }

    #[test]
    fn binary_is_guessed_by_the_hint_git_gives() {
        assert!(!looks_binary(b""), "an empty file is text");
        assert!(!looks_binary(b"plain words\n"), "prose is text");
        assert!(
            !looks_binary(&vec![b'x'; 20_000]),
            "a long text file is still text"
        );
        assert!(looks_binary(&[b'a', 0x00, b'b']), "a NUL says binary");

        // Past the first few kilobytes is where git stops looking, and so
        // is this: a NUL out there says nothing about how the file starts.
        let mut just_inside = vec![b'a'; 7_999];
        just_inside.push(0);
        assert!(looks_binary(&just_inside), "within them counts");

        let mut just_past = vec![b'a'; 8_000];
        just_past.push(0);
        assert!(!looks_binary(&just_past), "past them does not");
    }

    #[tokio::test]
    async fn an_unlimited_listing_returns_all_branches_and_tags() {
        let (scratch, bare, work) = scaffold("all-refs").await;

        // Dated so the order is the test's to decide rather than the
        // clock's, and alphabetical in the opposite order for the same
        // reason as the limited test.
        for (name, date, subject) in [
            ("one", "2021-01-01T00:00:00+00:00", "first"),
            ("two", "2021-01-02T00:00:00+00:00", "second"),
            ("three", "2021-01-03T00:00:00+00:00", "third"),
            ("four", "2021-01-04T00:00:00+00:00", "fourth"),
        ] {
            let dates = [("GIT_AUTHOR_DATE", date), ("GIT_COMMITTER_DATE", date)];
            git_in(&work, &dates, &["checkout", "-q", "-B", name]);
            git_in(
                &work,
                &dates,
                &["commit", "-q", "--allow-empty", "-m", subject],
            );
        }

        for (date, tag) in [
            ("2021-02-01T00:00:00+00:00", "v1"),
            ("2021-02-02T00:00:00+00:00", "v2"),
            ("2021-02-03T00:00:00+00:00", "v3"),
            ("2021-02-04T00:00:00+00:00", "v4"),
        ] {
            let dates = [("GIT_COMMITTER_DATE", date)];
            git_in(&work, &dates, &["tag", "-a", tag, "-m", tag]);
        }

        git_in(&work, &[], &["push", "-q", "--all", bare.to_str().unwrap()]);
        git_in(
            &work,
            &[],
            &["push", "-q", bare.to_str().unwrap(), "--tags"],
        );

        let branches = branches(&bare, None).await.expect("git should answer");
        let names: Vec<&str> = branches.iter().map(|branch| branch.name.as_str()).collect();
        assert_eq!(
            names,
            ["four", "three", "two", "one"],
            "every branch, newest first"
        );

        let tags = tags(&bare, None).await.expect("git should answer");
        let names: Vec<&str> = tags.iter().map(|tag| tag.name.as_str()).collect();
        assert_eq!(names, ["v4", "v3", "v2", "v1"], "every tag, newest first");

        drop(scratch);
    }

    #[tokio::test]
    async fn tags_are_ordered_by_when_they_were_made() {
        let (scratch, bare, work) = scaffold("tags").await;

        for (date, subject, tag, message) in [
            ("2022-02-01T00:00:00+00:00", "first", "v1", "release one"),
            ("2022-02-02T00:00:00+00:00", "second", "v2", "release two"),
            ("2022-02-03T00:00:00+00:00", "third", "v3", "release three"),
            ("2022-02-04T00:00:00+00:00", "fourth", "v4", "release four"),
        ] {
            let dates = [("GIT_AUTHOR_DATE", date), ("GIT_COMMITTER_DATE", date)];
            git_in(
                &work,
                &dates,
                &["commit", "-q", "--allow-empty", "-m", subject],
            );
            git_in(&work, &dates, &["tag", "-a", tag, "-m", message]);
        }

        git_in(&work, &[], &["push", "-q", bare.to_str().unwrap(), "main"]);
        git_in(
            &work,
            &[],
            &["push", "-q", bare.to_str().unwrap(), "--tags"],
        );

        let tags = tags(&bare, Some(REF_LIMIT))
            .await
            .expect("git should answer");
        let names: Vec<&str> = tags.iter().map(|tag| tag.name.as_str()).collect();
        assert_eq!(names, ["v4", "v3", "v2"], "newest first, at most three");

        // The tag is annotated, so this is the commit it points at rather than
        // the tag object: the subject is the commit's, not the tag message's.
        assert_eq!(tags[0].commit.subject, "fourth");
        assert_eq!(tags[0].commit.author, "Test");

        drop(scratch);
    }

    #[tokio::test]
    async fn a_readme_is_found_and_localised() {
        let (scratch, bare, work) = scaffold("readme").await;

        fs::write(work.join("README.md"), "# Hello\n").expect("writing the readme");
        fs::write(work.join("README.fr.md"), "# Bonjour\n").expect("writing the readme");
        fs::write(work.join("README_fr-CA.md"), "# Allo\n").expect("writing the readme");
        git_in(&work, &[], &["add", "-A"]);
        git_in(&work, &[], &["commit", "-q", "-m", "Add a readme"]);
        git_in(&work, &[], &["push", "-q", bare.to_str().unwrap(), "main"]);

        // With no preference, the plain readme.
        let readme = super::readme(&bare, "main", &[])
            .await
            .expect("git should answer")
            .expect("a readme should be found");
        assert_eq!(readme.path, "README.md");
        assert_eq!(readme.content, "# Hello\n");

        // A preference for French finds the French one, dot separator.
        let readme = super::readme(&bare, "main", &["fr".to_string()])
            .await
            .expect("git should answer")
            .expect("a readme should be found");
        assert_eq!(readme.path, "README.fr.md");

        // A regional preference finds the regional readme, underscore
        // separator.
        let readme = super::readme(&bare, "main", &["fr-CA".to_string()])
            .await
            .expect("git should answer")
            .expect("a readme should be found");
        assert_eq!(readme.path, "README_fr-CA.md");

        // A region with no readme of its own falls back to its language's.
        let readme = super::readme(&bare, "main", &["fr-FR".to_string()])
            .await
            .expect("git should answer")
            .expect("a readme should be found");
        assert_eq!(readme.path, "README.fr.md");

        // A language nobody wrote for falls back to the plain readme.
        let readme = super::readme(&bare, "main", &["de".to_string()])
            .await
            .expect("git should answer")
            .expect("a readme should be found");
        assert_eq!(readme.path, "README.md");

        drop(scratch);
    }

    #[tokio::test]
    async fn a_branch_with_no_commit_has_no_readme() {
        let (scratch, bare, _work) = scaffold("empty").await;

        let found = readme(&bare, "main", &[]).await.expect("git should answer");

        assert!(found.is_none(), "an empty repository has no readme");
        drop(scratch);
    }

    #[tokio::test]
    async fn an_empty_repository_lists_no_refs() {
        let (scratch, bare, _work) = scaffold("refs").await;

        assert_eq!(branches(&bare, Some(REF_LIMIT)).await.unwrap(), Vec::new());
        assert_eq!(tags(&bare, Some(REF_LIMIT)).await.unwrap(), Vec::new());

        drop(scratch);
    }

    #[tokio::test]
    async fn upload_pack_advertises_the_branches() {
        let (scratch, bare, work) = scaffold("advertise").await;

        git_in(
            &work,
            &[],
            &["commit", "-q", "--allow-empty", "-m", "Start"],
        );
        git_in(&work, &[], &["push", "-q", bare.to_str().unwrap(), "main"]);

        let advertised = upload_pack(&bare, &[], None, true)
            .await
            .expect("git should answer");
        let advertised = String::from_utf8_lossy(&advertised);

        assert!(
            advertised.contains("refs/heads/main"),
            "the advertisement should name the branch, got:\n{advertised}"
        );

        drop(scratch);
    }

    #[test]
    fn readme_candidates_prefer_the_language_then_the_plain_one() {
        let names = ["README.md", "README.fr.md", "README_de.md"];
        assert_eq!(
            choose_readme(&names, &["fr".to_string()]),
            Some("README.fr.md")
        );
        assert_eq!(
            choose_readme(&names, &["de".to_string()]),
            Some("README_de.md")
        );
        assert_eq!(
            choose_readme(&names, &["es".to_string()]),
            Some("README.md")
        );
        assert_eq!(choose_readme(&names, &[]), Some("README.md"));

        // The underscore spelling is found when that is what the repository
        // uses.
        let underscored = ["README.md", "README_fr.md"];
        assert_eq!(
            choose_readme(&underscored, &["fr".to_string()]),
            Some("README_fr.md")
        );

        // A regional tag finds its language's readme.
        let regional = ["README.md", "README.fr.md"];
        assert_eq!(
            choose_readme(&regional, &["fr-CA".to_string()]),
            Some("README.fr.md")
        );
    }

    #[test]
    fn parse_numstat_handles_normal_binary_and_rename_files() {
        let numstat = "10\t5\tfile.txt\n\
                       -\t-\timage.png\n\
                       3\t2\t{old.txt => new.txt}\n\
                       1\t0\tdir/{old.rs => new.rs}\n";

        let files = parse_numstat(numstat);

        assert_eq!(files.len(), 4);
        assert_eq!(
            files[0],
            ("file.txt".to_string(), "file.txt".to_string(), 10, 5, false)
        );
        assert_eq!(
            files[1],
            ("image.png".to_string(), "image.png".to_string(), 0, 0, true)
        );
        assert_eq!(
            files[2],
            ("old.txt".to_string(), "new.txt".to_string(), 3, 2, false)
        );
        assert_eq!(
            files[3],
            (
                "dir/old.rs".to_string(),
                "dir/new.rs".to_string(),
                1,
                0,
                false
            )
        );
    }

    #[test]
    fn parse_patch_fills_in_hunks_from_numstat() {
        let patch = "\
diff --git a/file.txt b/file.txt
index 1234567..abcdefg 100644
--- a/file.txt
+++ b/file.txt
@@ -1,3 +1,4 @@
 context line
-old line
+new line
+added line
 another context
";

        let numstat = vec![("file.txt".to_string(), "file.txt".to_string(), 2, 1, false)];

        let files = parse_patch(patch, numstat);

        assert_eq!(files.len(), 1);
        assert_eq!(files[0].new_path, "file.txt");
        assert_eq!(files[0].added, 2);
        assert_eq!(files[0].deleted, 1);
        assert!(!files[0].binary);
        assert_eq!(files[0].hunks.len(), 1);
        assert!(files[0].hunks[0].header.starts_with("@@ -1,3 +1,4 @@"));
        assert_eq!(files[0].hunks[0].lines.len(), 5);
        assert_eq!(files[0].hunks[0].lines[0].kind, DiffLineKind::Context);
        assert_eq!(files[0].hunks[0].lines[0].text, "context line");
        assert_eq!(files[0].hunks[0].lines[1].kind, DiffLineKind::Removed);
        assert_eq!(files[0].hunks[0].lines[1].text, "old line");
        assert_eq!(files[0].hunks[0].lines[2].kind, DiffLineKind::Added);
        assert_eq!(files[0].hunks[0].lines[2].text, "new line");
        assert_eq!(files[0].hunks[0].lines[3].kind, DiffLineKind::Added);
        assert_eq!(files[0].hunks[0].lines[3].text, "added line");
        assert_eq!(files[0].hunks[0].lines[4].kind, DiffLineKind::Context);
        assert_eq!(files[0].hunks[0].lines[4].text, "another context");
    }

    #[test]
    fn parse_patch_marks_binary_files_from_the_patch() {
        let patch = "diff --git a/image.png b/image.png\n\
                     new file mode 100644\n\
                     index 0000000..1234567\n\
                     Binary files /dev/null and b/image.png differ\n";

        let numstat = vec![("image.png".to_string(), "image.png".to_string(), 0, 0, true)];

        let files = parse_patch(patch, numstat);

        assert_eq!(files.len(), 1);
        assert!(files[0].binary);
        assert!(files[0].hunks.is_empty(), "binary files have no hunks");
    }

    #[test]
    fn parse_rename_path_handles_simple_and_nested_renames() {
        assert_eq!(
            parse_rename_path("old.txt => new.txt"),
            Some(("old.txt".to_string(), "new.txt".to_string()))
        );
        assert_eq!(
            parse_rename_path("dir/{old.rs => new.rs}"),
            Some(("dir/old.rs".to_string(), "dir/new.rs".to_string()))
        );
        assert_eq!(parse_rename_path("plain.txt"), None);
    }

    #[test]
    fn file_status_from_paths_distinguishes_rename_from_modify() {
        assert_eq!(
            file_status_from_paths("old.txt", "new.txt"),
            FileStatus::Renamed
        );
        assert_eq!(
            file_status_from_paths("file.txt", "file.txt"),
            FileStatus::Modified
        );
    }

    #[tokio::test]
    async fn history_returns_every_commit_reachable_from_a_revision() {
        let (scratch, bare, work) = scaffold("history").await;

        for (date, subject) in [
            ("2021-01-01T00:00:00+00:00", "first"),
            ("2021-01-02T00:00:00+00:00", "second"),
            ("2021-01-03T00:00:00+00:00", "third"),
        ] {
            let dates = [("GIT_AUTHOR_DATE", date), ("GIT_COMMITTER_DATE", date)];
            git_in(
                &work,
                &dates,
                &["commit", "-q", "--allow-empty", "-m", subject],
            );
        }

        git_in(&work, &[], &["push", "-q", bare.to_str().unwrap(), "main"]);

        let commits = history(&bare, "main")
            .await
            .expect("git should answer")
            .expect("a reachable revision should give a listing");

        let subjects: Vec<&str> = commits
            .iter()
            .map(|commit| commit.subject.as_str())
            .collect();
        assert_eq!(subjects, ["third", "second", "first"], "newest first");

        assert_eq!(commits[0].author, "Test");
        assert!(
            commits[0].hash.len() >= 7 && commits[0].hash.len() <= 40,
            "the short hash should be abbreviated"
        );
        assert_eq!(
            commits[0].full_hash.len(),
            40,
            "the full hash should be 40 characters"
        );
        assert!(
            commits[0].full_hash.starts_with(&commits[0].hash),
            "the short hash is the head of the full one"
        );
        assert!(
            commits[0].date.contains("2021"),
            "the date should be ISO 8601, got: {}",
            commits[0].date
        );

        drop(scratch);
    }

    #[tokio::test]
    async fn history_includes_per_commit_stats() {
        let (scratch, bare, work) = scaffold("history-stats").await;

        // First commit: add two files
        fs::write(work.join("file1.txt"), "line1\nline2\nline3\n").expect("writing file1");
        fs::write(work.join("file2.txt"), "a\nb\n").expect("writing file2");
        git_in(&work, &[], &["add", "file1.txt", "file2.txt"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-01T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-01T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "Add files"]);

        // Second commit: modify one file, add another
        fs::write(work.join("file1.txt"), "line1\nmodified\nline3\nnew line\n")
            .expect("modifying file1");
        fs::write(work.join("file3.txt"), "x\ny\nz\n").expect("writing file3");
        git_in(&work, &[], &["add", "file1.txt", "file3.txt"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-02T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-02T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "Modify and add"]);

        git_in(&work, &[], &["push", "-q", bare.to_str().unwrap(), "main"]);

        let commits = history(&bare, "main")
            .await
            .expect("git should answer")
            .expect("history should exist");

        assert_eq!(commits.len(), 2);

        // Newest commit: modify file1.txt (2 additions, 1 deletion) + add file3.txt (3 additions)
        // Total: 2 files, 5 insertions, 1 deletion
        assert_eq!(commits[0].subject, "Modify and add");
        assert_eq!(commits[0].files_changed, 2);
        assert_eq!(commits[0].insertions, 5);
        assert_eq!(commits[0].deletions, 1);

        // Oldest commit: add file1.txt (3 lines) + add file2.txt (2 lines)
        // Total: 2 files, 5 insertions, 0 deletions
        assert_eq!(commits[1].subject, "Add files");
        assert_eq!(commits[1].files_changed, 2);
        assert_eq!(commits[1].insertions, 5);
        assert_eq!(commits[1].deletions, 0);

        drop(scratch);
    }

    #[tokio::test]
    async fn history_stats_handle_binary_files() {
        let (scratch, bare, work) = scaffold("history-binary").await;

        // Add a binary file
        let binary = vec![0u8, 1, 2, 3, 255];
        fs::write(work.join("data.bin"), &binary).expect("writing binary");
        git_in(&work, &[], &["add", "data.bin"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-01T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-01T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "Add binary"]);

        git_in(&work, &[], &["push", "-q", bare.to_str().unwrap(), "main"]);

        let commits = history(&bare, "main")
            .await
            .expect("git should answer")
            .expect("history should exist");

        assert_eq!(commits.len(), 1);
        assert_eq!(commits[0].subject, "Add binary");
        assert_eq!(commits[0].files_changed, 1);
        // Binary files show as "-" in numstat, which we should handle gracefully
        assert_eq!(commits[0].insertions, 0);
        assert_eq!(commits[0].deletions, 0);

        drop(scratch);
    }

    #[tokio::test]
    async fn history_only_sees_ancestors_of_the_revision() {
        let (scratch, bare, work) = scaffold("history-ancestors").await;

        // Two commits on main.
        for (date, subject) in [
            ("2021-01-01T00:00:00+00:00", "main first"),
            ("2021-01-02T00:00:00+00:00", "main second"),
        ] {
            let dates = [("GIT_AUTHOR_DATE", date), ("GIT_COMMITTER_DATE", date)];
            git_in(
                &work,
                &dates,
                &["commit", "-q", "--allow-empty", "-m", subject],
            );
        }

        // Branch off and commit on the branch.
        git_in(&work, &[], &["checkout", "-q", "-b", "topic"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-03T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-03T00:00:00+00:00"),
        ];
        git_in(
            &work,
            &dates,
            &["commit", "-q", "--allow-empty", "-m", "on topic"],
        );

        git_in(&work, &[], &["push", "-q", bare.to_str().unwrap(), "main"]);
        git_in(&work, &[], &["push", "-q", bare.to_str().unwrap(), "topic"]);

        // Main's history should only include its two commits.
        let main_history = history(&bare, "main")
            .await
            .expect("git should answer")
            .expect("main should be reachable");
        let main_subjects: Vec<&str> = main_history
            .iter()
            .map(|commit| commit.subject.as_str())
            .collect();
        assert_eq!(
            main_subjects,
            ["main second", "main first"],
            "main's history excludes the branch's commit"
        );

        // Topic's history should include all three: the branch's commit plus its ancestors.
        let topic_history = history(&bare, "topic")
            .await
            .expect("git should answer")
            .expect("topic should be reachable");
        let topic_subjects: Vec<&str> = topic_history
            .iter()
            .map(|commit| commit.subject.as_str())
            .collect();
        assert_eq!(
            topic_subjects,
            ["on topic", "main second", "main first"],
            "topic's history includes its ancestors on main"
        );

        drop(scratch);
    }

    #[tokio::test]
    async fn history_returns_none_for_a_revision_that_does_not_exist() {
        let (scratch, bare, work) = scaffold("history-missing").await;

        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-01T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-01T00:00:00+00:00"),
        ];
        git_in(
            &work,
            &dates,
            &["commit", "-q", "--allow-empty", "-m", "Start"],
        );
        git_in(&work, &[], &["push", "-q", bare.to_str().unwrap(), "main"]);

        let found = history(&bare, "nowhere")
            .await
            .expect("git should not fail");

        assert!(
            found.is_none(),
            "a revision git cannot resolve to should give None"
        );

        drop(scratch);
    }

    #[tokio::test]
    async fn history_returns_none_for_an_empty_repository() {
        let (scratch, bare, _work) = scaffold("history-empty").await;

        let found = history(&bare, "main").await.expect("git should not fail");

        assert!(
            found.is_none(),
            "an unborn default branch on an empty repo should give None"
        );

        drop(scratch);
    }

    #[tokio::test]
    async fn commit_diff_returns_files_changed_in_a_commit() {
        let (scratch, bare, work) = scaffold("diff-basic").await;

        // First commit: add a file.
        fs::write(work.join("readme.txt"), "hello\n").expect("writing the readme");
        git_in(&work, &[], &["add", "readme.txt"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-01T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-01T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "Add readme"]);

        // Second commit: modify the file.
        fs::write(work.join("readme.txt"), "hello\nworld\n").expect("modifying the readme");
        git_in(&work, &[], &["add", "readme.txt"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-02T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-02T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "Add a line"]);

        git_in(&work, &[], &["push", "-q", bare.to_str().unwrap(), "main"]);

        let head = git_in(&work, &[], &["rev-parse", "HEAD"])
            .trim()
            .to_string();

        let diff = commit_diff(&bare, &head)
            .await
            .expect("git should answer")
            .expect("the commit should exist");

        assert_eq!(diff.len(), 1, "one file changed");
        assert_eq!(diff[0].new_path, "readme.txt");
        assert_eq!(diff[0].old_path, "readme.txt");
        assert_eq!(diff[0].added, 1);
        assert_eq!(diff[0].deleted, 0);
        assert!(!diff[0].binary);
        assert_eq!(diff[0].hunks.len(), 1);
        assert_eq!(diff[0].hunks[0].lines.len(), 2);
        assert_eq!(diff[0].hunks[0].lines[0].kind, DiffLineKind::Context);
        assert_eq!(diff[0].hunks[0].lines[0].text, "hello");
        assert_eq!(diff[0].hunks[0].lines[1].kind, DiffLineKind::Added);
        assert_eq!(diff[0].hunks[0].lines[1].text, "world");

        drop(scratch);
    }

    #[tokio::test]
    async fn commit_diff_returns_none_for_a_missing_commit() {
        let (scratch, bare, _work) = scaffold("diff-missing").await;

        let diff = commit_diff(&bare, "deadbeef")
            .await
            .expect("git should not fail");

        assert!(diff.is_none(), "a non-existent commit should give None");

        drop(scratch);
    }

    #[tokio::test]
    async fn commit_diff_handles_binary_files() {
        let (scratch, bare, work) = scaffold("diff-binary").await;

        // Add a binary file.
        let binary = vec![0u8, 1, 2, 3, 255];
        fs::write(work.join("data.bin"), &binary).expect("writing the binary");
        git_in(&work, &[], &["add", "data.bin"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-01T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-01T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "Add binary"]);

        git_in(&work, &[], &["push", "-q", bare.to_str().unwrap(), "main"]);

        let head = git_in(&work, &[], &["rev-parse", "HEAD"])
            .trim()
            .to_string();

        let diff = commit_diff(&bare, &head)
            .await
            .expect("git should answer")
            .expect("the commit should exist");

        assert_eq!(diff.len(), 1);
        assert_eq!(diff[0].new_path, "data.bin");
        assert!(diff[0].binary, "binary files should be marked as such");
        assert!(diff[0].hunks.is_empty(), "binary files have no hunks");

        drop(scratch);
    }

    #[tokio::test]
    async fn commit_diff_handles_empty_commits() {
        let (scratch, bare, work) = scaffold("diff-empty").await;

        // Create an empty commit.
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-01T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-01T00:00:00+00:00"),
        ];
        git_in(
            &work,
            &dates,
            &["commit", "-q", "--allow-empty", "-m", "Empty"],
        );

        git_in(&work, &[], &["push", "-q", bare.to_str().unwrap(), "main"]);

        let head = git_in(&work, &[], &["rev-parse", "HEAD"])
            .trim()
            .to_string();

        let diff = commit_diff(&bare, &head)
            .await
            .expect("git should answer")
            .expect("the commit should exist");

        assert!(diff.is_empty(), "an empty commit has no files changed");

        drop(scratch);
    }

    #[tokio::test]
    async fn compare_diff_shows_only_what_head_brought_since_the_fork() {
        let (scratch, bare, work) = scaffold("compare-diverged").await;

        // c1 on main: readme.txt = "hello\n"
        fs::write(work.join("readme.txt"), "hello\n").expect("writing readme");
        git_in(&work, &[], &["add", "readme.txt"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-01T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-01T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "c1"]);

        // Branch feat at c1
        git_in(&work, &[], &["checkout", "-q", "-b", "feat"]);

        // c2 on feat: append line to readme.txt
        fs::write(work.join("readme.txt"), "hello\nfrom feat\n").expect("modifying readme");
        git_in(&work, &[], &["add", "readme.txt"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-02T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-02T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "c2"]);

        // c3 on feat: new file feature.txt
        fs::write(work.join("feature.txt"), "feature\n").expect("writing feature");
        git_in(&work, &[], &["add", "feature.txt"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-03T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-03T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "c3"]);

        // Back on main: c4 with different file
        git_in(&work, &[], &["checkout", "-q", "main"]);
        fs::write(work.join("main_only.txt"), "only on main\n").expect("writing main file");
        git_in(&work, &[], &["add", "main_only.txt"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-04T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-04T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "c4"]);

        git_in(&work, &[], &["push", "-q", "--all", bare.to_str().unwrap()]);

        // Three-dot: main...feat shows only feat-side changes
        let diff = compare_diff(&bare, "main...feat")
            .await
            .expect("git should answer")
            .expect("the range should resolve");

        // Should have readme.txt and feature.txt, but NOT main_only.txt
        assert_eq!(diff.len(), 2, "two files changed on feat side");

        let paths: Vec<&str> = diff.iter().map(|f| f.new_path.as_str()).collect();
        assert!(
            paths.contains(&"readme.txt"),
            "readme.txt should be in diff"
        );
        assert!(
            paths.contains(&"feature.txt"),
            "feature.txt should be in diff"
        );
        assert!(
            !paths.contains(&"main_only.txt"),
            "main_only.txt should NOT be in diff"
        );

        // Verify readme.txt has the feat-side change
        let readme_diff = diff.iter().find(|f| f.new_path == "readme.txt").unwrap();
        assert_eq!(readme_diff.added, 1, "one line added to readme");
        assert_eq!(readme_diff.deleted, 0, "no lines deleted from readme");

        drop(scratch);
    }

    #[tokio::test]
    async fn compare_diff_of_a_revision_with_itself_is_empty() {
        let (scratch, bare, work) = scaffold("compare-self").await;

        // Create a commit
        fs::write(work.join("readme.txt"), "hello\n").expect("writing readme");
        git_in(&work, &[], &["add", "readme.txt"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-01T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-01T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "c1"]);

        git_in(&work, &[], &["push", "-q", bare.to_str().unwrap(), "main"]);

        // main...main should be empty (merge-base is main itself)
        let diff = compare_diff(&bare, "main...main")
            .await
            .expect("git should answer")
            .expect("the range should resolve");

        assert!(
            diff.is_empty(),
            "comparing a revision with itself should yield no changes"
        );

        drop(scratch);
    }

    #[tokio::test]
    async fn compare_diff_returns_none_for_a_missing_revision() {
        let (scratch, bare, work) = scaffold("compare-missing").await;

        // Create a commit
        fs::write(work.join("readme.txt"), "hello\n").expect("writing readme");
        git_in(&work, &[], &["add", "readme.txt"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-01T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-01T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "c1"]);

        git_in(&work, &[], &["push", "-q", bare.to_str().unwrap(), "main"]);

        // main...deadbeef should return None
        let diff = compare_diff(&bare, "main...deadbeef")
            .await
            .expect("git should not fail");

        assert!(diff.is_none(), "a non-existent revision should give None");

        drop(scratch);
    }

    #[tokio::test]
    async fn compare_diff_returns_none_without_a_common_ancestor() {
        let (scratch, bare, work) = scaffold("compare-orphan").await;

        // First branch: main
        fs::write(work.join("main.txt"), "main\n").expect("writing main file");
        git_in(&work, &[], &["add", "main.txt"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-01T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-01T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "main commit"]);

        git_in(&work, &[], &["push", "-q", bare.to_str().unwrap(), "main"]);

        // Orphan branch with no common ancestor
        git_in(&work, &[], &["checkout", "-q", "--orphan", "orphan"]);
        git_in(&work, &[], &["rm", "-rf", "."]);

        fs::write(work.join("orphan.txt"), "orphan\n").expect("writing orphan file");
        git_in(&work, &[], &["add", "orphan.txt"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-02T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-02T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "orphan commit"]);

        git_in(
            &work,
            &[],
            &["push", "-q", bare.to_str().unwrap(), "orphan"],
        );

        // main...orphan should return None (no common ancestor)
        let diff = compare_diff(&bare, "main...orphan")
            .await
            .expect("git should not fail");

        assert!(diff.is_none(), "unrelated histories should give None");

        drop(scratch);
    }

    #[tokio::test]
    async fn compare_diff_marks_binary_files() {
        let (scratch, bare, work) = scaffold("compare-binary").await;

        // c1 on main
        fs::write(work.join("readme.txt"), "hello\n").expect("writing readme");
        git_in(&work, &[], &["add", "readme.txt"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-01T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-01T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "c1"]);

        // Branch feat
        git_in(&work, &[], &["checkout", "-q", "-b", "feat"]);

        // c2 on feat: add binary file with NUL bytes
        let binary = vec![0u8, 1, 2, 3, 255, 254];
        fs::write(work.join("data.bin"), &binary).expect("writing binary");
        git_in(&work, &[], &["add", "data.bin"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-02T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-02T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "c2"]);

        git_in(&work, &[], &["push", "-q", "--all", bare.to_str().unwrap()]);

        // main...feat should include the binary file
        let diff = compare_diff(&bare, "main...feat")
            .await
            .expect("git should answer")
            .expect("the range should resolve");

        assert_eq!(diff.len(), 1, "one file changed");
        assert_eq!(diff[0].new_path, "data.bin");
        assert!(diff[0].binary, "binary files should be marked as such");
        assert!(diff[0].hunks.is_empty(), "binary files have no hunks");

        drop(scratch);
    }

    #[tokio::test]
    async fn history_accepts_a_range() {
        let (scratch, bare, work) = scaffold("history-range").await;

        // c1 on main
        fs::write(work.join("readme.txt"), "hello\n").expect("writing readme");
        git_in(&work, &[], &["add", "readme.txt"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-01T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-01T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "c1 on main"]);

        // Branch feat
        git_in(&work, &[], &["checkout", "-q", "-b", "feat"]);

        // c2 on feat
        fs::write(work.join("feature.txt"), "feature\n").expect("writing feature");
        git_in(&work, &[], &["add", "feature.txt"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-02T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-02T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "c2 on feat"]);

        // c3 on feat
        fs::write(work.join("feature.txt"), "feature\nmore\n").expect("modifying feature");
        git_in(&work, &[], &["add", "feature.txt"]);
        let dates = [
            ("GIT_AUTHOR_DATE", "2021-01-03T00:00:00+00:00"),
            ("GIT_COMMITTER_DATE", "2021-01-03T00:00:00+00:00"),
        ];
        git_in(&work, &dates, &["commit", "-q", "-m", "c3 on feat"]);

        git_in(&work, &[], &["push", "-q", "--all", bare.to_str().unwrap()]);

        // history with range main..feat should return only feat-side commits
        let commits = history(&bare, "main..feat")
            .await
            .expect("git should answer")
            .expect("the range should resolve");

        assert_eq!(commits.len(), 2, "two commits on feat side");
        assert_eq!(commits[0].subject, "c3 on feat", "newest first");
        assert_eq!(commits[1].subject, "c2 on feat", "oldest first");

        // Verify numstat stats are filled
        assert!(commits[0].insertions > 0, "insertions should be counted");
        assert!(commits[1].insertions > 0, "insertions should be counted");

        drop(scratch);
    }
}
