~/blog/bare-repo-worktrees cat a-bare-repo-as-the-worktree-root.md

A bare repo as the worktree root

I stopped treating branches as states of one directory.

The usual workflow asks one checkout to be everything at once: switch branches, stash unfinished work, rebuild dependencies, and remember what was running before the switch. Git worktrees fix the underlying problem by giving each branch its own directory while sharing one object store.

A normal clone still leaves one checkout in a special position: the repository lives inside its .git directory while every additional worktree lives somewhere else. I prefer a symmetrical layout instead. The repository itself is bare, and every checkout hangs from it:

~/repos/sageveil.git/
├── main/
├── feat/pi-port/
└── docs/screenshot-backgrounds/

No checkout owns the repository. They are all disposable views over the same history.

I previously wrapped this setup in a shared justfile and a large helper named git-worktree-tms. It worked, but the interface leaked the implementation: every repository needed an import, and I had separate recipes for opening, listing, picking, refreshing, and removing worktrees.

The replacement is one command: wt.

Initialize once

wt init git@github.com:sageveil/sageveil.git

That command:

  1. clones the repository bare into ~/repos/sageveil.git
  2. restores the remote fetch refspec that bare clones omit
  3. fetches origin
  4. wires the default local branch to origin/HEAD
  5. runs the post-init hook

The Git setup it hides is still worth knowing:

git clone --bare <repo-url> ~/repos/<name>.git
git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'
git fetch --prune origin
git branch --set-upstream-to=origin/main main

Without that refspec, remote branches do not appear under origin/*, which makes worktree creation feel mysteriously broken.

My post-init hook opens the new repository through tmux-sessionizer. TMS notices that the repository is bare and creates the default branch worktree as the first tmux window.

One sharp edge: TMS matches excluded_dirs against the full path as a substring. Excluding .git also excludes a bare repository named sageveil.git. I removed .git from that list; normal repositories are found before TMS ever needs to descend into their metadata directory.

One interface for daily work

Create or open a worktree by branch name:

wt feat/pi-port

wt fetches first, then does the least surprising thing:

Run it without arguments to pick from all available local and remote branches:

wt

The fzf list is deduplicated and excludes branches that already have a worktree.

The rest of the interface is deliberately small:

wt list                 # list worktrees
wt rm feat/pi-port      # remove a clean worktree and its local branch
wt rm                   # pick multiple worktrees with fzf
wt rm topic --force     # remove a dirty worktree deliberately
wt rm --force           # force-remove selected worktrees

Removal never deletes the remote branch. A dirty checkout requires --force, so the short command is not a shortcut around data-loss protection.

Hooks instead of hard-coded integrations

The worktree lifecycle and my tmux setup are separate concerns. wt sources $XDG_CONFIG_HOME/wt/hooks and recognizes:

wt_pre_init    wt_post_init
wt_pre_add     wt_post_add
wt_pre_rm      wt_post_rm

Each hook receives the repository root, repository name, branch, and worktree path through environment variables. A failing pre-hook aborts the operation; a failing post-hook reports failure.

My defaults are small:

The Git tool remains useful without tmux, and changing session managers no longer means rewriting worktree logic.

Worktrees make agent handoffs boring

The same layout is useful for coding agents. My handoff skill creates a new branch from the current branch, asks wt for a fresh worktree, and places an untracked handoff.md there with the goal, current state, decisions, next steps, validation, and relevant files.

The next agent starts in an isolated checkout with explicit context instead of inheriting a long conversation and a dirty directory. The only important rule is to hand off committed state: a new branch cannot contain changes that exist only in another worktree’s index or working directory.

The result

The old version automated Git commands. The new version gives the workflow one small interface.

Repositories are initialized consistently. Branch discovery is interactive. Destructive removal is guarded. Tmux integration lives in hooks. Agent sessions can move between worktrees with a plain Markdown handoff.

No per-repository justfile, no special main checkout, and no pile of commands to remember.

The implementation lives in my dotfiles:

~/blog