Table Of Contents

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:

  1. How to use Git worktrees during normal development.
  2. 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:

  1. Inventory every clone.
  2. Choose one canonical repository.
  3. Update the canonical repository carefully.
  4. Convert one clone at a time.
  5. Import the source clone's exact branch and commit.
  6. Rename the source clone to a backup.
  7. Create a linked worktree.
  8. Copy the complete working-directory state.
  9. Compare the old clone and new worktree.
  10. 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 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

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:

  1. the canonical repository remains available,
  2. one linked worktree lives elsewhere,
  3. 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 -rf on 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.