Tag: git

“Use git worktrees,” they said. “It’ll be fun!” they said.

Earlier this week, we had an “AI Days” event at work where a bunch of engineers got together to share our AI workflows with the wider engineering organization. I ran a small session on some AI workflows One thing that stuck out: a surprising number of us had independently come up with our own way to manage git worktrees. We all had different names for it. “Replace.” “Recycle.” “Warm worktrees.” But we were all describing the same thing. That felt like a blog post.

On top of that, there’s been a lot of buzz about running multiple AI coding agents in parallel. Simon Willison has been writing about agentic engineering patterns and even wrote about embracing the parallel coding agent lifestyle. The idea is simple: spin up multiple instances of Claude Code (or Codex, or whatever) across different branches, let them work simultaneously, and review the results when they’re done.

The enabling technology for all of this? Git worktrees.

For the uninitiated (hey, I didn’t know what a git worktree was 3 or 4 months ago): a git worktree lets you check out multiple branches of the same repo into separate directories, all sharing a single .git history. Instead of git stash && git checkout other-branch, you just cd into another folder. Each agent gets its own isolated workspace. No conflicts. No stashing. No context switching headaches.

In theory, it’s a superpower. In practice, at least in a large monorepo, it’s been one of my most frustrating developer experience problems as of late.

Every blog post and tutorial about git worktrees shows something like this:

git worktree add ../feature-branch feature/my-feature
cd ../feature-branch
# start coding!

And that works great if you’re in a small repo. The worktree itself is created almost instantly. Git is just setting up a new working directory that points at the same .git folder.

But I work on a large monorepo powered by Yarn workspaces. Our node_modules situation involves 750,000+ files. So the actual workflow looks more like this:

git worktree add ../feature-branch feature/my-feature
cd ../feature-branch
yarn install --immutable                # ~10 minutes
# ...go get coffee, check Slack, forget what you were doing, grow old

Ten minutes. Every time. For what sometimes might be a 5-minute Claude Code task.

This is the part that none of the “git worktrees for AI agents!” articles mention. They’re all written from the perspective of small-to-medium repos where dependencies aren’t a factor. When your dependency tree generates three quarters of a million files, the worktree itself isn’t the bottleneck. node_modules is.

So, this led me down a rabbit hole. I spent a solid couple of weeks trying to make worktree creation fast. Here’s my graveyard of failed approaches.

Symlinked node_modules:

My first instinct. Symlink all the node_modules directories from the main repo checkout into the new worktree. In a Yarn workspaces monorepo, that’s not just one node_modules folder. It’s the root one plus nested ones inside individual packages.

This sort of worked. Until I tried to run tests. Vitest and Vite both choked on the symlinked paths. Module resolution in Node follows symlinks and then gets confused about what’s where. After a bunch of debugging, I ripped it all out.

Yarn’s hardlinks-global mode:

Our .yarnrc.yml has nmMode: hardlinks-global configured. This tells Yarn to store packages in a global cache and hardlink them into each project’s node_modules. In theory, this should make yarn install much faster because you’re just creating hardlinks instead of copying files.

In practice? It’s still creating 750K+ filesystem entries. The number of bytes copied is lower, sure. But the bottleneck was never the bytes. It was the sheer number of file operations. Even with hardlinks, you’re asking the filesystem to create hundreds of thousands of directory entries, and that takes time.

APFS Copy-on-Write (cp -c):

This one felt clever. macOS’s APFS filesystem supports copy-on-write cloning. You can duplicate a file instantly at the filesystem level with zero extra disk usage until someone modifies it. The cp -c command does this.

But again: 750K files. Even a copy-on-write clone has to create all the directory entries and metadata for each file. The filesystem operation count is the bottleneck, not the bytes. A cp -c of the entire node_modules tree still took way too long to be practical.

The solution? recycled worktrees:

Here’s what I landed on. Instead of creating and destroying worktrees on demand, I keep a fixed pool of 6 worktree slots (tree-1 through tree-6). Each one already has node_modules installed. They sit there, detached from any branch, ready to go (…but taking up disk space).

When I need a worktree, I don’t create one. I activate one. Under the hood, this:

  1. Finds the oldest idle slot (detached HEAD, clean working tree)
  2. Checks out the new branch in that slot
  3. Checks if yarn.lock changed between the old HEAD and the new branch
  4. Only runs yarn install if the lockfile actually differs

That third step is the key insight. Most of my branches are based on a recent main. The yarn.lock rarely changes between them. So in the common case, “activating” a worktree means checking out a branch and… that’s it. Seconds, not minutes.

I built a little CLI called wt to manage all of this, and it’s become one of my favorite tools as of late.

Here’s what that looks like in practice, using wt create after I pickup a Jira ticket:

$ wt create daves/HP-123/some-feat

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Activating slot: tree-3
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  Slot path: /Users/daves/workspace/rentals-js.worktrees/tree-3

[1/3] Checking out branch...
      git checkout -b daves/HP-123/some-feat main
      ✓ On branch: daves/HP-123/some-feat

[2/3] Checking dependencies...
      ✓ yarn.lock unchanged, skipping install

[3/3] Summary
      ✅ Slot ready!

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Next steps:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  cd /Users/daves/workspace/rentals-js.worktrees/tree-3

  # Start working (deps ready)
  cd /Users/daves/workspace/rentals-js.worktrees/tree-3/apps/hotpads-web
  yarn dev

Current branch: daves/HP-123/some-feat
Slot path:      /Users/daves/workspace/rentals-js.worktrees/tree-3
Slot name:      tree-3

The whole thing takes a few seconds. No yarn install (sometimes…). No waiting. I’m in a fully functional worktree and ready to fire up Claude Code.

It also supports checking out existing branches (wt create --checkout daves/HP-6841) and branching from something other than main (wt create daves/HP-6841 --base some-other-branch).

When I’ve got a few active worktrees going, I don’t want to type out full paths. wt go uses fuzzy matching against directory names and branch names:

$ wt go daves/HP-345/other-feat
# cd's into /Users/daves/workspace/rentals-js.worktrees/tree-3

It matches partial strings, so wt go 6841 works just as well. If your query matches multiple worktrees, it tells you:

$ wt go daves
Ambiguous match for 'daves'. Did you mean:
  tree-2 (daves/HP-6839)
  tree-3 (daves/HP-6841)
  tree-5 (daves/HP-6850)

And if there’s no match, it lists what’s available:

$ wt go some-nonexistent-branch
No worktree matching 'some-nonexistent-branch'

Available worktrees:
  tree-2 (daves/HP-6839)
  tree-3 (daves/HP-6841)
  tree-5 (daves/HP-6850)

The tricky part with wt go is that a script can’t change your shell’s working directory. So it’s backed by a shell function in my .zshrc that catches the output path and runs cd for you. Without the shell function, it just prints the path and tells you to cd manually.

Another command that I added was wt list. It gives you a quick overview of all your worktrees:

$ wt list

Git Worktrees for rentals-js

  BRANCH                 LAST MODIFIED   PATH
  main                   2 hours ago     ~/workspace/rentals-js (main repo)
  daves/HP-6841          5 mins ago      ~/workspace/rentals-js.worktrees/tree-3
  daves/HP-6839          3 hours ago     ~/workspace/rentals-js.worktrees/tree-2
  (detached HEAD)        2 days ago      ~/workspace/rentals-js.worktrees/tree-1
  (detached HEAD)        5 days ago      ~/workspace/rentals-js.worktrees/tree-4
  (detached HEAD)        1 week ago      ~/workspace/rentals-js.worktrees/tree-5
  (detached HEAD)        2 weeks ago     ~/workspace/rentals-js.worktrees/tree-6

  7 worktrees total  ·  Use --full for git status and commit age

At a glance, I can see that tree-3 and tree-2 are active (they have branches), and tree-1, tree-4, tree-5, and tree-6 are idle (detached HEAD) and available for the next task. The list is sorted by most recent activity, so the stuff I’m actively working on floats to the top.

If you want more detail, wt list --full runs git status and git log across all worktrees (in parallel, so it’s still fast) and shows you clean/dirty status with colored indicators plus commit ages.

After a PR is merged and I’m done with a branch, I release the slot back to the pool with wt release:

$ wt release HP-6841

Releasing slot: tree-3
  Branch: daves/HP-6841

[1/2] Detaching HEAD...
      ✓ Slot is now idle

[2/2] Delete branch 'daves/HP-6841'?
  [y/N] y
      ✓ Branch deleted

✓ 'tree-3' returned to pool

The slot goes back to detached HEAD with its node_modules intact and ready for the next task. If you’ve got uncommitted changes, it warns you before proceeding.

There are a few more details that make this all feel smooth:

  • Tab completion: The shell integration includes zsh completions for wt go and wt release. It autocompletes branch names and slot directory names, so you rarely have to type the full thing.
  • No pool slots get recycled by accident: I also keep a couple of named worktrees around (like dev and review) that aren’t part of the tree-N pool. The recycler only considers slots matching the tree-* pattern, so my persistent worktrees are safe.
  • It’s locked to the monorepo: The tool checks that you’re in the right repo before doing anything. If you accidentally run it from your personal projects folder, it tells you to navigate to the monorepo. This might seem overly cautious, but when you’re automating things with AI agents, guardrails matter.

The pool approach doesn’t just save time on individual tasks. It makes entire categories of automated workflows feasible.

I’ve been building an orchestrator that processes a backlog of bug tickets through sequential Claude Code phases: root cause analysis, write tests, fix, verify, push. Each ticket gets assigned a worktree from the pool. Without the pool, the 10-minute yarn install overhead would make this kind of automation completely impractical. With the pool, each ticket just grabs a slot, does its work, and releases it when it’s done.

It also changes the economics of “should I spin up a parallel agent for this?” If creating a worktree takes 10 minutes, you’re only going to do it for substantial tasks. If it takes 5 seconds, you’ll start doing it for everything. Quick refactor? Throw it in a worktree. Lint fix? Worktree. Experiment with an approach you might throw away? Worktree. The overhead drops below the threshold where you even think about it.

Of course, there are still a few pain points still.

Disk space issues are one area the come to mind. Six copies of node_modules at 750K files each is… a lot. The hardlinks-global mode helps with actual disk usage (files in the Yarn cache are shared), but the filesystem metadata overhead is real.

Also, for worktrees that have been sitting idle for a while, the best approach is git pull origin main && yarn install before checking out a new branch. This keeps the slot’s dependencies current and avoids the lockfile-changed install. I do this periodically but it would be nice to automate.

That said, Ggt worktrees really are a superpower for agentic engineering. Running multiple Claude Code instances across isolated branches, firing off tasks in parallel, building automated pipelines… all of it requires worktree-level isolation.

But if you work in a large JavaScript monorepo, the naive approach of creating fresh worktrees on demand is going to be painful. The worktree itself is instant. The dependency install is not.

The pre-warmed pool pattern sidesteps the problem entirely. Keep a handful of worktrees alive with their dependencies intact, rotate branches through them, and only reinstall when the lockfile changes. It took a few weeks of failed experiments to get here and numerous discussions with fellow engineers, but I’m really happy with the workflow as of today.

Git Branch Manager: a manager for git branches

A logo that was completely generated with AI, like everything else in the project. (Source: ChatGPT)

I don’t mind the term “vibe coding“, when used correctly. A lot of people (at least on Reddit) seem to think it means “any time you code with AI.” I prefer Andrej Karpathy’s original definition: ”

There’s a new kind of coding I call “vibe coding”, where you fully give in to the vibes, embrace exponentials, and forget that the code even exists. It’s possible because the LLMs (e.g. Cursor Composer w Sonnet) are getting too good. […] I ask for the dumbest things like “decrease the padding on the sidebar by half” because I’m too lazy to find it. I “Accept All” always, I don’t read the diffs anymore. When I get error messages I just copy paste them in with no comment, usually that fixes it. The code grows beyond my usual comprehension, I’d have to really read through it for a while. Sometimes the LLMs can’t fix a bug so I just work around it or ask for random changes until it goes away. It’s not too bad for throwaway weekend projects.

Emphasis mine.

That last bit is key. For throwaway weekend projects, vibe coding is this magical state where you have an idea but absolutely no desire to setup boilerplate, read API documentation, or even want to deal with the plumbing. You just want the thing to exist.

A few evenings ago, I decided to put in a serious vibe coding session using one of my favorite tools, Claude Code via SSH through Blink on my iPhone (!), to create a CLI utility to help manage the endless number of branches I have on my machine, all because of our huge monorepo.

For reasons I’m not entirely sure about, I decided to use Python. A language I know approximately nothing about beyond “white-space matters”, “there’s probably a library for that,” and “print("Hello, world!").”

(Hey, I’d have to turn in my software engineering badge if I didn’t attempt to reinvent the wheel every so often)

So, picture this scenario: I’m staring at my terminal after I had just typed git branch. I’m looking at a screen with about 37 branches in various states of decay. And of course there are numerous unhelpful branches with names like, daves/no-jira/hotfix, daves/no-jira/hotfix-2, daves/no-jira/hackweek-project-cool. My terminal is just a wall of branch names, most of them mocking my organizational skills.

(By the way, what was hackweek-project-cool? I don’t know… I never committed any code to it!)

I needed a better way to manage this chaos. So naturally, instead of cleaning up my branches like a responsible developer, I decided to build a tool. In a language I don’t know. Using only Claude Code. What could possibly go wrong?

Claude Code is great. It never gets tired. It never gets angry. Even if I kept asking questions and piling more features on top.

  • “Let’s make a shell script using Python to display Git branches.”
  • “Ohhh, can we make it show which branches have uncommitted changes?”
  • “Oh, oh! Add some colors, but tasteful colors…”
  • “Hey, there are a lot of branches here. What if pressing shift + d deleted branches (but we should probably have a confirmation…)?”
  • “You know what it really needs now? A loading spinner!”

After a few hours of asynchronous back and forth, we had a result! Git Branch Manager: a terminal UI that actually shows useful information at a glance. Navigate your branches with arrow keys, see visual indicators for everything important, and perform common operations without memorizing Git commands.

For me, those little indicators are game changers:

  • * Current branch
  • [modified] Has uncommitted changes
  • [unpushed] Exists locally but not on remote
  • [merged] Already merged (why is this still here?)
  • Remote branch you haven’t checked out yet

One feature that I love: Smart stash management. When you try to switch branches with uncommitted changes, gbm asks if you want to stash them. But here’s the cool part: it remembers which stashes it created and gives you a notification when you switch back. Press ‘S’ and boom! Your changes are restored!

This process still blows my mind. I describe what I wanted in plain English, and Claude Code would translate these chaotic ideas into actual, working Python. “Make old branches look old” turned into color-coding based on commit age. “It should be smart about stashing” became an entire stash management system.

Installation is pretty simple, too. No pip, no dependencies, just curl and go:

# Quick install
sudo curl -L https://raw.githubusercontent.com/daveschumaker/gbm/main/git-branch-manager.py -o /usr/local/bin/git-bm
sudo chmod +x /usr/local/bin/git-bm

# Additionally, add as a git alias
git config --global alias.bm '!git-bm'

Now you can just type git bm anywhere. Pretty neat!

There’s also browser integration (press b on any branch), worktree support, and a bunch of other features I’m proud of, err, creating, despite not having touched the code.

Here’s the thing about vibe coding with AI: it completely changes what’s possible for a weekend project. I built a legitimate, useful tool in a language I don’t know and using standard libraries I’d never heard of.

If you asked me 6 months ago if someone with no knowledge could build useful tools or websites using AI prompts only, I might have said no. “You still need people who understand software engineering principles, who understand what the code is doing,” I’d (not so) confidently say.

But now… it’s getting crazy. Is this the future of programming? To be a glorified conductor who oversees an orchestra of AI agents? Maybe!

Bottom line: my Git branches have never looked better, and I didn’t have to spend six months learning Python to make it happen! So, if you’re buried in branches like I was, give gbm a shot: github.com/daveschumaker/gbm

Now if you’ll excuse me, I need to go stare at my beautifully organized branch list again.

P.S. Yes, I realize I could have just cleaned up my branches manually in less time than it took to build this. But where’s the fun in that?

TIL: List git branches by recent activity

In both my work and personal coding projects, I generally have a number of various branches going at once. Switching between various branches (or remembering past things I was working on) can somethings be a chore. Especially if I’m not diligent about deleting branches that have already been merged.

Usually, I do something like:

> git branch

Then, I get a ridiculously huge list of branches that I’ve forgotten to prune and spend all sorts of time trying to remember what I was most recently working on.

daves/XXXX-123/enable-clickstream
daves/XXXX-123/impression-events
daves/XXXX-123/tracking-fixes
daves/XXXX-123/broken-hdps
daves/XXXX-123/fix-contacts
daves/XXXX-123/listing-provider
daves/XXXX-123/revert-listing-wrapper-classname
daves/XXXX-123/typescript-models
daves/XXXX-123/inline-contact-form
daves/XXXX-123/clickstream_application_event
daves/XXXX-123/unused-file
daves/XXXX-123/convert-typescript
daves/XXXX-123/convert-typescript-v2
daves/XXXX-123/similar-impressions
daves/XXXX-123/update-node-version

At least 75% of those have already been merged and should have been pruned.

There has to be a better way, right?

Thanks to the power of the Google machine (and Stack Overflow), I found out, there is!

> git branch --sort=-committerdate

Hot diggity dog!

daves/XXXX-123/clickstream-filter-events
main
daves/XXXX-123/convert-typescript-v2
daves/XXXX-123/update-node-version
daves/XXXX-123/similar-impressions
daves/XXXX-123/convert-typescript
daves/XXXX-123/clickstream_application_event
daves/XXXX-123/unused-file
daves/XXXX-123/typescript-models
daves/XXXX-123/listing-provider
daves/XXXX-123/inline-contact-form
daves/XXXX-123/revert-listing-wrapper-classname

That list is now sorted by most recent activity on the branch.

Alright. Even though this is better, that’s still a lot of typing to remember. Fortunately, we can create an alias:

> git config --global alias.recent "branch --sort=-committerdate"

Now all I need to do is just type git recent and it works!

Nice.

TIL: How to change your default editor for git commits

A recent post on Hacker News highlighted the benefits of detailed commit messages in git.

Usually, my git commits look something like this:

> git commit -m "fix: component missing configuration file"

…which isn’t all that helpful. (Related: see XKCD on git commit messages)

I decided to try and utilize this newfound knowledge in my own git commits and I quickly ran into an obstacle. Simply using > git commit opens up vim. Which, I really don’t want to use. (I’m sorry!)

This is something I should already know how to do, but I had to do a Google search to learn more. It turns out, you can change the default editor in git. This makes it much more convenient! How do you do it?

git config --global core.editor "nano"

Replace “nano” with your preferred editor of choice. Now, running > git commit opens up your editor and you can make detailed commit messages to your heart’s content!

Next level code review skills

I’m always searching for better ways to improve my workflow, increase productivity, and just generally learn new and exciting things. (Besides, it’s part of having a healthy growth mindset.)

We’ve had some big changes on our team during the past year and I’ve felt like I’ve needed to step up when it comes to reviewing code that my fellow colleagues write. While searching for some ideas on how to improve my code review skills, I discovered a blog post from 2018, entitled “Code Review from the Command Line“.

This blew my mind and really helped reframe how we engineers should approach code reviews:

When I ask that other people review my code, it’s an opportunity for me to teach them about the change I’ve just made. When I review someone else’s code, it’s to learn something from them.

Jake, the author of the above post, goes on to describe his setup and custom tooling for conducting an interactive code review.

He uses custom git alias to see which files have changed and how many changes there are, a custom script that visualizes how often the files within the pull request have changed over time, and another custom script that can visualize the relationship between changed files.

All of these go above and beyond the call of duty for reviewing code, but it’s stuff that decreases friction and can make a sometimes tedious process much more enjoyable.

I’ll be implementing some of these ideas into my own workflow in the near future.