Git worktrees are one of the lesser known and under-appreciated features of Git that I discovered a couple of years ago but didn’t fully adopt into my work flow until recently. In doing so I found there are some friction points to adopting Git Worktrees but as with most things there are ways to smooth over these. This post explains how to use Git hooks to manage setting up just configuration files and create virtual environments with uv and configure direnv to activate these.
The problem(s)
When working on Python projects I’ll use virtual environments to provide an isolated set of packages specific to the package being developed, typically Python. I used to use virtualenvwrapper to create and manage my environments but have recently been trying out uv where the source of the environment is created within the project (by default in the .venv/ directory). When using the traditional Git workflow of a single folder and switching branches this was quite convenient, as installing packages with [uv] pip install -e . meant that switching branches the files within the project are “editable” and by switching branches I also get the changes between branches reflected in the behaviour of the software.
However, with the switch to using Git Worktrees each branch lives in its own directory and it’s not possible to have a single virtual environment that you can easily switch between the development versions of the software that resides in each directory. Instead you have to create a virtual environment in each directory and activate it. For a long time I’ve used direnv to carry out the task of activating a given virtual directory when I cd into it (or load a project in Emacs thanks to the emacs-direnv package).
I’ve also started using the Just runner system to provide convenient short-cut commands for tasks I run regularly such as running different pre-commit hooks, installing the package itself, updating tests and so forth (see my justfile template for a growing list of examples).
The solution
Regular readers of this blog, if any exist, will know that I’m a big fan of pre-commit which is a framework for leveraging Git hooks. Hooks are not limited to run prior to commits though, there are a whole host of other stages at which they can run such as post-commit, pre-push and pre-pull. For the challenge at hand we can leverage the post-checkout hook to run some commands after we checkout a branch which is part of the steps carried out when creating a worktree.
The real trick though is doing so only on the first time we checkout a branch and I found this solution courtesy of a short snippet Using Git Hooks When Creating Worktrees which provides the framework/scaffolding which I’ve bulked out for my use case. The script…
- Sets the
BASE_PATHto the directory two levels higher. This is because I use a convention of<username>/<issue-##>-<short-description>when naming branches and this carries over nicely to naming my worktrees. - Checks to see if there is a
main/.justfile, if not it creates one usingechoto append the required commands tomain/.justfile. This example only adds two runners, for more examples see my justfile template - Checks for
main/.envrcand if not creates one which willsource .venv/bin/activatewhich is the pathuv venvinstalls to by default. - Checks if this is a new worktree, this is the “magic” I found in Mark Skelton’s article if so then…
- Copy
main/.justfileandmain/.envrcfrommain/to the current directory. - If there is no virtual environment directory (
.venv/) then… - Create a virtual environment using
uv venv. - Run
uv syncto install the project dependencies. - Install the current package in editable form using the
just pip_installcommand to ensure alldevgroup dependencies are installed. - Allow
direnvin the current directory. - If
pre-commithooks are not present then initialisepre-commit.
#!/bin/bash
#
# Inspiration from : https://mskelton.dev/bytes/20230906143340
#
if [[ "$1" == "0000000000000000000000000000000000000000" ]]; then
# Base directory for bare repository
BASE_PATH="$(git rev-parse --git-dir)/../../"
MAIN_PATH="${BASE_PATH}/main"
# If main/.justfile doesn't exist create it
if [ ! -f "${MAIN_PATH}/.justfile" ]; then
echo "No .justfile creating a basic skeleton"
printf "
@default:
@just --list
" > ${MAIN_PATH}/.justfile
printf "
# uv pip install with group dev dependencies
pip_install:
uv pip install --editable . --group dev
" >> ${MAIN_PATH}/.justfile
fi
# If main/envrc doesn't exist create it
if [ ! -f "${MAIN_PATH}/.envrc" ]; then
echo "No .envrc creating a basic skeleton"
printf "#!/bin/bash
source .venv/bin/activate
export PATH=${VIRTUAL_ENV}/bin:${PATH}" > ${MAIN_PATH}/.envrc
fi
# Copy main/{envrc,justfiles} to current directory
FILES=(".justfile" ".envrc")
for FILE in "${FILES[@]}"; do
echo "Copying main/'${FILE}' to $(pwd)/${FILE}"
cp "${MAIN_PATH}/${FILE}" "$(pwd)/${FILE}"
done
# Create a virtual environment using uv and install the current Python package
if [ ! -d ".venv/" ]; then
uv venv
uv sync
just pip_install
# Allow direnv so venv can be activated
if [ -f ".envrc" ]; then
direnv allow
fi
fi
# Enable pre-commit if not already enabled
if [ ! -f "${BASE_PATH}/hooks/pre-commit" ]; then
pre-commit install
fi
fiWith Worktrees you typically, but not always, will have cloned a bare repository which contains all the files that normally live in a repositories ~/.git/ directory at the top level. The above should code should be placed in the hooks/post-checkout file and will then run after every checkout.
Gotchas
There are a few gotchas to be aware of if you want to use the above script directly. Setting the BASE_PATH variable above assumes that you are creating your worktrees in a nested directory. I alluded to this above when I wrote that I use <username>/<issue-##>-<short-description> for my branch names and use the same convention for worktree names. If you only use <branch-name> for the worktree directory then you will have to modify the BASE_PATH and remove one of the ../ as you will only need to go up one directory level.
If you don’t use direnv then you should remove those sections that create/copy .envrc and activate the directory. Similarly if you don’t use pre-commit you should remove the lines that activate that.
Side-stepping creation
If you already have either .justfile or .envrc files that you use in a given repository and don’t want to create them from scratch then you do not need to worry because if they are in the main branch they will be used.
Summary
There is a small amount of friction to switching to a Git Worktrees based workflow but they greatly reduce the friction involved in working on multiple branches simultaneously as it reduces to cd <worktree_path>. With a post-checkout hook its easy to create just and direnv configuration files on initial creation of a worktree and branch as well as create and activate uv based virtual environments, allow direnv and initialise the pre-commit framework in the new directory.
[uv_env]
Reuse
Citation
@online{shephard2026,
author = {Shephard, Neil},
title = {Git - {Worktree} {Hooks}},
date = {2026-07-25},
url = {https://blog.nshephard.dev/posts/git-worktree-hooks/},
langid = {en}
}
