Ruby Subversion Bindings. Finally Some Documentation.
UPDATE, 12/22/07: This page is obsolete. Please see my new post here.
So, I’m visiting my parents in Atlanta, and I finally have some free time. (You know how the parents are — they go to bed early.
). I’ve been wanting to get at this blog post for some time, and now I’ve finally found time to do it.
A while back, I found myself wanting to write something like a wiki in Ruby on Rails. Instead of reinventing the revision/diffing wheel needed to support a wiki, I wanted to use the Subversion Version Control system as a backend to my program. I searched far and wide for some documentation, but found nothing; all I found were sites asking for what I’m about to write. Since I couldn’t find any documentation, I looked to the unit tests. This turned out to be exactly what I needed.
From looking at the unit tests, and playing around with Subversion for some time, I found there are two states in which you can access a repository. One state is the client side, where you have a directory of files that should match up with the files in the repository. The other state is the server side; this is the side that receives commands from the client and executes them. Since I wanted to use Subversion as a backend to a program — and I didn’t want to maintain a separate list of files — I had to figure out how to send direct commands to the server. The following is a list of what I learned, and shows how to control a repository through the Ruby programming language.
Installing the SVN Bindings:
There are many sites that tell you how to install the bindings; I won’t be covering that here. This might help: http://collaboa.org/docs/svnbindings/install
(For Ubuntu users, just ‘apt-get install libsvn-ruby’)
Creating and opening a repository through code:
This is easier than printing “Hello World” in assembly. It’s that easy:
Svn::Repos.create(”/path/where/repsitory/should/be/created”)
If you think about the way Ruby works, then the above command shouldn’t be that complicated. Both Svn and Repos are modules; Repos is a child module of Svn. The create method is a method inside the Svn::Repos module.
Opening a repository is just as easy:
Svn::Repos.open(”/path/to/existing/repistory”)
Opening a repository is like opening a file for reading and writing.
Now, both the open and create commands return a repository object (this is not the official name) that you can interact with. To do anything useful, you should store the returned object into a variable, like this:
repos = Svn::Repos.create(”/path/where/repsitory/should/be/created”)
repos = Svn::Repos.open(”/path/to/existing/repository”)
We will use the repository object returned from the above two methods throughout the rest of this post.
Commiting a file:
When I first started writing my wiki-like program, the thing I was most interested in was commiting a file. This isn’t that easy to do, but it’s not hard.
The internals of Subversion are just guesswork to me, but generally, most transfers are done through transactions and streams. I’m not positive what a transaction is to the system (I know what the word means) but it seems to be that, whenever we open a new transaction, Subversion’s internal revision counter increases by one (this is important, as I’ll explain later). A stream, on the other hand, is much like a stream that you’d find in most programming languages; it lets us write to the repository as if we were writing to a file system.
Here’s how you’d commit a file using transactions and streams:
repos.fs.transaction do |txn|
checksum = MD5.new(data).hexdigest
stream = txn.root.apply_text(”/internal/path”, checksum)
stream.write(fileData)
stream.closeend
Note that it’s important we think of a Subversion repository as its own separate file system. All paths within the filesystem start with a forward slash (”/”) and are very similar (i.e., the same) as UNIX filesystem paths.
Getting contents of a file within the repository:
It’s great that we can commit files to the repository, but it doesn’t do us any good if we can’t get that data back out. Here’s how you’d do that:
stream = repos.fs.root(revision_id).file_contents(path)
stream.read
There are two things interesting about the above command: 1) We’re using streams, like before, but not within transactions, and 2) there’s a revision_id variable. Basically, the above command gives you the contents of a file for any revision, just as long as you tell it which revision you want. If you don’t specify a revision id, or you pass in the value of nil, the repository will give you the data corresponding to the most recent revision.
Note that the revision_id variable has some exactness to it, which may bite you in the butt later. If you pass in a revision id, you must pass in a revision id where the file you want was specifically edited. What does this mean? Say you have two files in a new repository, and each one was edited once. The first edit would push Subversion’s internal revision count to 1, and the second edit would push the count to two. If you use a revision_id of 2 to get the data within the first file, nothing will be returned because the first file was not edited on the second revision. If you want the data from the first file, you have to be specific; in this case, you’d use a revision_id of 1.
So how do you know which revision id’s correspond to a given file? Read on.
Figuring out the revisions of each file:
All we need to do is run this command:
repos.file_revs(path, 0, repos.fs.youngest_rev)
All this says is “Give us all the revisions between the initial revision (0) and the most recent revision (repos.fs.youngest_rev) corresponding to the file at path path.” This returns a lot of data.
What you get back is an array of arrays; each of the inner arrays represents one revision within the repository. Each revision has three parts: the path, the revision id, and the date the revision was made. The path is the same path we passed in (it seems redundant, but it’s actually quite handy), and the revision id and date are String representations of what their names imply. Since the revision is represented as an array, these values are at index 0, 1 and 2 respectively.
Note: The date value returned may not be what you expect. Read the next section to figure out why.
Getting the date of a revision (or, getting any property for that matter):
Whenever you commit something — or more importantly, whenever you create a new transaction — Subversion automatically stores the time at which the transaction occurred. It’s easy to get by calling this command:
date = repos.prop(Svn::Core::PROP_REVISION_DATE, revision_id)
The prop() function returns any stored property for the revision specified by revision_id; we’re using a Subversion-specific constant to ask for the date.
Note that the date returned is a String representation of the date; the prop() function only returns strings. However, we can turn it into a Ruby Time object by doing this:
Time.parse(date)
(For you advanced Ruby programmers, and all those who read the last section, the Subversion bindings actually override the Time.parse function to handle the output from the Subversion repository. Cool isn’t it?
)
Setting the author for each revision:
Sometimes it’s nice to tell the repository which author made which revision — like, say, when you’re committing files. Since a new revision is created each time we create a new transaction, we can put the following statement into the transaction code given above:
txn.set_prop(Svn::Core::PROP_REVISION_AUTHOR, author)
We again use a Subversion specific constant, but this time, we’re setting the data instead of getting it. The author variable holds the author’s name as a string.
Note that if we wanted to get the author back out later, we’d do the exact same thing we did to get the date, except we’d use the author constant instead:
author = repos.prop(Svn::Core::PROP_REVISION_AUTHOR, revision_id)
Getting the diffs from one revision to the next:
Sometimes, especially in a wiki, it’s handy to show the user the differences from one revision to the next. It’s easy to get the diffs from the repository, but it’s a little complicated to understand what comes back.
To get the diffs, do this:
info = Svn::Info.new(”/path/to/repository”, revision_id)
diffs = info.diffs
Subversion has a separate construct for getting specific revision information. This is the Info construct. We create a new Info construct to get the diffs for a certain revision. The thing that’s different about doing things this way is we totally bypass the repository; or, at least, we don’t use the repository variable we created before. Instead, we pass the repository path and a revision id into the Info constructor, and we get what we want back out.
Well, actually, I lied — we don’t get what we want back out. Instead, we get a lot more than what we wanted. The diffs variable above will contain a hash of all the diffs for the given revision, where the key represents a file path within the repository, and the value is another hash of data corresponding to that file. This second hash — the “value hash” — contains either an :added or a :modified key. These keys are Ruby symbols, and, depending on which one is present, the file was either added or modifed. The value associated with each of these keys is a Subversion DiffEntry object. We don’t care about this object, per se, but we do care about its body instance variable; this variable will give us a unified diff of a certain file showing the differences from one revision to the next.
Can it get any easier?
Now, I know some of the above functions may seem complicated or tedious, and that’s because they are — there’s no getting around that. However, I’ve written a wrapper class that does some of the tedium for us. Download it here: svn_repos.rb
EDIT, 4/2/07:Â The wrapper class linked above is old. A newer one can be found within the acts_as_subversioned plugin project, located on RubyForge.
Note to Ruby on Rails users:
My next post will be a howto describing how to use the wrapper class within a Ruby on Rails project. I’ll be giving a Rails plugin for download, which includes a test harness to test Subversion repositories like you test MySQL databases; that is, using fixtures! Believe me, it’s awesome. (Because I’m busy, I have no guarantee when I can get this out. However, I hope it to be soon.)
I’d love any comments you readers can give me on this. It feels great to finally post again. ![]()
When do I get to see it work?
[From Tim: Oh, I don't know. Soon...?
There's no more spring break, so we'll see if I have time.]
Tim, this looks very valuable and just what I’m looking for. I hope you find time to contribute your Rails plugin. In the meantime, I’ll mess around with your wrapper and see what I can get working. - Bill
[From Tim: Bill, I'm definitely finding the time. The Subversion plugin is almost there -- once all the functionality I need is done, I'll release it to the public to get some feedback. Check back in the next two weeks (hopefully!
).]
Is there a separate API for accessing a remote subversion respository? Svn::Repos.open() seems to barf when given a http://…/ URL
[From Tim: Hmmm... There has to be, but I'm not sure what it is. You might muck around in the unit tests or the source code for the Ruby Subversion Bindings -- maybe the Subversion RA stuff? (I've yet to figure out what RA means...). I'm not sure. At worst, you may have to figure out how to send WebDAV commands to a WebDav+Subversion enabled server. It looks like there might be some following on this: http://www.google.com/search?q=ruby+webdav
I'd love to hear what you find out.]
RA = Repository Access layer.
I’ve now managed to make something work, but I have little idea what I’ve actually done. I’ve ordered the Practical Subversion book which I gather documents the libsvn API, perhaps that will help.
# Cobbled together from
# http://www.meadowy.org/~gotoh/projects/remote-svn-plugin/wiki/SvnRaBinding
# http://svn.collab.net/repos/svn/trunk/subversion/bindings/swig/ruby/test/test_ra.rb
# and some guesswork
require ’svn/client’
require ’svn/ra’
url = ‘http://trac-hacks.swapoff.org/svn’
ctx = Svn::Client::Context.new
cb = Svn::Ra::Callbacks.new(ctx.auth_baton)
cb.auth_baton = Svn::Core.auth_open([])
cfg = Svn::Core::config_get_config(nil)
s = Svn::Ra::Session.open(url, cfg, cb)
st = s.stat(”, 1)
puts
That got garbled for some reason, the last line should be:
puts <<EOS
Status of node(#{url})
created revision = #{st.created_rev}
committed time = #{Time.at(st.time / 1_000_000)}
author = #{st.last_author}
size = #{st.size}
EOS
Thanks Brian!
Very usefull Brian, Anybody got any ideas about how I could pass a username and password in to that jumble?
It seems it needs to go through:
Svn::Core.auth_open([])
http://svn.collab.net/svn-doxygen/structsvn__auth__provider__object__t.html
[...] methods that can really help you figure out what you’re doing when you’re working with sparsely documented APIs or you just want a quick [...]
how can i reproduce ’svn –force add .” through ruby bindings for subversion?
it is supposed to be something like:
provider = Proc.new do |cred, realm, default, may_save, pool|
simplecreds = Svn::Ext::Core::Svn_auth_cred_simple_t.new
simplecreds.username = “username”
simplecreds.password = “password”
simplecreds
end
cb.auth_baton = Svn::Core.auth_open([Svn::Client::get_simple_prompt_provider(provider,2)])
But it doesn’t work to me
[...] turns out that this page — a previous blog post of mine — is my most visited page on this site. This isn’t [...]