Announcing git-prole

TL;DR: I’ve built git-prole, an ergonomic git-worktree manager. Check out the user manual to get started! If you don’t know what a Git worktree is or why you might want one, read on.

If you’re anything like me, you have a couple repositories cloned that are cumbersome enough to clone more than once:

$ ls -l
drwxr-xr-x     - rebeccat  15 Oct 11:13 mwb
drwxr-xr-x     - rebeccat  11 Oct 14:19 mwb2
drwxr-xr-x     - rebeccat  30 Sep 10:06 mwb3
drwxr-xr-x     - rebeccat  19 Oct 13:31 mwb4
drwxr-xr-x     - rebeccat   2 Aug 16:45 mwb5
drwxr-xr-x     - rebeccat   3 Oct 12:10 mwb6

This feels a little bit silly, but there’s some good reasons for it:

At the same time, there’s some costs to this approach:

Git worktrees, then, are a feature that allows you to associate multiple checkouts with a single repository, like this:

my-repo/
  main/        # A checkout for the main branch
    .git/      # The main .git directory.
    README.md
  feature1/    # A checkout for work on a feature
    .git       # A file containing `gitdir: /Path/to/my-repo/main/.git`
    README.md

And, as it turns out, with a little fiddling, you can also use a bare checkout for the .git directory; this means that none of the worktrees are “special” and they can all be deleted (if you try to set up a repository like this yourself, you will have problems; more on this later).

my-repo/
  .git/        # A bare repository
  main/        # A checkout for the main branch
    .git       # A file with the path to the bare repository
    README.md
  feature1/    # A checkout for work on a feature
    .git
    README.md

Features

git-prole includes a number of tools to make using multiple worktrees as ergonomic as possible:

Why hadn’t I heard of worktrees before?

This is a pretty cool feature, and once we have some tooling like git-prole to paper over the ergonomic challenges it makes a great workflow! So why haven’t you heard of worktrees before?

For reasons I can’t fully determine, worktrees have received basically zero fanfare except from your coworker who runs nix-darwin. The git-worktree command was first present in Git 2.5.0, released in 2015. However, the release notes for Git 2.5.0, which include dozens of updates like “Clarify in the Makefile a guideline to decide use of USE_NSEC”, have nothing to say about worktrees. GitHub actually published a blog post titled “Git 2.5, including multiple worktrees and triangular workflows”, which received, as far as I can tell, one Reddit post with 35 upvotes and two top-level comments, neither of which were excited for the new feature.

Recently, worktrees have started to garner a little more attention (see, for example, matklad’s “How I Use Git Worktrees”), but the second search result for “git worktrees” is a Stack Overflow question titled “What would I use git-worktree for?”

Trying and failing with worktrees

A couple weeks ago, I finally decided to take a couple hours and figure out how to use worktrees. Let’s create a worktree for a new branch, which I’ll call feature1:

$ git worktree add feature1
Preparing worktree (new branch 'feature1')
branch 'feature1' set up to track 'main'.
HEAD is now at b1b2888 Fix static binary builds in releases (#86)

Great, that was easy! Now, let’s just type git status, which I enter reflexively after nearly any Git command:

$ git status
On branch main
Your branch is up to date with 'origin/main'.

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        feature1/

nothing added to commit but untracked files present (use "git add" to track)

…huh. The first argument to git worktree add is used as a branch name, but it’s actually a path, and it will happily create a worktree nested in another worktree, despite (presumably) nobody ever wanting to do this.

The actual invocation you want is something like this, where Git uses the last component of the path as the branch name:

$ git worktree add ../feature1

And then we get to the next ergonomic issue with Git worktrees: what do you name them? If you’re not a worktree user already, you probably have your repository cloned in some “projects” directory, containing a whole bunch of repositories. So when you start working on feature1, you should really disambiguate it:

$ git worktree add ../my-repo-feature1

This is a bad experience! You have to type a path manually, prefix the path with the repository name, and then you have to type the repository name again when you cd to the checkout later. Can we do better? Of course!

A better layout for a worktree repository

If we look at the example workflow from the GitHub blog post above, we can see that they expect you to have the workflow layout I showed you at the start of the post:

$ cd ../main
$ rm -rf ../hotfix

GitHub must expect you to have a containing directory named my-repo and then worktrees directly inside it, named corresponding to the branches they have checked out. git-prole works well with this layout; git prole add hotfix will create a new worktree in the same directory as the main worktree.

Let’s set up one of our repositories like that. First, we’ll move our main working tree from /path/to/my-repo to /path/to/my-repo/main:

$ git worktree move . main
fatal: '.' is a main working tree

Oh. I guess we’ll do it the hard way: renaming my-repo to my-repo.bak, creating a new my-repo directory, and then moving my-repo.bak to my-repo/main. A three-step barrier to entry for worktrees, and that’s if you only have one to start with. If you’ve already created some worktrees in the parent directory, you’ll have to figure out how to reassociate them with the main worktree once you move everything. Theoretically this is possible with git worktree repair, but I found myself having to edit files in the .git directory manually.

git-prole will handle this conversion process for you, even with multiple worktrees, un-committed work, or any number of other complications:

$ git worktree list
/path/to/git-prole  719ed92 [main]
$ nix run github:9999years/git-prole -- convert

• Converting ~/git-prole to a worktree repository at ~/git-prole.
  I'll move the following worktrees to new locations:
  • ~/git-prole -> ~/git-prole/main
  Additionally, I'll convert the repository to a bare repository.

Preparing worktree (checking out 'main')
• ~/git-prole has been converted to a worktree checkout
• You may need to `cd .` to refresh your shell
$ cd .
$ git worktree list
/path/to/git-prole       (bare)
/path/to/git-prole/main  719ed92 [main]

Eventually, I got the repository set up the way I wanted, but there was still one thing that bothered me: the .git directory. If you create a worktree layout by moving your existing worktrees around, you end up with one special “main” worktree, which contains the full .git directory shared by the other worktrees and cannot be renamed or removed.

Having a main worktree is fine, but it’s smelly. I don’t like that all of the worktrees are ephemeral and short-lived except for one. The solution to this is to have the main worktree be a bare repository: a .git directory with no accompanying working tree. A repository is bare if its configuration sets the core.bare option to true:

$ git status
On branch main
nothing to commit, working tree clean
$ git config set --local core.bare true
$ git status
fatal: this operation must be run in a work tree

So we create the my-repo directory and do a bare clone into it:

$ git clone --bare git@github.com:9999years/git-prole.git
Cloning into bare repository 'git-prole.git'...

Great! Now let’s set up a working tree:

$ cd git-prole.git/
$ git worktree add ../main
Preparing worktree (checking out 'main')
HEAD is now at 719ed92 Fix links to `git-worktree(1)` docs (#89)
$ cd ../main/
$ git pull
There is no tracking information for the current branch.
Please specify which branch you want to rebase against.
See git-pull(1) for details.

    git pull <remote> <branch>

If you wish to set tracking information for this branch you can do so with:

    git branch --set-upstream-to=origin/<branch> master

$ git branch --set-upstream-to=origin/master master
fatal: the requested upstream branch 'origin/master' does not exist
hint:
hint: If you are planning on basing your work on an upstream
hint: branch that already exists at the remote, you may need to
hint: run "git fetch" to retrieve it.
hint:
hint: If you are planning to push out a new local branch that
hint: will track its remote counterpart, you may want to use
hint: "git push -u" to set the upstream config as you push.
hint: Disable this message with "git config advice.setUpstreamFailure false"

…huh. Apparently, as someone pointed out on Stack Overflow to a user with the same problem, git clone --bare doesn’t just skip creating a working directory. According to the man page:

Also the branch heads at the remote are copied directly to corresponding local branch heads, without mapping them to refs/remotes/origin/.

This means that instead of (e.g.) creating a remote-tracking branch origin/main, Git creates a local branch main. Then, when you go to push or pull, Git errors out when it can’t find a matching remote branch. You can sort of fix this by setting a refspec for your remote (e.g., git config set --local remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*", good luck remembering that the next time you need it), but then you have to rename all of the refs manually, transforming refs/heads/foo to refs/remotes/origin/foo, which is a pain to say the least!

Converting an existing repository to a bare one is similarly painful, although for different reasons (it requires a song and dance to reassociate the main worktree with the repository). Eventually, I realized that the only way to fix these issues would be with a new tool to make setting up and working with multiple worktrees easy.

Easier worktrees with git-prole

For a breath of fresh air, let’s clone a repository with git-prole, taking advantage of the (configuration-gated) gh support to let us enter a GitHub repository slug instead of a full URL:

$ git prole clone 9999years/git-prole
Cloning into '/Users/wiggles/test-repo/git-prole'...
remote: Enumerating objects: 668, done.
remote: Counting objects: 100% (502/502), done.
remote: Compressing objects: 100% (215/215), done.
remote: Total 668 (delta 331), reused 420 (delta 286), pack-reused 166 (from 1)
Receiving objects: 100% (668/668), 277.44 KiB | 1.66 MiB/s, done.
Resolving deltas: 100% (407/407), done.

• • Move git-prole/.git to $TMPDIR/.tmpwdCaYS/.git
  • In $TMPDIR/.tmpwdCaYS/.git, set core.bare=true
  • Move git-prole to $TMPDIR/.tmpwdCaYS/main
  • Create directory git-prole
  • Move $TMPDIR/.tmpwdCaYS/.git to git-prole/.git
  • In git-prole/.git, create but don't check out a worktree for main at git-prole/main
  • In git-prole/main, reset the index state
  • Move git-prole/main/.git to $TMPDIR/.tmpwdCaYS/main/.git
  • Remove git-prole/main
  • Move $TMPDIR/.tmpwdCaYS/main to git-prole/main

Preparing worktree (checking out 'main')
• git-prole has been converted to a worktree checkout

$ cd git-prole
$ git worktree list
/path/to/git-prole       (bare)
/path/to/git-prole/main  719ed92 [main]

Nice! Let’s add a new worktree:

$ git prole add add feature1
• Creating worktree in ~/git-prole/feature1 for feature1 tracking origin/main
Preparing worktree (new branch 'feature1')
branch 'feature1' set up to track 'origin/main'.
HEAD is now at 719ed92 Fix links to `git-worktree(1)` docs (#89)

New branches automatically start at the repository’s default branch, instead of the current commit.

That’s the core of git-prole’s functionality, along with some goodies like copying untracked files to new worktrees to keep your build caches warm and your local configuration intact.

The workflow enabled by cheap checkouts is super nice and I encourage you to give it a try! To get started with git-prole, check out the user manual for installation instructions.