Git-worktree workflow
April 20, 2024For as long as I can remember, I've tried to find a workflow that fits in my brain. I have usually settled with a massive ~/code
directory, as we do, and try to manually manage the directories for whatever I'm working on. Often times I'd have multiple related projects cloned that would have to be merged together. Over time, this ends up being a massive, unmaintainable mess where I have to periodically spend time investigating old project directories and try to remember if anything in there was important before I blew it away to prune down the sheer amount of projects that have built up.
git-worktrees
Before we begin, lets create a test repository with a single branch:
cd ~/code
mkdir test
cd test
git init
touch some-file
git add some-file
git commit -m "Yay a commit!"
Now you should have simple git repository with a single commit. And, of course, in that directory is the .git/
directory and the single file you created. Nothing special.
ls -a
.git some-file
A git repository without a branch checked out (i.e. just the .git/
directory) is called a bare repository. You can create a new one or clone one from another repository with the --bare
option on git.
cd ~/code
git clone --bare test/ test-bare.git
ls test-bare.git
branches config description HEAD hooks info objects packed-refs refs
Notice that now the contents of your directory is the usual contents of a .git/
directory. There's no room for source code here. This is often what you use when you host your own git repository on a server.
Now can use the git worktree add
command to create a worktree in a different place from this bare repository.
cd test-bare.git
git worktree add ../test-main
Preparing worktree (new branch 'test-main') HEAD is now at d2ba6fe Yay a commit!
git worktree list
/home/purplg/code/test-bare.git (bare) /home/purplg/code/test-main d2ba6fe [test-main]
Now you can see there is an additional worktree with the branch test-main
checked out. If you list the contents in it, you'll notice it has the some-file
from before!
ls ~/code/test-main
some-file
Okay, let's make another worktree, but with a new branch called new-feature
this time.
git worktree add ../new-feature -b new-feature
Preparing worktree (new branch 'new-feature') HEAD is now at d2ba6fe Yay a commit!
As you'd expect, it shows up in the worktree list and it starts from the default branch just like creating a new branch normally behaves.
git worktree list # List the worktrees associated with this repository
/home/purplg/code/test.git (bare) /home/purplg/code/new-feature d2ba6fe [new-feature] /home/purplg/code/test-main d2ba6fe [test-main]
Now let's make some changes to the new-feature
branch.
cd ../new-feature
echo "Super technical changes" > new-file
rm some-file
But, oops! You actually wanted all those changes you just made to be on the test-main
branch. Now here's where worktrees are actually useful (we finally got there). You can move these changes to the other directory using git stash
.
git add .
git stash
cd ../test-main # Switch to other repo
git stash pop
On branch test-main Changes to be committed: (use "git restore --staged <file>..." to unstage) new file: new-file Changes not staged for commit: (use "git add/rm <file>..." to update what will be committed) (use "git restore <file>..." to discard changes in working directory) deleted: some-file Dropped refs/stash@{0} (ab20143e220ab296e570e007f1ce731f4b7b6d39)
Cool! You just stashed some changes and popped them into a different project!
Another benefit of using worktrees and a persistent bare repository is that you keep you git database in-tact. This means you can safely remove a worktree and your stashes, reflog, etc are always there. If you forgot something, you can just create a new worktree, open an existing one, or go to the bare directly, and access that data.
The automation
Okay, that's cool and all, but it seems like a lot of work to do by hand – is what I thought while I was practicing this. So, in the Emacs-user fashion, I wrote a package for it.
First of all, you need a directory for all your projects. I use ~/workspaces
and I'll call this the workspace directory.
In the workspace directory, I create a hidden .bare/
directory which I'll call the bare directory. The bare directory is where all the bare repositories (that we conventionally suffix with .git
) that we can create worktrees from. It's hidden since I'm using automation to create worktrees and I'd rather not see it normally.
Also in the workspace directory is where all the workspace's go. I consider a workspace to be a collection of related worktrees (or "projects").
Here's a modified version of the potential structure taken from the README of treebundel.
~/workspaces/ (workspace directory) L .bare/ (bare directory) | L workspace1/ | L project-one (branch: "feature/workspace1") | L project-two (branch: "feature/workspace1") | L project-three (branch: "feature/workspace1") | L workspace2 L project-one (branch: "feature/workspace2") L project-two (branch: "feature/workspace2") L project-three (branch: "feature/workspace2")
When I add a new worktree from a bare repo, my package suggests a branch name of feature/<workspace-name>
by default. Of course, you can change this or select an existing branch, but this is a convenient default for me.
Now when I finish the feature, or bug fix, whatever, I can easily see which merge requests need to be created based on the projects within the workspace. And when they're merged, I can safely remove the workspace and all the projects in it knowing the git database is tucked away in the bare directory.
BONUS! Per-workspace notes
I like to share the same notes for a single workspace so I can keep a discoverable list of things that need to be done or other related scratch information no matter which workspace-related project I'm in. I use denote for this. Specifically, I tag the notes with a keyword project
and title the note after the workspace. For example, for a workspace named add-cool-feature
, I'd have a note named <timestamp>--add-cool-feature__project.org
. This not only makes them quickly searchable using denote's simple naming convention, but also predictable for automation. So when I want to look at my workspace notes, when I'm in a buffer within some workspace, I can just press my keybind SPC p x
and the notes open. Here's the command that I bind:
If you don't use treebundel, you'd have to derive which workspace you're in yourself, but you should be able to do that by just looking at the directory the current buffers' file is in.
(defun pg/open-project-notes ()
(interactive)
(when-let* ((workspace-name (or (and current-prefix-arg
(treebundel-read-workspace))
(treebundel-current-workspace)
(treebundel-read-workspace))))
(if-let ((project-notes (denote-directory-files
(concat "^.*" workspace-name "__project.org$"))))
(find-file-other-window (car project-notes))
(denote workspace-name '("project")))))
Conclusion
Using a project structure like this not only gives you a goal-oriented organization, but also allows you to provide context and focus on what you're working on. You could also integrate this with some git-forge package to automate the creation of a workspace with a single project from some issue the project is attached to. Then if you need to touch some other repo, you can simple add it to your workspace.
And git-worktrees are, I feel, an underutilized feature of git that make this system even better. I imagine the main reasons people don't use worktrees are either they simply don't know about it them or possibly it is a lot of extra work to do when cloning is so much easier. But with a little automation to do the heavy lifting, I find them immensely useful.
The
.git
suffix on the directory below is not required and is just a convention to easily identify a bare repository.