//! 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(®ional, &["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);
}
}