Implementing revisions and workflows for text and written documents in a normal database kind of sucks. Git does it so well, why not use it for things we write in human language too? Unfortunately teaching most writers to work on the command line seems to be a non-starter, so let's build higher-level interfaces for them.
CapitalGit provides a database adapter like interface into a remote git repository. It's a higher-level abstraction wrapping the Rugged gem which itself wraps the libgit2 library.
Building a CMS shaped tool for a small number of users? Try plugging CapitalGit in instead of MongoDB or MySQL.
Or run the included Sinatra app in server mode to read and write from repositories over a REST interface. Just provide a configuration file to indicate which remote repositories can be edited.
CapitalGit is installed much like any database. If you're using bundler, add this to your Gemfile
gem 'capital_git', git: '[email protected]:newsdev/capital_git.git', branch: 'master'
In Rails and Sinatra apps, configuration can be specified in a config/capitalgit.yml file. Look at the config/repos.yml.sample file here for guidance on syntax. Somewhere during app initialization (like in a config/initializers/capital_git.rb file) you'll then need to include this.
CapitalGit.load!("config/capitalgit.yml")
Now you can interact with repositories like so:
# clones/pulls a local copy of [email protected]:repo-slug.git
@repo = CapitalGit.connect("[email protected]:newsdev/capital_git_testrepo.git")
# list files
@repo.list
# show commit log
@repo.log
# read a specific file
item = @repo.read("path/to/file.txt")
# string with file contents
item[:value]
# update a file, commit, and push
@repo.write("path/to/file.txt", "new contents\nare here\n",
:message => "Write and commit"
)
# each of these methods can take a :branch option to operate on a non-default branch
@repo.list(branch: "some-branch")
# can also see what's changed
@repo.show # equivalent to git show
@repo.show(:branch => "master")
@repo.show(:commit => "9db5b61dd6761c647cb537c7fe2fd8339d80219f")
# returns some commit information
# along with a :changes attribute with arrays of :added, :deleted, :renamed, :modified, etc objects
# simple listing of all paths that changed at HEAD
@repo.show[:diff][:changes].values.flatten.map {|o| o[:new_path]}
# text diffs of each modified (but not added/deleted) file in the latest commit on master
@repo.show(:branch => "master")[:diff][:changes][:modified].map {|o| o[:patch]}
# diff between two commits
@repo.diff(COMMIT_SHA_1, COMMIT_SHA_2)
{
:commits => [COMMIT_SHA_1, COMMIT_SHA_2],
:files_changed => diff.stat[0],
:additions => diff.stat[1],
:deletions => diff.stat[2],
:changes => changes # changes hash
}
# listing of branches
@repo.branches
# listing of branches, with difference from a specified base branch
@repo.branches("master")
# each of these methods that read the repository will pull from the remote
# to reduce the number of pulls to call multiple in succession use sync
# to ensure everything in the block will see the same repository state
@repo.sync do |repo|
{
files: repo.list,
commits: repo.log
}
end
If you want to manually configure databases and skip the config file:
# set up connection information
@db = CapitalGit::Database.new
# optional configuration
@db.credentials = {
:username => "git",
:publickey => '...',
:privatekey => '...',
:passphrase => "a passphrase goes here"
}
@db.committer = {
:email => "[email protected]",
:name => "Me at Work"
}
Right now, only local repositories and repositories cloned over ssh with keys specified as a file. Support for HTTP(S) basic auth and SSH agent key support are planned. (Or you could implement it!)
Another caveat, CapitalGit can't initialize new repositories.
Installing the gem provides a capital_git binary that can be run as a server by specifying the path to a configuration file.
capital_git /path/to/repos.yml
You can also work on a branch to test out your changes, then merge it back into master.
Create a branch, (with a uniquely generated name (how?)) and return it
branch_name = repo.create_branch
Write to the new branch
repo.write_many(files, {:branch => branch_name})
Return an array of matching branch names — ones that have any unmerged commits by [email protected]
repo.find_branches(author_email: "[email protected]")
Read back files from a branch
files = repo.read_all(branch: branch_name)
Preview what changes a merge would do. This is equivalent to repo.diff between the merge_base of HEAD and branch and the branch
repo.merge_preview(branch_name)
Merge a branch back into HEAD.
The merge result can either be a:
- commit (a successful automerge)
- maybe with some flags showing
- index, with conflicts (an unsuccessful merge)
merge_resultcan then be returned to client for a new commit set to be resolved
merge_result = repo.merge_branch(branch_name)
merge_result
If a merge has conflicts, they can be resolved manually, and new objects written back directly to resolve the conflicts. The commit ids of the two parent commits are passed back to create the merge commit.
If commit_one and commit_two are both at the tip of their respective branches/HEAD this will create a new commit and set both as parents. Otherwise, it will fail again.
repo.write_merge_branch(files, {:parents => [commit_one, commit_two]})
By default, branching and merging works from whatever you defined as the :default_branch. If you want to branch from or merge into a different branch, pass head: "other-branch" as an option to any of the merge methods.
The server can be run as a binary, or with anything else that takes a config.ru file. In that mode it defaults to looking for configuration at config/repos.yml. Look in config/repos.yml.sample for an example.
The API looks like this.
-
GET /:repo-slugList contents of a git repo or subfolder of that repo. Response in JSON format. -
GET /:repo-slug/path/to/fileReturn the contents of a file in that git repo wrapped in JSON. -
PUT /:repo-slug/path/to/fileUpdate and commit changes to a file. Expects the following POST parameters:valueThe new contents of the file.commit_user_emailUser email for the commitcommit_user_nameUser name for the commitcommit_messageCommit message
Puts are immediately pushed to the remote server.
Set everything up:
bundle install
Run the server:
shotgun -p 4567 config.ru
Or load things from the command line with ./bin/console
# set up where the local working copy will be
tmp_path = Dir.mktmpdir("capital-git-test-repos")
database = CapitalGit::Database.new({:local_path => tmp_path})
# set which ssh keys to use
database.credentials = {
:username => "git",
:publickey => "../test/fixtures/keys/testcapitalgit.pub",
:privatekey => "../test/fixtures/keys/testcapitalgit",
:passphrase => "capital_git passphrase"
}
# clone and pull
repo = database.connect("[email protected]:newsdev/capital_git_testrepo.git")
repo.list
#=> [ ...stuff... ]
# clean up
FileUtils.remove_entry_secure(tmp_path)
Have at it!
The test suite is written with Minitest. To run, simply:
rake
Please try and maintain test coverage for new features. They're in the test directory and hopefully should be fairly easy to follow along with.
To build the gem:
gem build capital_git.gemspec
There are potential issues with installing to be able to handle ssh keys and cloning remotely. On OS X you need both cmake and libssh2 (of version at least 1.4.3) installed, and then the gem must be compiled with libssh2 available for dynamic linking.
Hopefully this will work.
brew install cmake libssh2
If not, get in touch.
This is pre-release software. Bugs abound. API unstable. Versioning erratic. You probably shouldn't be using it yet and definitely don't expose the web app to the internet.
MIT License. Copyright (c) 2014 The New York Times Company. See LICENSE for details.