Over the past few weeks I’ve been developing and using a personal command-line
tool called gwt (Git Worktree) to manage Git repositories using worktrees.
This article explains what the tool does, how it evolved, and how I used
GitHub Copilot CLI to develop it (in
fact the idea of building the script was also to test the tool).
The Problem: Managing Multiple Branches
I was working on a project with multiple active branches, including orphans; the regular branches are for fixes or features, while the orphans are used to keep copies of remote documents or store processed versions of those documents.
The project also uses a special orphan branch that contains the scripts and the CI/CD configuration to store and process the external documents (it is on a separate branch to avoid mixing its operation with the main project code).
The plan is trigger a pipeline against the special branch from remote projects to create or update the doc branch for it in our git repository, retrieving artifacts from the remote projects to get the files and put them on an orphan branch (initially I added new commits after each update, but I changed the system to use force pushes and keep only one commit, as the history is not really needed).
The original documents have to be changed, so, after ingesting them, we run a
script that modifies them and adds or updates another branch with the processed
version; the contents of that branch are used by the main branch build process
(there we use git fetch and git archive to retrieve its contents).
When working on the scripts to manage the orphan branches I discovered the
worktree feature of git, a
functionality that allows me to keep multiple branches checked out in parallel
using a single .git folder, removing the need to use git switch and git
stash when changing between branches (until now I’ve been a heavy user of those
commands).
Reading about it I found that a lot of people use worktrees with the help of a wrapper script to simplify the management. After looking at one or two posts and the related scripts I decided to create my own using a specific directory structure to simplify things.
That’s how I started to work on the gwt script; as I also wanted to test
copilot I decided to build it using its help (I have a pro license at work and
wanted to play with the cli version instead of integrated into an editor, as I
didn’t want to learn a lot of new keyboard shortcuts).
The gwt Philosophy: Opinionated and Transparent
gwt enforces a simple, filesystem-visible model:
- Exactly one bare repository named
bare.git(treated as an implementation detail) - One worktree directory per branch where the directory name matches the branch name
- Single responsibility:
gwtdoesn’t try to be a generalgitwrapper; it only handles operations that map cleanly to this layout
The repository structure looks like this:
my-repo/
+-- bare.git/ # the Git repository (internal)
+-- main/ # worktree for branch "main"
+-- feature/api/ # worktree for branch "feature/api"
+-- fix/docs/ # worktree for branch "fix/docs"
+-- orphan-history/ # worktree for the "orphan-history" branchThe tool follows five core design principles:
- Explicit over clever: Git commands are not hidden or reinterpreted
- Transparent execution: Every operation is printed before it happens
- Safe, preview-first operations: Destructive commands default to preview, confirmation, then apply
- Shell-agnostic core: The script never changes the caller’s working directory (shell wrappers handle that)
- Opinionated but minimal: Only commands that fit the layout model are included
Core Commands
The script provides these essential commands:
gwt init <url>— Clone a repository and set up thegwtlayoutgwt convert <dir>— Convert an existing Git checkout to thegwtlayoutgwt add [--orphan] <branch> [<base>]— Create a new worktree (optionally orphaned)gwt remove <branch>— Remove a worktree and unregister it (asks the user to remove the local branch too, useful when removing already merged branches)gwt rename <old> <new>— Rename a branch AND its worktree directorygwt list— List all worktreesgwt default [<branch>]— Get or set the default branchgwt current— Print the current worktree or branch name
Except init and convert all of the commands work inside a directory
structure that follows the gwt layout, which looks for the bare.git folder to
find the root folder of the structure.
As I don’t want to hide which commands are really used by the wrapper, all git
and filesystem operations pass through a single run shell function that prints
each command before executing it. This gives complete visibility into what the
tool is doing.
Also, destructive operations (remove, rename) default to preview mode:
$ gwt remove feature-old --dry-run
+ git -C bare.git branch -d feature-old
+ git -C bare.git worktree remove feature-old/
Apply these changes? [y/N]:The user sees exactly what will happen, can verify it’s correct, and only then confirm execution.
Incremental Development with Copilot
The gwt script has grown from 597 lines in its original version (git-wt) to
1,111 lines when writing the first draft of this post.
This growth happened through incremental, test-driven development, with each feature being refined based on real usage patterns.
What follows is a little history of the script evolution written with the help
of git log.
Initial version
First I wrote a design document and asked copilot to create the initial
version of the git-wt script with the original core commands.
I started to use the tool with a remote repostory (I made copies of the branches
in some cases to avoid missing work) and fixed bugs (trivial ones with neovim,
larger ones asking copilot to fix the issues for me, so I had less typing to
do).
First command update
One of the first commands I had to enhance was rename:
- as I normally use branches with
/on their name and my tool checks out the worktrees using the branch name as the path inside thegwtroot folder (i.e. afix/renamebranch creates thefixdirectory and checks the branch inside thefix/renamefolder) therenamecommand had to clean up the empty parent directories - when renaming a worktree we move the folders and fix the references using the
worktree repaircommand to make things work locally, but the rename also affects the remote branch reference, to avoid surprises the command unsets the remote branch reference so it can be pushed again using the new name (of course, the user is responsible of managing the old remote branch, as thegwtcan’t guess what it should do with it).
Integration with the shell
As I use zsh with the Powerlevel10k
theme I asked copilot to help me add visual elements to the prompt when
working with gwt folders, something that I would have never tried without
help, as it would have required a lot of digging on my part on how to do it, as
I never looked into it.
The initial version of the code was on an independent file that I sourced from
my .zshrc file and it prints on the right part of the prompt
when we are inside a gwt folder (note that if the folder is a worktree we see
the existing git integration text right before it, so we have the previous
behavior and we see that it is a gwt friendly repo) and if we are on the root
folder or the bare.git folder we see gwt or bare
(I added the text because there are no git promts on those folders).
I also asked copilot to create zsh autocompletion functions (I only use
zsh, so I didn’t add autocompletion for other shells). The good thing here is
that I wouldn’t have done that manually, as it would have required some reading
to get it right, but the output of copilot worked and I can update things
using it or manually if I need to.
One thing I was missing from the script was the possibility of changing the
working directory easily, so I wrote a gwt wrapper function for zsh that
intercepts commands that require shell cooperation (changing the working
directory) and delegates everything else to the core script.
Currently the function supports the following enhanced commands:
cd [<branch>]: change into a worktree or the default one if missingconvert <dir>: convert a checkout, then cd into the initial worktreeadd [--orphan] <branch> [<base>]: create a worktree, then cd into it on successrename <old> <new>: rename a worktree, then cd into it if we were inside it
Note that the cd command will not work on other shells or if the user does not
load my wrapper, but the rest will still work without the working directory
changes.
Renaming the command
As I felt that git-wt was a long name I renamed the tool to gwt, I could
have done it by hand, but using copilot I didn’t have to review all files by
myself and it did it right (note that I have it configured to always ask me
before doing changes, as it sometimes tries to do something I don’t want and I
like to check its changes … as I have the files in git repos, I manually add
the files when I like the status and if the cli output is not clear I allow it
to apply it and check the effects with git diff so I can validate or revert
what was done).
The convert command
After playing with one repo I added the convert subcommand for migrating
existing checkouts, it seemed a simple task at first, but it took multiple
iterations to get it right, as I found multiple issues while testing (in fact I
did copies of the existing checkouts to be able to re-test each update, as some
of the iterations broke them).
The version of the function when this post was first edited had the following comment explaining what it does:
# ---------------------------------------------------------------------------
# convert - convert an existing checkout into the gwt layout
# ---------------------------------------------------------------------------
#
# Must be run from the parent directory of <dir>.
#
# Steps:
# 1. Read branch from the checkout's HEAD
# 2. Rename <dir> to <dir>.wt.tmp (sibling, same filesystem)
# 3. Create <dir>/ as the new gwt root
# 4. Move <dir>.wt.tmp/.git to <dir>/bare.git; set core.bare = true
# 5. Fix fetch refspec (bare clone default maps refs directly, no remotes/)
# 6. Add a --no-checkout worktree so git wires up the metadata and
# creates <dir>/<branch>/.git (the only file in that dir)
# 7. Move that .git file into the real working tree (<dir>.wt.tmp)
# 8. Remove the now-empty placeholder directory
# 9. Move the real working tree into place as <dir>/<branch>
# 10. Reset the index to HEAD so git status is clean
# (--no-checkout leaves the index empty)
# 11. Create <dir>/.git -> bare.git symlink so plain git commands work
# from the root without --git-dir
#
# The .git file ends up at the same absolute path git recorded in step 5,
# so no worktree repair is needed. Working tree files are never modified.The .git link was added when I noticed that I could run commands that don’t
need the checked out files on the root of the gwt structure, which is handy
sometimes (i.e. a git fetch or a git log, that shows the log of the branch
marked as default).
After playing with commands that used the bare.git folder I updated the init
and convert commands to keep the origin refs, ensuring that the remote
tracking works correctly.
Improving the add command
While playing with the tool on more repos I noticed that I also had to enhance
the add command to better handle worktree creation, depending on my needs.
Right now the tool supports the following use cases:
- if the
branchexists locally or on origin, it just checks it out. - if the
branchdoes not exist, we create it using the given base branch or, if no base is given, the current worktree (if we are in the root folder orbare.gitthe command fails). - as I needed it for my project, I added a
--orphanoption to be able to create orphan branches directly.
Moving to a single file
Eventually I decided to make the tool self contained; I removed the design
document (I moved the content to comments on the top of the script and details
to comments on each function definition) and added a pair of commands to print
the code to source for the p10k and zsh integration (autocompletion &
functions), leaving everything in a single file.
Now my .zshrc file adds the following to source both things:
# After loading the p10k configuration
if type gwt >/dev/null 2>&1; then
source <(gwt p10k)
fi
[...]
# After loading autocompletion
if type gwt >/dev/null 2>&1; then
source <(gwt zsh)
fiVersioning
As I modified the script I found interesting to use CalVer-based versioning (the
version variable has the format YYYY.mm.dd-r#) so I added a subcommand to show
its value or bump it using the current date and computing the right revision
number.
About the use of copilot
Although I’ve never been a fan of AI tools I have to admit that the copilot
CLI has been very useful for building the tool:
- Rapid prototyping: Each commit represented a small feature or fix that I could implement, test immediately in my actual workflow, and iterate on based on the result
- Edge case handling: Rather than trying to anticipate every scenario upfront, I could ask Copilot how to handle edge cases as they appeared in real usage
- Script refinement: Questions like "how do I clean up empty directories after a rename" or "how do I detect if I’m inside a specific worktree" were quickly answered with working code
- Shell integration: The Zsh wrapper and completion system grew from simple prototypes to sophisticated features, with each iteration informed by how I actually used the tool
For example, the convert command started as a simple rename operation, but
evolved to also create a .git symlink and intelligently handle various
migration scenarios—all because I used it repeatedly and refined the
implementation each time.
Self-Contained and Opinionated
gwt is deliberately opinionated:
- Zsh & Powerlevel10k Integration: The tool includes built-in Zsh shell
integration, accessed via
source <(gwt zsh)and supports adding a prompt segment when usingp10k, as described earlier. - Directory Structure: The
bare.gitdirectory name is non-negotiable. This is howgwtdiscovers the repository root from any subdirectory, and how the tool knows whether a directory is a gwt repository. The simplicity of this marker means the discovery mechanism is foolproof and requires no configuration. - No Configuration Files:
gwtdeliberately has no configuration. There are no.gwtrcfiles or config directories. This makes it portable; the tool works the same way everywhere, and repositories can be shared across systems without synchronizing configuration.
From Script to System
What started as a small helper script for managing worktrees has become a complete system:
- Core script (
gwt): 1,111 lines of pure shell, no external dependencies - Shell integration: Zsh functions and completions
- Prompt integration: Powerlevel10k segment
- Documentation: Built-in help and design philosophy documentation
The script is self-contained, everything needed for the tool to work is in a single file.
This makes it trivial to update (just replace the script) or audit (no hidden dependencies).
Development with AI support
Developing gwt with copilot taught me some things:
- Incremental refinement works well for small tools: Each iteration informed the next, resulting in a tool that handles real use cases elegantly
- Transparency is a feature: Making operations visible builds confidence and is easier to debug
- Opinionated tools can be powerful: By constraining the problem space (one bare repo, one worktree per branch), the solution becomes simpler and more robust
- Shell integration matters: The same core commands are easier to use when they can automatically change directories and provide completions
- Real-world testing is essential: I wouldn’t have discovered the need for
automatic directory cleanup or context-aware
cdbehavior without actually using the tool daily
What was next?
The tool is stable and handles my daily workflow well, so my guess is that I
would keep using it and fixing issues if or when I found them, but I do not plan
to include additional features unless I find a use case that justifies it (i.e.
I never added support for some of the worktree subcommands, as it is easier to
use the git versions if I ever needed them).
What really happened
While editing this post I discovered that I needed to add another command to it and fixed a bug (see below).
With those changes and the inclusion of a license and copyright notice (just in case I distribute it at some point) now the script is 1,217 lines long instead of the 1,111 it had when I started to write this entry.
Submodule Support
When I converted this blog repository to the gwt format and tried to preview
the post using docker compose, it failed because the worktree I was on didn’t
have the Git submodule initialized.
My blog theme is included on the repository as a submodule, and when I used
gwt to check out different branches in worktrees, the submodule was not
initialized in the new worktrees.
This led me to add new internal function and a gwt submodule command to
handle submodule initialization; the internal function is called from convert
and add (when converting a repo or adding a worktree) and the public command
is useful to update the submodules on existing branches.
Path Handling with Branch Names Containing Slashes
The second discovery was a bug in how the tool handled branch names containing
slashes (e.g., feature/new-api, docs/user-guide), the worktree directories
are created with the branch name as the path, so a branch like feature/new-api
would create two nested folders (feature and new-api inside it).
However, there was a mismatch in how the zsh wrapper function resolved
worktree paths (initially it used shell parameter expansion, i.e.
rel="${cwd#"$REPO_ROOT"/}"), versus how the core script calculated them,
causing the cd command to fail or navigate to the wrong location when branch
names contained slashes.
The fix involved ensuring consistent path resolution throughout the script and
wrapper (now it uses a function that processes the git worktree list output),
so that gwt cd feature/new-api correctly navigates to the worktree directory
regardless of path depth.
Conclusion
gwt is a tool that solves a real problem: managing multiple Git branches
simultaneously without context-switching overhead.
I’m sure I’m going to keep using it for my projects, as it simplifies some
workflows, although I’ll also use switch and stash in some cases, but I like
the use of multiple worktrees in parallel.
In fact I converted this blog repository checkout to the gwt format to work on
a separate branch as it felt the right approach even if I’m the only one using
the repo now, and it helped me improve the tool, as explained before.
Also, it was a good example of how to use AI tools like copilot to develop a
simple tool and keep it evolving while using it.
In any case, although I find the copilot useful and has saved me time, I don’t
trust it to work without supervision, it worked well, but got stuck some times
and didn’t do the things as I wanted in multiple occasions.
I also have an additional problem now … I’ve been reading about it, but I don’t really know which models to use or how the premium requests are computed (I’ve only been playing with it since last month and I ran out of requests the last day of the month on purpose, just to see what happened … it stops working … ;).
On my work machine I’ve been using a specific user account with a GitHub
Copilot Business subscription and I only used the Anthropic Claude Sonnet 4.6
model and with my personal account I configured the Anthropic Claude Haiku 4.5
model, but I’ve only used that to create the initial draft of this post (I ended
up rewriting most of it manually anyway) and to review the final version (I’m
not a native speaker and it was useful for finding typos and improving the style
in some parts).
I guess I’ll try other models with copilot in the future and check other
command line tools like aider or
claude-code, but probably only using
free accounts unless I get a payed account at work, as I have with GitHub
Copilot.
To be fair, what I will love to be able to do is to use local models (aider can
do it), but the machines I have are not powerful enough. I tried to run a simple
test and it felt really slow, but when I have the time or the need I’ll try
again, just in case.