Skip to content

Read a repository's history

By the end of this you'll have a program that opens a git repository, reports what's dirty, streams the commit log, shows what changed in the last commit, and attributes a file line by line — all through rtb-forge's Repo type.

Allow about twenty minutes, most of it waiting on the first cargo build. gix is a large dependency tree and the first compile takes a few minutes.

Before you start

You'll need:

  • Rust 1.82 or newer. That's the crate's minimum supported version.
  • git on your PATH — only to create the sample repository in step 2. Everything the program itself does runs on gix, in pure Rust, with no git binary involved.

No network access is needed beyond cargo fetching crates the first time. Nothing here touches a remote.

Create the project

cargo new --bin forge-tour
cd forge-tour

Add the three dependencies:

cargo add rtb-forge --no-default-features --features git
cargo add tokio --features macros,rt-multi-thread
cargo add futures

--no-default-features --features git asks for the git slice only. The defaults would also pull in all six release backends and their HTTP stack, which this program never uses. Your Cargo.toml should now have:

[dependencies]
futures = "0.3"
rtb-forge = { version = "0.7", default-features = false, features = ["git"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

futures is there for one thing: StreamExt, which gives you .next() on the commit stream. rtb-forge returns a futures_core::Stream and deliberately doesn't re-export an extension trait for it.

Build a sample repository to read

The program needs something to look at. Build a three-commit repository next to your project so the paths in the code are predictable:

cd ..
mkdir sample-repo && cd sample-repo
git init -q -b main
git config user.name "Ada Lovelace"
git config user.email "ada@example.com"

printf 'Notes\n=====\n\nFirst pass.\n' > README.md
git add README.md && git commit -qm "docs: start the notes file"

printf 'Notes\n=====\n\nFirst pass.\nSecond pass.\n' > README.md
printf 'MIT\n' > LICENSE
git add README.md LICENSE && git commit -qm "docs: add a licence and a second pass"

printf '# Changelog\n' > CHANGELOG.md
git rm -q README.md
git add CHANGELOG.md && git commit -qm "docs: replace the notes with a changelog"

cd ../forge-tour

git config is set locally rather than globally so this doesn't disturb your own identity. The three commits give you a file that's modified, one that's added, and one that's deleted — enough to see every ChangeKind the diff can produce.

Open the repository and check what's dirty

Replace src/main.rs with:

use rtb_forge::git::Repo;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let repo = Repo::open("../sample-repo").await?;
    println!("opened {}", repo.path().display());

    let status = repo.status().await?;
    println!(
        "status: {} unstaged, {} untracked",
        status.unstaged.len(),
        status.untracked.len()
    );

    Ok(())
}

Run it:

cargo run
opened ../sample-repo
status: 0 unstaged, 0 untracked

Two things worth noticing before you move on.

Repo::open is async but does no I/O on your task — it hands the blocking gix call to tokio::task::spawn_blocking. Every method on Repo that does real work behaves the same way, which is why the whole surface is async despite gix being a blocking library.

repo.path() gives back exactly the path you passed, not the worktree root. That matters later if you start writing: the subprocess-backed methods (commit, checkout, fetch, push) use it as their working directory.

RepoStatus has a third bucket, staged, and it's deliberately not printed here. It's always empty — the tree-to-index comparison is never computed, so staged changes don't show up in it or anywhere else. Don't use status() to ask "is there anything to commit". See what rtb-forge does not do.

Stream the commit log

Repo::walk is the one method that isn't async. It resolves the revspec straight away and hands back a stream; the traversal itself runs on a background task and pushes commits through a bounded channel, so a repository with 50,000 commits never materialises as a Vec.

Add the import at the top:

use futures::StreamExt as _;

And this before the Ok(()):

    println!("\n-- history --");
    let mut walk = repo.walk("HEAD")?;
    while let Some(commit) = walk.next().await {
        let commit = commit?;
        println!("{} {} <{}>", &commit.id[..7], commit.summary, commit.author_email);
    }
-- history --
daa476e docs: replace the notes with a changelog <ada@example.com>
fdd29c5 docs: add a licence and a second pass <ada@example.com>
4e22f2d docs: start the notes file <ada@example.com>

Your OIDs will differ — they encode the commit timestamps, which are whenever you ran step 2.

Note the double error handling. walk("HEAD")? fails immediately if the revspec doesn't resolve; each commit? inside the loop handles a failure that happens partway through the traversal. Both are RepoError, but they reach you at different times.

walk takes three shapes of revspec: a single tip like HEAD or v1.2.0, a range like v1.2.0..HEAD, and a merge base like main...topic. Try the range form:

    let mut walk = repo.walk("HEAD~2..HEAD")?;
daa476e docs: replace the notes with a changelog <ada@example.com>
fdd29c5 docs: add a licence and a second pass <ada@example.com>

Anything more exotic — HEAD^!, ^HEAD — comes back as RepoError::WalkFailed with an "unsupported revspec kind" message. Change it back to HEAD before the next step.

See what changed between two commits

Repo::diff compares the trees of two commits. Both sides have to resolve to a single commit, so you pass the two ends separately rather than a range.

    println!("\n-- what changed in the last commit --");
    let diff = repo.diff("HEAD~1", "HEAD").await?;
    let mut changes = diff.changes;
    changes.sort_by(|a, b| a.path.cmp(&b.path));
    for change in changes {
        println!("{:?} {}", change.kind, change.path.display());
    }
-- what changed in the last commit --
Added CHANGELOG.md
Deleted README.md

The sort isn't decoration. gix emits changes in its own order, and it isn't alphabetical or otherwise stable — if you want a deterministic listing, you sort it yourself.

This is file granularity only. ChangeKind tells you Added, Modified, Deleted or Renamed { from }, and that's the whole story: there are no hunks, no line counts and no patch text.

Attribute a file line by line

use std::path::Path;
    println!("\n-- who wrote LICENSE --");
    let blame = repo.blame(Path::new("LICENSE"), "HEAD").await?;
    for line in blame.lines {
        println!("{} {} {}", &line.commit_id[..7], line.author_name, line.content);
    }
-- who wrote LICENSE --
fdd29c5 Ada Lovelace MIT

The path must be repository-relative. Path::new("LICENSE") works; ../sample-repo/LICENSE and an absolute path do not — they don't match a tree entry and come back as RepoError::RevspecNotFound with a revspec field reading LICENSE at HEAD. There's no separate "file not found" variant, so a mistyped path and a bad revision look the same. Check the path first.

gix produces blame in hunks; rtb-forge flattens them to one entry per line and sorts by line number, matching git blame --porcelain. Author details are copied onto every line, so you don't need a second lookup to render a line.

Run the finished program

cargo run
opened ../sample-repo
status: 0 unstaged, 0 untracked

-- history --
daa476e docs: replace the notes with a changelog <ada@example.com>
fdd29c5 docs: add a licence and a second pass <ada@example.com>
4e22f2d docs: start the notes file <ada@example.com>

-- what changed in the last commit --
Added CHANGELOG.md
Deleted README.md

-- who wrote LICENSE --
fdd29c5 Ada Lovelace MIT

Everything you just ran was pure Rust. open, status, walk, diff and blame are all gix, which is why the program needs no git binary at runtime.

That stops being true the moment you write. commit, checkout, fetch, push and an authenticated clone all shell out to git, so a binary that uses them needs git installed wherever it runs. An anonymous clone stays on gix. It's worth knowing before you package this into a container — why the write paths shell out to git explains the split.

Clean up

The sample repository is a throwaway:

rm -rf ../sample-repo

Where to go next