24 days of Rust - git2
Most of software developers are to some extent familar with Git. This version control system won our hearts even though it is not perfect. Practicality beats purity, as quoted from The Zen of Python.
But Git is not only the git
executable. It's the entire ecosystem around
Git that made it so successful - IDE integrations, hosting services and
third-party tools and libraries. One of these is a very popular C library called
libgit2
which implements Git APIs (although
git
itself doesn't use libgit2
and
probably never will).
There are bindings to libgit2
in many languages, but we're interested
primarily in Rust, right? The git2
crate
provides safe Rust bindings.
Open a repository
Just like most of our work with Git happens in a repository, our interaction
with git2
starts with a Repository
struct. This type has a few associated
functions (aka static methods) to create a value of the type. Among those
are clone()
or init()
which correspond to git
subcommands,
but we're going to use open()
in the example.
extern crate git2; use git2::Repository; fn main() { let repo_root = std::env::args().nth(1).unwrap_or(".".to_string()); let repo = Repository::open(repo_root.as_str()).expect("Couldn't open repository"); println!("{} state={:?}", repo.path().display(), repo.state()); }
open()
assumes an already existing repository. Once we have a Repository
object, we can inspect its location and state using path()
and state()
methods.
$ cargo run -- ../repo /home/zbyszek/Development/Rust/repo/.git/ state=Clean
Display latest commit
After we have opened a repository, we can explore its history. We'll start
with writing two helper functions. One will fetch the latest commit (usually
HEAD
) and the other will display any commit in a format known from git log
.
use git2::{Commit, ObjectType, Repository}; fn find_last_commit(repo: &Repository) -> Result<Commit, git2::Error> { let obj = repo.head()?.resolve()?.peel(ObjectType::Commit)?; obj.into_commit().map_err(|_| git2::Error::from_str("Couldn't find commit")) } fn display_commit(commit: &Commit) { let timestamp = commit.time().seconds(); let tm = time::at(time::Timespec::new(timestamp, 0)); println!("commit {}\nAuthor: {}\nDate: {}\n\n {}", commit.id(), commit.author(), tm.rfc822(), commit.message().unwrap_or("no commit message")); } let commit = find_last_commit(&repo).expect("Couldn't find last commit"); display_commit(&commit);
Putting these two functions together should print out some information about the most recent commit. Is that so?
$ cargo run -- ../repo commit 8ad23c6f8888a0f90711cdf2d0da91b64c2a9333 Author: Zbigniew Siciarz <zbigniew@siciarz.net> Date: Fri Dec 16 18:44:16 2016 +0100 Initial commit $ cd ../repo && git log commit 8ad23c6f8888a0f90711cdf2d0da91b64c2a9333 Author: Zbigniew Siciarz <zbigniew@siciarz.net> Date: Fri Dec 16 18:44:16 2016 +0100 Initial commit
Hey, it works!
Add and commit a file
But we're not confined to a read-only view of the repository. We can use
git2
APIs to add or remove files, stage changes and commit them as well.
Here's a helper function that adds and commits a single file (signing off
the commit as yours truly).
use git2::{Oid, Signature}; use std::path::Path; fn add_and_commit(repo: &Repository, path: &Path, message: &str) -> Result<Oid, git2::Error> { let mut index = repo.index()?; index.add_path(path)?; let oid = index.write_tree()?; let signature = Signature::now("Zbigniew Siciarz", "zbigniew@siciarz.net")?; let parent_commit = find_last_commit(&repo)?; let tree = repo.find_tree(oid)?; repo.commit(Some("HEAD"), // point HEAD to our new commit &signature, // author &signature, // committer message, // commit message &tree, // tree &[&parent_commit]) // parents }
The standard practice in Git is to stage changes before committing. The area containing staged changes is known in the Git lingo as the index.
Feel free to consult the Git glossary for explanation of a few other terms that appear here.
We can access the index from a Repository
by calling the index()
method.
Next, we add our file (this must be a path relative to repo root) and
write staged changes to a tree object. This object has its own ID that's
used later to find the actual tree. Before really committing, we need to have
a few pieces of information at hand:
- should we update a ref (such as
HEAD
) to point to the new commit - author's and committer's signature (it may be the same person)
- commit message (look here for inspiration)
- the tree object that will be committed
- and a reference to a parent commit (or more parents if this is a merge)
Whew! But thankfully that is enough. The commit()
method returns an
object ID (if successful), which in this context is the hash of our new
commit.
use std::fs::File; use std::io::Write; let relative_path = Path::new("example.txt"); { let file_path = Path::new(repo_root.as_str()).join(relative_path); let mut file = File::create(file_path.clone()).expect("Couldn't create file"); file.write_all(b"Hello git2").unwrap(); } let commit_id = add_and_commit(&repo, &relative_path, "Add example text file") .expect("Couldn't add file to repo"); println!("New commit: {}", commit_id);
We're creating a text file on the fly, making sure it's properly closed after writing (hence the nested scope). So what happens if we try to commit this new file?
$ cargo run -- ../repo new commit: fae1be0d7582c5859820f16980b484e1a138728f $ cd ../repo && git log commit fae1be0d7582c5859820f16980b484e1a138728f Author: Zbigniew Siciarz <zbigniew@siciarz.net> Date: Fri Dec 16 19:06:36 2016 +0100 Add example text file
Fantastic! A new commit appeared in the repository.
Push to remote repository
There's one more thing left to do. What good is a commit if we cannot share it with the Open Source world? Let's push our changes to a remote repository!
use git2::Direction; fn push(repo: &Repository, url: &str) -> Result<(), git2::Error> { let mut remote = match repo.find_remote("origin") { Ok(r) => r, Err(_) => repo.remote("origin", url)?, }; remote.connect(Direction::Push)?; remote.push(&["refs/heads/master:refs/heads/master"], None) }
This is actually the simplest helper function of all we wrote today. We check
if a remote named origin
is already configured in the repository and
add it with remote()
if it isn't. Next we establish connection to the
remote and finally push our local master branch to a remote ref.
use std::fs::canonicalize; let remote_url = format!("file://{}", canonicalize("../git_remote").unwrap().display()); println!("Pushing to: {}", remote_url); let _ = push(&repo, remote_url.as_str()).expect("Couldn't push to remote repo");
If we run this and check git log
in the git_remote
directory, we'll find
the freshly created commit.
Note: I decided to use a local directory for the remote to avoid dealing with
authentication, such as setting up an SSH connection. This is of course possible
in git2
using the
remote_callbacks()
method. But then the push()
function would get too complex and I wanted
something short and sweet for the grande finale.
To sum it all up: we started with opening an existing repository and checking its state. Later on we learned how to read and display the latest commit. That was important when we actually committed a file, because the new commit needed a reference to its parent. Finally we configured a remote repository and pushed all commits there. Wasn't that hard, was it? :-)
For comparison, these are the equivalent git
commands:
$ git status $ git show HEAD $ echo "Hello git2" > example.txt $ git add example.txt $ git commit -m "Add example text file" $ git remote add origin ../git_remote $ git push origin master
Further reading
- libgit2
- libgit2 "log" example - in C
- git2 "log" example - in Rust
- Git from the Bottom Up - a deep dive into the internals of Git
- Move Fast and Fix Things
Photo by Rob Oo and shared under the Creative Commons Attribution 2.0 Generic License. See https://www.flickr.com/photos/105105658@N03/15307825114/