Git Worktrees: Replace Your Pile of Clones with One Manageable Repository
What is a Git worktree?
A normal Git clone contains: the object database, commit history, branches, tags, remotes, remote-tracking references, and one checked-out working directory. A linked worktree adds another checked-out working directory to that same repository. For example:
~/src/project main
~/src/project-FEATURE-123 FEATURE-123
~/src/project-HOTFIX-456 HOTFIX-456
~/src/project-large-restructure large-restructure
Each worktree has its own:
- checked-out branch or detached
HEAD, - modified files,
- untracked files,
- ignored files,
- staging area,
- merge state, and
- rebase state.
The worktrees share:
- commit objects,
- local branches,
- tags,
- remotes,
- remote-tracking references, and
- the results of
git fetch.
That distinction is important. A worktree is not merely another directory pointing at the same files. It is an independent working area backed by shared repository metadata.
Why use worktrees instead of several clones?
Commits are immediately visible everywhere
Suppose you commit something in a feature worktree:
git -C "$HOME/src/project-FEATURE-123" commit -am "Complete FEATURE-123"
The commit and updated branch are immediately visible from the main worktree:
git -C "$HOME/src/project" log FEATURE-123 -1
There is no local push-and-fetch cycle between directories.
One fetch updates every worktree
Run:
git -C "$HOME/src/project" fetch origin --prune
Every linked worktree immediately sees the updated origin/* references. With independent clones, every clone must be fetched separately, and each clone can have a different idea of what origin/main means.
Git prevents one branch from being checked out twice
Git normally refuses to check out the same local branch in two linked worktrees at once. That protects you from independently modifying one branch from two directories.
Repository history is not duplicated
Each worktree has a separate checked-out filesystem tree, but the object database and Git history are shared. For a large repository, that can save substantial disk space.
You get a central inventory
Run this from any worktree:
git worktree list
Example output:
/home/user/src/project 81f9d2a [main]
/home/user/src/project-FEATURE-123 f27ae91 [FEATURE-123]
/home/user/src/project-HOTFIX-456 6b812b0 [HOTFIX-456]
For script-friendly output:
git worktree list --porcelain
A practical directory convention
Git does not impose a directory naming convention, but this one is easy to understand:
~/src/<repository> main
~/src/<repository>-<branch> another branch
Examples:
~/src/project
~/src/project-FEATURE-123
~/src/project-HOTFIX-456
Branch names containing / should usually be converted to filesystem-friendly names:
feature/new-api โ project-feature-new-api
The worktree directory name does not need to match the branch name exactly.
Everyday Git worktree commands
Before getting into migration, here are the commands you will use during normal development.
List worktrees
git worktree list
Add an existing local branch
git worktree add ../project-FEATURE-123 FEATURE-123
Create a new branch and worktree
git worktree add -b FEATURE-123 ../project-FEATURE-123 main
This creates FEATURE-123 from main and checks it out in the new directory.
Create a worktree from a remote branch
Update the remote-tracking references:
git fetch origin --prune
Then create the local branch and worktree:
git worktree add -b FEATURE-123 ../project-FEATURE-123 origin/FEATURE-123
Create a detached worktree for temporary testing
git worktree add --detach ../project-test main
A detached worktree is useful for: running a build against an old commit, reviewing a release tag, reproducing a bug, or performing disposable testing. Use a named branch instead if you may want to retain commits.
Work normally inside a worktree
Once created, a worktree behaves like an ordinary Git working directory:
cd ../project-FEATURE-123
git status
git add .
git commit
git pull --rebase
git push
You do not need special worktree versions of ordinary Git commands.
Rebase a feature branch onto updated main
Update the main worktree:
git -C ../project fetch origin --prune
git -C ../project merge --ff-only origin/main
Then rebase the feature worktree:
git -C ../project-FEATURE-123 rebase main
Because the worktrees share local branches, the feature worktree immediately sees the updated main.
Remove a worktree
git worktree remove ../project-FEATURE-123
Git normally refuses to remove a worktree containing uncommitted changes. This is much safer than deleting the directory manually.
Consolidating several independent clones
Suppose you currently have:
~/src/project
~/src2/project
~/src3/project
~/src4/project
The target layout is:
~/src/project canonical repository and main
~/src/project-FEATURE-123 linked worktree
~/src/project-HOTFIX-456 linked worktree
~/src/project-large-restructure linked worktree
The safe approach is:
- Inventory every clone.
- Choose one canonical repository.
- Update the canonical repository carefully.
- Convert one clone at a time.
- Import the source clone's exact branch and commit.
- Rename the source clone to a backup.
- Create a linked worktree.
- Copy the complete working-directory state.
- Compare the old clone and new worktree.
- Delete the backup only after testing.
Do not try to automate the entire migration in one opaque script. Convert one work area, verify it, and then continue.
Safety rules before starting
A few rules prevent most migration disasters.
Keep the source clone as a backup
Do not delete an old clone during migration. Rename it:
mv "$source_repo" "$backup"
Only delete the backup after the new worktree has been compared, built, and tested.
Do not use exit in pasted interactive snippets
A command such as:
exit 1
is reasonable inside a standalone script. It is not friendly in a command block intended to be pasted into an interactive terminal because it may close the shell session. The examples in this article print errors and rely on the engineer to stop before continuing.
Use braced variables in Bash and zsh
When text immediately follows a variable, use:
"${branch}:refs/heads/${branch}"
rather than:
"$branch:refs/heads/$branch"
The braced form is valid in both Bash and zsh. This matters particularly when a variable is followed by :. Zsh can interpret the colon as part of parameter-expansion syntax and produce a malformed Git refspec.
Remember that a linked worktree has a .git file
A normal clone has: .git/
A linked worktree usually has: .git
That .git entry is a text file pointing back to the shared repository metadata. This difference becomes important when using rsync.
Phase 1: Inventory every clone
Define the known clone paths:
repos=("$HOME/src/project" "$HOME/src2/project" "$HOME/src3/project" "$HOME/src4/project")
This array syntax works in both Bash and zsh.
Inventory each clone:
for repo in "${repos[@]}"; do
echo
echo "================================================================"
echo "Repository: $repo"
echo "================================================================"
if ! git -C "$repo" rev-parse --git-dir > /dev/null 2>&1; then
echo "NOT A GIT REPOSITORY"
continue
fi
printf "Branch: "
git -C "$repo" branch --show-current
printf "HEAD: "
git -C "$repo" rev-parse HEAD
printf "Origin: "
git -C "$repo" remote get-url origin 2>/dev/null || echo "(none)"
printf "Upstream: "
git -C "$repo" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || echo "(none)"
echo
echo "Status:"
git -C "$repo" status --short --branch --untracked-files=all
echo
echo "Recent commits:"
git -C "$repo" log --oneline --decorate -5
done
For each clone, record: directory, checked-out branch, exact HEAD commit, origin URL, upstream branch, modified files, deleted files, untracked files, and local-only commits.
Do not forget ignored files
Normal git status hides ignored files. Check them separately:
for repo in "${repos[@]}"; do
echo
echo "===== $repo : ignored files ====="
git -C "$repo" status --short --ignored=matching --untracked-files=all | grep '^!!' || true
done
Ignored files can still be important. Examples include: local configuration, test credentials, IDE settings, generated test fixtures, build caches, and environment-specific inventory files. You need to decide whether each one should be preserved.
Phase 2: Choose the canonical repository
Normally, the clone already used for main becomes the canonical repository:
canonical="$HOME/src/project"
Fetch the current remote state:
git -C "$canonical" fetch origin --prune
Inspect local and remote main:
git -C "$canonical" status --short --branch
git -C "$canonical" log --oneline --decorate --graph -10 main origin/main
Check for untracked-file collisions
Before fast-forwarding, inspect incoming paths:
git -C "$canonical" diff --name-status HEAD..origin/main
A future tracked file may collide with a local untracked file at the same path. Git will normally refuse to overwrite it, but the safer workflow is to preserve the local file explicitly.
pre_update_backup="$HOME/project-pre-update-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$pre_update_backup/path/to"
mv "$canonical/path/to/local-file.yml" "$pre_update_backup/path/to/local-file.yml"
Then fast-forward only:
git -C "$canonical" merge --ff-only origin/main
Compare the saved local file with the newly tracked version before restoring anything.
Phase 3: Decide what each clone should become
Clone on a different branch with useful work
Convert it into a linked worktree.
Another clone of main
Git normally permits a local branch to be checked out in only one worktree. If the duplicate clone is clean and contains nothing unique, retire it. Check for local-only commits:
git -C "$source_repo" log --oneline origin/main..main
Check tracked, untracked, and ignored files:
git -C "$source_repo" status --short --ignored=matching --untracked-files=all
If the duplicate main clone is being used as a second test environment, create either: a local alias branch, or a detached worktree.
Local alias example:
git -C "$canonical" branch local/second-main-testing main
git -C "$canonical" worktree add "$HOME/src/project-second-main-testing" local/second-main-testing
Detached example:
git -C "$canonical" worktree add --detach "$HOME/src/project-main-test" main
Use a named branch if commits may need to be retained.
Phase 4: Convert one clone
The following example converts one source clone into one linked worktree.
Set migration variables
canonical="$HOME/src/project"
source_repo="$HOME/src2/project"
branch="FEATURE-123"
destination="$HOME/src/project-FEATURE-123"
backup="$HOME/src2/project.pre-worktree-$(date +%Y%m%d-%H%M%S)"
migration_ref="refs/remotes/migrate/${branch}"
Verify the source
git -C "$source_repo" branch --show-current
git -C "$source_repo" rev-parse HEAD
git -C "$source_repo" status --short --branch --untracked-files=all
If git branch --show-current prints nothing, the source clone is in a detached HEAD state. Investigate before continuing unless a detached result is intentional.
Import the exact source branch
Do not assume the branch in the source clone matches: the canonical repository's local branch, origin/<branch>, or the latest remote commit. Import the source clone's exact branch into a temporary reference:
git -C "$canonical" fetch "$source_repo" "refs/heads/${branch}:${migration_ref}"
This also imports local-only commits that have never been pushed.
If the local branch does not already exist in the canonical repository, create it:
if git -C "$canonical" show-ref --verify --quiet "refs/heads/${branch}"; then
echo "Local branch already exists:"
git -C "$canonical" rev-parse "$branch"
else
echo "Creating local branch from imported source"
git -C "$canonical" branch "$branch" "$migration_ref"
fi
Now compare the two branch tips:
printf 'Source clone:'
git -C "$source_repo" rev-parse HEAD
printf 'Canonical: '
git -C "$canonical" rev-parse "$branch"
The hashes must be identical before continuing. Do not blindly reset either repository if they differ. Investigate why the branch histories are different.
After verification, remove the temporary migration reference:
git -C "$canonical" update-ref -d "$migration_ref"
Rena
Comments
No comments yet. Start the discussion.