Notes to Self

Alex Sokolsky's Notes on Computers and Programming

Git Worktree Workflow

Git worktrees let one repo have multiple checked-out branches in separate directories. Developers use them to isolate concurrent issues, merge requests, reviews, and urgent fixes without repeatedly switching or stashing the main worktree.

Terminology

This doc uses Git’s worktree terminology:

Benefits

When To Use A Worktree

Use a linked worktree for:

A linked worktree is optional for a brief read-only inspection when the main worktree is clean and no branch change is needed.

Directory Layout

Use the projects root appropriate to the workstation:

OS Example Projects Root
macOS /Users/{user}/Projects
Linux /home/{user}/Projects

Keep main and linked worktrees separate beneath a common projects root:

{projects-root}/{repo}
{projects-root}/.worktrees/{repo}/{work-id}-{short-slug}

Reasons for the choice of location and naming of the .worktrees directory:

The .worktrees name keeps this operational directory out of normal directory listings while remaining explicit when requested. It is not a Git requirement: another organization-wide location is valid when used consistently and kept outside main worktrees.

For example, a developer working in the my-repo would have this:

{projects-root}/
├── my-repo/                           # Main worktree on main
└── .worktrees/
    └── my-repo/
        ├── issue-963-foo-bar/         # Issue implementation
        ├── mr-236-bar-baz/            # Exact-head MR review
        └── fix-connection-timeout/    # Independent urgent fix

Do not create a linked worktree inside the main worktree.

Naming

Use lowercase kebab-case and short descriptive slugs.

Work Type Directory Pattern Example
Merge request mr-{iid}-{slug} mr-161-foo-bar
Issue issue-{iid}-{slug} issue-883-immutable-delivery
Feature feat-{slug} feat-authenticated-smoke
Fix fix-{slug} fix-race-condition
Documentation docs-{slug} docs-deployment-runbook
Experiment experiment-{slug} experiment-cache-policy

Repo branch naming rules still apply. The branch and worktree directory do not need identical names.

Create Or Reuse A Worktree

Inspect existing worktrees before creating one:

git worktree list

Reuse an existing worktree for the same task. Otherwise, refresh the default branch and create an isolated task branch:

git fetch origin main

git worktree add \
  -b {branch-name} \
  {projects-root}/.worktrees/{repo}/{work-id}-{slug} \
  origin/main

Follow repo-specific instructions when the default branch is not main or the task has a prescribed base branch.

Review A Merge Request At Its Exact Head

Fetch the merge request head and create a detached worktree:

git fetch origin \
  +refs/merge-requests/{iid}/head:refs/remotes/origin/mr-{iid}

git worktree add \
  --detach \
  {projects-root}/.worktrees/{repo}/mr-{iid}-{slug} \
  refs/remotes/origin/mr-{iid}

Verify that the checked-out commit matches the merge request’s current head before drawing conclusions.

Work Within The Linked Worktree

Decide Whether Cleanup Is Safe

Do not remove a worktree until:

  1. Its exact path and associated branch are known.
  2. Its superproject and initialized submodules contain no modified, staged, or untracked files.
  3. Its commits are merged, squash-merged, preserved remotely, or explicitly approved for discard.
  4. Associated merge request, pipeline, deployment, or review work has reached the required outcome.

A clean status alone does not make cleanup safe. A clean branch can still contain commits that exist nowhere else.

Inspect the superproject without honoring submodule ignore configuration:

git -C /absolute/worktree/path status \
  --porcelain=v1 \
  --untracked-files=all \
  --ignore-submodules=none

Stop if this check produces dirty-state output or exits unsuccessfully.

Squash merges need special care because the original branch commits are not ancestors of the squash commit. Confirm the live merge request head and recorded squash commit rather than relying on a single git cherry result.

Verify A Normal Merge

Refresh the target and test ancestry:

git fetch origin main
git merge-base --is-ancestor {branch-commit} origin/main

Exit status 0 confirms that the commit is an ancestor of origin/main. A nonzero result requires further investigation.

Verify A Squash Merge

Verify all of the following:

  1. The live merge request state is merged.

  2. GitLab reports the expected source sha and a non-null squash_commit_sha.

  3. The local branch tip equals the recorded merge request head SHA. Preserve or review any later local commits separately.

  4. The squash commit is reachable from the refreshed target:

    git fetch origin main
    git merge-base --is-ancestor {squash-commit-sha} origin/main
    
  5. The aggregate merge request change matches the squash commit change:

    git diff --binary {mr-base-sha}..{mr-head-sha} |
      git patch-id --stable
    
    git diff --binary {squash-commit-sha}^..{squash-commit-sha} |
      git patch-id --stable
    

Matching aggregate patch IDs provide strong evidence that the squash commit preserves the merge request change. When IDs differ because of conflict resolution or target-branch adjustments, inspect both aggregate patches and account for every difference before deletion.

git cherry origin/main {branch-name} is supplementary evidence only:

Remove A Completed Worktree

Preferred: use cleanup-worktrees.sh.

Manual steps: remove the worktree through Git, then delete its local branch and verify the remaining worktrees:

git worktree remove /absolute/worktree/path
git branch -d {branch-name}
git worktree list

Use git branch -D only after independently verifying that squash-merged or otherwise non-ancestor work is preserved, or after explicit approval to discard it.

Do not substitute recursive filesystem deletion for git worktree remove.

Protect Submodule Work

In addition to the superproject audit under Decide Whether Cleanup Is Safe, inspect every initialized submodule recursively from inside that submodule before forced submodule deinitialization or worktree removal:

git -C /absolute/worktree/path submodule foreach --recursive '
  dirty="$(git status \
    --porcelain=v1 \
    --untracked-files=all \
    --ignore-submodules=none)" || exit $?
  if test -n "$dirty"; then
    printf "Dirty submodule: %s\n%s\n" "$displaypath" "$dirty" >&2
    exit 1
  fi
'

Stop if either check produces dirty-state output or exits unsuccessfully. Ordinary superproject status may hide submodule changes through .gitmodules, local Git configuration, or command-line ignore settings.

If normal removal reports that the worktree contains submodules, deinitialize them only after both audits pass:

git -C /absolute/worktree/path submodule deinit -f --all
git worktree remove /absolute/worktree/path

If the current Git version still refuses removal solely because submodule metadata exists, forced removal is permitted only after the recursive clean-state and branch-preservation checks:

git worktree remove --force /absolute/worktree/path

Keep Remote Cleanup Separate

Removing a local worktree and branch does not authorize deletion of its remote branch. Delete remote branches only when the merge request workflow or repo owner calls for it.

Troubleshooting

The Worktree Already Exists

Use git worktree list to find and reuse the existing linked worktree. Do not create a duplicate directory or parallel branch for the same task.

Git Reports That The Branch Is Already Checked Out

Each branch can be checked out in only one worktree. Use the path reported by git worktree list, or choose the correct different branch for the new task.

Git Refuses To Remove A Worktree With Submodules

First verify the superproject and all initialized submodules recursively. After confirming that the worktree is clean and its branch is preserved, deinitialize its submodules and retry worktree removal. Some Git versions require forced worktree removal after this deinitialization.

Git Refuses To Delete A Squash-Merged Branch

This is expected when the original commits are not ancestors of the squash commit. Verify the live merged merge request, its exact head, the recorded squash commit, and the aggregate change before force-deleting the local branch.