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
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
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:
/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
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
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"
Rename the old clone
echo "Backup path: $backup"
mv "$source_repo" "$backup"
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
Create the linked worktree
git -C "$canonical" worktree add \
"$destination" \
"$branch"
Compare the starting commits:
printf 'Old clone: '
git -C "$backup" rev-parse HEAD
printf 'New worktree: '
git -C "$destination" rev-parse HEAD
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/"
The anchored exclusion is critical:
--exclude='/.git'
Do not use only:
--exclude='.git/'
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
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
Compare them:
diff -u \
/tmp/project-old.status \
/tmp/project-new.status
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/"
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"
Cross-device move
A move between filesystems may fail with an error similar to:
Cross-device link
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"
Then repair Git's recorded paths:
git -C "$canonical" worktree repair \
"$new_path"
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"
The .git file should contain a pointer resembling:
gitdir: /path/to/canonical/.git/worktrees/project-FEATURE-123
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
Repair it from the canonical repository:
git -C "$canonical" worktree repair \
"$destination"
Then verify:
file "$destination/.git"
cat "$destination/.git"
git -C "$destination" rev-parse HEAD
git -C "$destination" status \
--short \
--branch \
--untracked-files=all
For future copies, use:
--exclude='/.git'
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
Lock the removable worktree:
git worktree lock \
--reason "Stored on removable volume" \
/Volumes/External/project-testing
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
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
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
Only after confirming that every listed worktree is permanently gone:
git worktree prune
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
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
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
Build and test every converted worktree.
Only then remove its backup:
rm -rf "$backup"
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
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.
Top comments (0)