A hack/ship git workflow with shared remote branches

By Chris Williams

This post describes how we’ve come to develop some git scripts and shortcuts to help manage our development process at Hotel Tonight. All the aliases/functions I use in the examples below are documented there.

Some History

When we first started working on Hotel Tonight, we adopted the hack & ship workflow to facilitate performing all work on feature-specific branches. As long as we had fewer developers than software projects, that worked great, because it was very unusual for more than one person to work on a feature before it was ready to ship to master.

Somewhat similar to GitHub’s workflow, we don’t have formal releases, and like to keep master always in a deployable state. So that means that when we want to collaborate on a new feature, we do it in a remote branch. And that’s where things become tricky.

The dark side of rebasing

The original hack script pulls in other people’s changes from master, by rebasing your branch onto master each time. This presents two problems if you’re working on a shared remote branch with others – first, it’s not what you’re most interested in. That is, while you’d like to keep up with changes on master, what you really want is to keep up with other people’s changes on your branch.

Second, and this is what really bit us, if you rebase commits you’ve already shared with others onto master, you’re in effect rewriting history, and chaos ensues. Witness:

I create a new branch and start working on some feature–

[chris example (master)]$ gcb disco_ball
 Switched to a new branch 'disco_ball'

[chris example (disco_ball)]$ vi ball.rb

[chris example (disco_ball %)]$ gac
[disco_ball 73c2c53] Some initial thoughts on implementing the ball
 1 files changed, 1 insertions(+), 0 deletions(-)
 create mode 100644 ball.rb

[chris example (disco_ball)]$ git push origin disco_ball
 To /home/chris/example      * [new branch]      disco_ball -> disco_ball

Now my coworker checks it out, enters some of his ideas–

[patrick example (master)]$ git checkout -t origin/disco_ball
 Branch disco_ball set up to track remote branch disco_ball from origin.
 Switched to a new branch 'disco_ball'

[patrick example (disco_ball)]$ vi ball.rb

[patrick example (disco_ball *)]$ gac
 [disco_ball 819d41c] Some alternate disco-ball strategies
 1 files changed, 1 insertions(+), 1 deletions(-)

Meanwhile, say there have been some changes on master, and I want to pull those in, and then do some more work on the ball, then share that. Our old hack would do a rebase onto master..

[chris example (disco_ball)]$ git rebase master
 First, rewinding head to replay your work on top of it...
 Applying: Some initial thoughts on implementing the ball

[chris example (disco_ball)]$ vi ball.rb

[chris example (disco_ball *)]$ gac
 [disco_ball a76373e] Lighting-related work
 1 files changed, 1 insertions(+), 0 deletions(-)

[chris example (disco_ball)]$ git push
 To /home/chris/example
   0ae267e..3fc53df  master -> master
 ! [rejected]        disco_ball -> disco_ball (non-fast-forward)
 error: failed to push some refs to '/home/chris/example'
 To prevent you from losing history, non-fast-forward updates were rejected
 Merge the remote changes before pushing again.  See the 'Note about
 fast-forwards' section of 'git push --help' for details.

And here’s where things break down. My history doesn’t match up with the remote history anymore (3fc53df is from master):

[chris example (disco_ball)]$ gl
 a76373e Lighting-related work
 06bd2d1 Some initial thoughts on implementing the ball
 3fc53df An important component to keep the people upstairs away from us
 0ae267e Create example repo

[chris example (disco_ball)]$ gl origin/disco_ball
 73c2c53 Some initial thoughts on implementing the ball
 0ae267e Create example repo

Git suggests merging the remote changes, but that isn’t pretty–

[chris example (disco_ball)]$ git pull origin disco_ball
 From /home/chris/example
  * branch            disco_ball -> FETCH_HEAD
 Auto-merging ball.rb
 CONFLICT (add/add): Merge conflict in ball.rb
 Automatic merge failed; fix conflicts and then commit the result.

[chris example (disco_ball *+|MERGING)]$ cat ball.rb
# There should be a whole ton of little mirrors on it!
<<<<<< HEAD
# Also, important that we shine light at it.
=======
>>>>>> 73c2c5395095d5f0502db8d397d80f3aa029f1ec

Alternately, we could force push, but then look what happens when Patrick tries to update:

[patrick example (disco_ball)]$ git pull
 From /home/chris/example
  + 73c2c53...a76373e disco_ball -> origin/disco_ball  (forced update)
 Auto-merging ball.rb
 CONFLICT (add/add): Merge conflict in ball.rb
 Automatic merge failed; fix conflicts and then commit the result.

[patrick example (disco_ball *+|MERGING)]$ cat ball.rb
<<<<<< HEAD
# What if it's just one big shiny sphere?
=======
# There should be a whole ton of little mirrors on it!
# Also, important that we shine light at it.
>>>>>> a76373ed377efdc65849e887e8694eeca0e23730

What a mess! So, clearly, this doesn’t work. And, if you start squashing stuff, which we like to do sometimes, so that we don’t have to spam github with all our intermediate work, the history-rewriting merge-conflict-creating situation becomes even uglier.

A kinder, gentler way to share

In order to control this potential for chaos, we needed a better strategy for how branches are shared. This is the procedure I came up with:

  1. When you ‘hack’ on a shared branch, you rebase your local changes onto the remote branch, not onto master. New commits from master are not pulled in during hack.
  2. When you ‘share’ your changes to an existing shared branch, first do an interactive rebase onto the remote, so you can squash, then merge in anything new from master, right before pushing. This is the only safe time to merge in from master, because you don’t want to squash changes from master in amongst your local changes.
  3. When you ‘ship’ to master, do an interactive rebase onto master, to do a big squash of all the branch work – and at that point, the remote branch is considered deprecated and should be removed, since its history no longer matches.

Here’s what that process looks like, starting over with a new disco ball branch:

[chris example (master)]$ gcb disco_ball
 Switched to a new branch 'disco_ball'

[chris example (disco_ball)]$ vi ball.rb

[chris example (disco_ball %)]$ gac
 [disco_ball 77f1fa3] Some initial thoughts on implementing the ball
 1 files changed, 1 insertions(+), 0 deletions(-)
 create mode 100644 ball.rb

[chris example (disco_ball)]$ share
 Successfully rebased and updated refs/heads/disco_ball.
 To /home/chris/example
  * [new branch]      disco_ball -> disco_ball

OK, so ‘share’ at this stage basically just created a new remote branch for us. Now Patrick wants to check that out; we’ll give him a shortcut to see what’s available and ‘borrow’ it:

[patrick example (master)]$ borrow
 Already on 'master'
 Already up-to-date.
   disco_ball
   master

[patrick example (master)]$ borrow disco_ball
 Branch disco_ball set up to track remote branch disco_ball from origin.
 Switched to a new branch 'disco_ball'

Now let’s say Patrick makes a few changes, and wants to share them with me.

[patrick example (disco_ball)]$ vi ball.rb

[patrick example (disco_ball *)]$ gac
 [disco_ball 840074c] Another thought on the disco ball
  1 files changed, 1 insertions(+), 0 deletions(-)

[patrick example (disco_ball)]$ vi ball.rb

[patrick example (disco_ball *)]$ gac
 [disco_ball bb06e68] No, wait, that was stupid, this instead.
  1 files changed, 1 insertions(+), 1 deletions(-)

[patrick example (disco_ball)]$ share -notest

At that point he’ll have a chance to squash:

pick 021e621 Another thought on the disco ball
pick f3b8218 No, wait, that was stupid, this instead.

# Rebase 77f1fa3..f3b8218 onto 77f1fa3
...

The initial commit on the branch, which already exists in the remote, isn’t listed here; that wouldn’t be safe to squash.

When the rebase is done the share will merge in the latest from master, and push to the remote. Note the ‘-notest’ parameter; normally ‘share’ would notice that there was new stuff from master merged in, and run tests before pushing to make sure they hadn’t broken anything – since there aren’t any tests set up for this example repo, we’ll just skip that. In practice, you’ll likely need to modify the ‘runtests’ script to actually run your project’s tests correctly.

When I next ‘hack’, I’ll get both Patrick’s changes, and the latest from master that was merged in when he shared:

[chris example (disco_ball)]$ hack
 From /home/chris/example
  * branch            disco_ball -&gt; FETCH_HEAD
 Updating 77f1fa3..0bf7d4c     Fast-forward
  ball.rb    |    1 +
  ceiling.rb |    1 +
 2 files changed, 2 insertions(+), 0 deletions(-)
  create mode 100644 ceiling.rb
 Current branch disco_ball is up to date.

Ship It

So now if I’m ready to release the feature to master, I can just ‘ship’ normally. I’ll get a chance to squash everything on the branch:

pick 77f1fa3 Some initial thoughts on implementing the ball
pick dd47d48 Another thought on the disco ball

# Rebase a6ed99a..0bf7d4c onto a6ed99a
...

And then it’s merged into master, and I’m warned to get rid of the remote. The ‘gbdone’ script will remove the shipped local and remote branches.

[chris example (disco_ball)]$ ship
 From /home/chris/example
  * branch            disco_ball -&gt; FETCH_HEAD
 Already up-to-date.
 Current branch disco_ball is up to date.
 [detached HEAD 00db9d0] A basic disco ball
  1 files changed, 2 insertions(+), 0 deletions(-)
  create mode 100644 ball.rb
 Successfully rebased and updated refs/heads/disco_ball.
 Switched to branch 'master'
 Updating a6ed99a..00db9d0     Fast-forward
  ball.rb |    2 ++
  1 files changed, 2 insertions(+), 0 deletions(-)
  create mode 100644 ball.rb
 To /home/chris/example
    a6ed99a..00db9d0  master -> master
 Switched to branch 'disco_ball'
 IMPORTANT: Please remember that your local branch has a conflicting
 history with its remote now, due to rebase onto master. Don't let anyone
 keep working on the remote; it needs to be blown away via either outright
 removal or forced push.

[chris example (disco_ball)]$ gbdone
 Switched to branch 'master'
 Deleted branch disco_ball (was 00db9d0).
 To /home/chris/example
  - [deleted]         disco_ball

A slightly more cautious way to ship

Alternately, you may want to do a code review and/or some extra testing, after rebasing onto master and squashing your changes, but before shipping. So we added a convenience method to ‘pack’ your code into another branch before release. Then you can ‘share’ that for code review, or just play with it locally, and ship it normally when you’re done.

[patrick example (disco_ball)]$ pack
 Switched to a new branch 'disco_ball_release'
 [detached HEAD f77edf3] A basic disco ball
  1 files changed, 2 insertions(+), 0 deletions(-)
  create mode 100644 ball.rb
 Successfully rebased and updated refs/heads/disco_ball_release.

[patrick example (disco_ball_release)]$ gl
 f77edf3 A basic disco ball
 a6ed99a An important component to keep the people upstairs away from us
 0ae267e Create example repo

In summary

I guess that all sounds a bit complicated. But once you get into the routine, it’s pretty frictionless. When you’re not sharing a branch, hack and ship still work the same as before. When you are, all you need to do most of the time is ‘share’ periodically to keep everybody in sync, or ‘hack’ occasionally if you want to pull in a coworker’s changes before yours are ready to share.

There are still a few edge cases, mostly around what happens when there are legit conflicts – some of those the scripts will detect and help you out with, others we might still need to work on. Again, all of it’s available here:

http://github.com/hoteltonight/git-scripts

Let us know what you think!

Written by Chris Williams

Read more posts by Chris, and follow Chris on Twitter.

Interested in building something great?

Join us in building the worlds most loved hotel app.
View our open engineering positions.