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.
giton yourPATH— only to create the sample repository in step 2. Everything the program itself does runs ongix, 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¶
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:
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:
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:
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());
}
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¶
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);
}
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¶
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:
Where to go next¶
- Authenticate clone, fetch and push — the next thing most tools need.
Repooperations — every method, every option, and which ones need thegitbinary.RepoErrorvariants — what each failure means and which method produces it.- What rtb-forge does not do — worth reading before you plan around a capability.