Table Of Contents
- What is a Git worktree?
- Why use worktrees instead of several clones?
- A practical directory convention
- Everyday Git worktree commands
- Consolidating several independent clones
- Safety rules before starting
- Phase 1: Inventory every clone
- Phase 2: Choose the canonical repository
- Phase 3: Decide what each clone should become
- Phase 4: Convert one clone
- Moving a worktree
- Recovering a deleted .git worktree file
- When should a worktree be locked?
- When should git worktree prune be used?
- Final validation
- Quick reference
- Closing thoughts
Have you ever ended up with a directory structure like this?
~/src/project
~/src2/project
~/src3/project
~/src4/project
Enter fullscreen mode Exit fullscreen mode
Each directory started innocently enough.
One was for main. Another was for a feature branch. A third contained a half-finished experiment. The fourth had several untracked test files you were afraid to lose.
Eventually, each clone had its own:
- stale view of the remote repository,
- duplicated Git history,
- local-only commits,
- modified files,
- ignored test artifacts,
- and unknown relationship to the others.
Git worktrees are designed to solve this problem.
A worktree gives you multiple checked-out working directories backed by one shared Git repository. Each working directory can have its own branch and uncommitted changes, while commits, branches, tags, remotes, and fetched objects remain shared.
This article covers two things:
- How to use Git worktrees during normal development.
- How to safely consolidate several independent clones into one worktree-based layout without losing local work.
The shell examples are written to work in both Bash and zsh.
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
Enter fullscreen mode Exit fullscreen mode
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"
Enter fullscreen mode Exit fullscreen mode
The commit and updated branch are immediately visible from the main worktree:
git -C "$HOME/src/project" log FEATURE-123 -1
Enter fullscreen mode Exit fullscreen mode
There is no local push-and-fetch cycle between directories.
One fetch updates every worktree
Run:
git -C "$HOME/src/project" fetch origin --prune
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Example:
/home/user/src/project 81f9d2a [main]
/home/user/src/project-FEATURE-123 f27ae91 [FEATURE-123]
/home/user/src/project-HOTFIX-456 6b812b0 [HOTFIX-456]
Enter fullscreen mode Exit fullscreen mode
For script-friendly output:
git worktree list --porcelain
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Examples:
~/src/project
~/src/project-FEATURE-123
~/src/project-HOTFIX-456
Enter fullscreen mode Exit fullscreen mode
Branch names containing / should usually be converted to filesystem-friendly names:
feature/new-api -> project-feature-new-api
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Add an existing local branch
git worktree add \
../project-FEATURE-123 \
FEATURE-123
Enter fullscreen mode Exit fullscreen mode
Create a new branch and worktree
git worktree add \
-b FEATURE-123 \
../project-FEATURE-123 \
main
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Then create the local branch and worktree:
git worktree add \
-b FEATURE-123 \
../project-FEATURE-123 \
origin/FEATURE-123
Enter fullscreen mode Exit fullscreen mode
Create a detached worktree for temporary testing
git worktree add \
--detach \
../project-test \
main
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Then rebase the feature worktree:
git -C ../project-FEATURE-123 rebase main
Enter fullscreen mode Exit fullscreen mode
Because the worktrees share local branches, the feature worktree immediately sees the updated main.
Remove a worktree
git worktree remove ../project-FEATURE-123
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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"
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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}"
Enter fullscreen mode Exit fullscreen mode
rather than:
"$branch:refs/heads/$branch"
Enter fullscreen mode Exit fullscreen mode
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/
Enter fullscreen mode Exit fullscreen mode
A linked worktree usually has:
.git
Enter fullscreen mode Exit fullscreen mode
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"
)
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
For each clone, record:
- directory,
- checked-out branch,
- exact
HEADcommit, - 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
Enter fullscreen mode Exit fullscreen mode
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"
Enter fullscreen mode Exit fullscreen mode
Fetch the current remote state:
git -C "$canonical" fetch origin --prune
Enter fullscreen mode Exit fullscreen mode
Inspect local and remote main:
git -C "$canonical" status --short --branch
git -C "$canonical" log \
--oneline \
--decorate \
--graph \
-10 \
main origin/main
Enter fullscreen mode Exit fullscreen mode
Check for untracked-file collisions
Before fast-forwarding, inspect incoming paths:
git -C "$canonical" diff \
--name-status \
HEAD..origin/main
Enter fullscreen mode Exit fullscreen mode
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"
Enter fullscreen mode Exit fullscreen mode
Then fast-forward only:
git -C "$canonical" merge \
--ff-only \
origin/main
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Check tracked, untracked, and ignored files:
git -C "$source_repo" status \
--short \
--ignored=matching \
--untracked-files=all
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Detached example:
git -C "$canonical" worktree add \
--detach \
"$HOME/src/project-main-test" \
main
Enter fullscreen mode Exit fullscreen mode
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}"
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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}"
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Now compare the two branch tips:
printf 'Source clone: '
git -C "$source_repo" rev-parse HEAD
printf 'Canonical: '
git -C "$canonical" rev-parse "$branch"
Enter fullscreen mode Exit fullscreen mode
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"
Enter fullscreen mode Exit fullscreen mode
Rename the old clone
echo "Backup path: $backup"
mv "$source_repo" "$backup"
Enter fullscreen mode Exit fullscreen mode
Confirm that the backup is still a valid Git repository:
git -C "$backup" rev-parse HEAD
git -C "$backup" status \
--short \
--branch \
--untracked-files=all
Enter fullscreen mode Exit fullscreen mode
Create the linked worktree
git -C "$canonical" worktree add \
"$destination" \
"$branch"
Enter fullscreen mode Exit fullscreen mode
Compare the starting commits:
printf 'Old clone: '
git -C "$backup" rev-parse HEAD
printf 'New worktree: '
git -C "$destination" rev-parse HEAD
Enter fullscreen mode Exit fullscreen mode
Again, the hashes must match.
Do not copy the old working files onto a worktree based on a different commit. The resulting diff would mix branch differences with uncommitted working-directory changes.
Restore the complete working-directory state
Use rsync to reproduce:
- modified tracked files,
- deleted tracked files,
- untracked files,
- ignored files,
- and local generated files.
rsync -a \
--delete \
--exclude='/.git' \
"$backup/" \
"$destination/"
Enter fullscreen mode Exit fullscreen mode
The anchored exclusion is critical:
--exclude='/.git'
Enter fullscreen mode Exit fullscreen mode
Do not use only:
--exclude='.git/'
Enter fullscreen mode Exit fullscreen mode
The trailing slash pattern protects a .git directory, but a linked worktree has a .git file. With --delete, the wrong exclusion can delete that file and temporarily break the worktree.
Restore upstream tracking
If the branch exists on origin, restore its upstream relationship:
if git -C "$canonical" show-ref \
--verify \
--quiet "refs/remotes/origin/${branch}"
then
git -C "$destination" branch \
--set-upstream-to="origin/${branch}" \
"$branch"
else
echo "No origin/${branch}; branch remains local-only"
fi
Enter fullscreen mode Exit fullscreen mode
Compare Git-visible state
Capture the old and new status:
git -C "$backup" \
status --porcelain=v1 --untracked-files=all \
> /tmp/project-old.status
git -C "$destination" \
status --porcelain=v1 --untracked-files=all \
> /tmp/project-new.status
Enter fullscreen mode Exit fullscreen mode
Compare them:
diff -u \
/tmp/project-old.status \
/tmp/project-new.status
Enter fullscreen mode Exit fullscreen mode
No output means Git sees the same:
- modified files,
- deleted files,
- and untracked files.
Compare the complete filesystem
Use a checksum-based dry run to include ignored files:
rsync -a \
--checksum \
--dry-run \
--delete \
--exclude='/.git' \
"$backup/" \
"$destination/"
Enter fullscreen mode Exit fullscreen mode
No output means the filesystems match, excluding Git metadata.
At this point, build and test the new worktree before deleting the backup.
Moving a worktree
Same-filesystem move
Use Git's worktree-aware move command:
git -C "$canonical" worktree move \
"$old_path" \
"$new_path"
Enter fullscreen mode Exit fullscreen mode
Cross-device move
A move between filesystems may fail with an error similar to:
Cross-device link
Enter fullscreen mode Exit fullscreen mode
This often happens when moving between:
- internal storage and an external disk,
- two mounted volumes,
- or separate filesystem partitions.
Move the directory using the operating system:
mv "$old_path" "$new_path"
Enter fullscreen mode Exit fullscreen mode
Then repair Git's recorded paths:
git -C "$canonical" worktree repair \
"$new_path"
Enter fullscreen mode Exit fullscreen mode
Verify the result:
git -C "$canonical" worktree list
git -C "$new_path" rev-parse HEAD
git -C "$new_path" status \
--short \
--branch
file "$new_path/.git"
cat "$new_path/.git"
Enter fullscreen mode Exit fullscreen mode
The .git file should contain a pointer resembling:
gitdir: /path/to/canonical/.git/worktrees/project-FEATURE-123
Enter fullscreen mode Exit fullscreen mode
Do not rely only on the messages printed by git worktree repair. The final verification commands determine whether the repair succeeded.
Recovering a deleted .git worktree file
If an rsync --delete operation removed the linked worktree's .git file, Git commands in that directory may report:
fatal: not a git repository
Enter fullscreen mode Exit fullscreen mode
Repair it from the canonical repository:
git -C "$canonical" worktree repair \
"$destination"
Enter fullscreen mode Exit fullscreen mode
Then verify:
file "$destination/.git"
cat "$destination/.git"
git -C "$destination" rev-parse HEAD
git -C "$destination" status \
--short \
--branch \
--untracked-files=all
Enter fullscreen mode Exit fullscreen mode
For future copies, use:
--exclude='/.git'
Enter fullscreen mode Exit fullscreen mode
When should a worktree be locked?
Locking is useful when:
- the canonical repository remains available,
- one linked worktree lives elsewhere,
- and that linked worktree may temporarily disappear.
Example:
~/src/project canonical repository on internal storage
/Volumes/External/project-testing linked worktree on removable storage
Enter fullscreen mode Exit fullscreen mode
Lock the removable worktree:
git worktree lock \
--reason "Stored on removable volume" \
/Volumes/External/project-testing
Enter fullscreen mode Exit fullscreen mode
This tells Git not to treat the missing worktree as an abandoned entry eligible for pruning.
Unlock it before intentionally moving or removing it:
git worktree unlock \
/Volumes/External/project-testing
Enter fullscreen mode Exit fullscreen mode
When locking is unnecessary
Suppose the canonical repository and every linked worktree are together on the same external drive:
/Volumes/External/src/project
/Volumes/External/src/project-FEATURE-123
/Volumes/External/src/project-HOTFIX-456
Enter fullscreen mode Exit fullscreen mode
When the drive is disconnected, the entire repository is unavailable. Git cannot run maintenance against it.
Locking every worktree does not provide meaningful additional protection in that arrangement.
Reconnect the drive at the same mount path and continue working normally.
When should git worktree prune be used?
Git stores administrative metadata for each linked worktree in the canonical repository.
If a worktree directory is removed outside Git, its administrative record may remain behind.
Examples:
- Someone ran
rm -rfon a worktree directory. - A temporary build worktree was deleted manually.
- A storage volume was permanently retired.
- A cross-device migration left an obsolete worktree record.
Preview stale records first:
git worktree prune \
--dry-run \
--verbose
Enter fullscreen mode Exit fullscreen mode
Only after confirming that every listed worktree is permanently gone:
git worktree prune
Enter fullscreen mode Exit fullscreen mode
prune removes obsolete administrative records. It does not delete an existing, accessible worktree directory.
Do not prune simply because a removable drive is temporarily disconnected.
Instead:
- reconnect the drive, or
- lock the removable linked worktree if the canonical repository remains available elsewhere.
Final validation
List the registered worktrees:
git -C "$canonical" worktree list
Enter fullscreen mode Exit fullscreen mode
Check each one:
for worktree in \
"$HOME/src/project" \
"$HOME/src/project-FEATURE-123" \
"$HOME/src/project-HOTFIX-456"
do
echo
echo "===== $worktree ====="
git -C "$worktree" status \
--short \
--branch \
--untracked-files=all
done
Enter fullscreen mode Exit fullscreen mode
Inspect the shared and per-worktree metadata:
git -C "$destination" rev-parse \
--show-toplevel
git -C "$destination" rev-parse \
--git-common-dir
git -C "$destination" rev-parse \
--git-dir
Enter fullscreen mode Exit fullscreen mode
Build and test every converted worktree.
Only then remove its backup:
rm -rf "$backup"
Enter fullscreen mode Exit fullscreen mode
Check the path carefully before running that command.
Quick reference
# List worktrees
git worktree list
# Machine-readable list
git worktree list --porcelain
# Add an existing local branch
git worktree add ../project-FEATURE FEATURE
# Create a new branch and worktree
git worktree add \
-b FEATURE \
../project-FEATURE \
main
# Create a detached testing worktree
git worktree add \
--detach \
../project-test \
main
# Move a worktree on the same filesystem
git worktree move ../old-location ../new-location
# Repair after a manual or cross-device move
git worktree repair ../new-location
# Lock one removable worktree while the canonical repo remains available
git worktree lock \
--reason "Removable storage" \
../project-FEATURE
# Unlock before intentionally moving or removing it
git worktree unlock ../project-FEATURE
# Remove a registered worktree
git worktree remove ../project-FEATURE
# Preview obsolete worktree records
git worktree prune --dry-run --verbose
# Remove confirmed-obsolete records
git worktree prune
Enter fullscreen mode Exit fullscreen mode
Closing thoughts
Git worktrees are not an exotic Git feature reserved for unusual workflows.
They are useful whenever you need to:
- maintain several active branches,
- compare old and new releases,
- run concurrent builds,
- preserve a long-running experiment,
- review another branch without disturbing current work,
- or replace a growing collection of independent clones.
The safest migration strategy is intentionally conservative:
- preserve the source clone,
- import its exact commit,
- create the worktree at that commit,
- copy the working-directory state,
- compare everything,
- test it,
- and only then remove the backup.
Once the migration is complete, everyday work becomes simpler:
- one fetch,
- one repository history,
- one set of remotes,
- and as many independent working directories as you actually need.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.