Back to home

Exploring Git Worktree

Published: Aug 14, 2024

Last updated: Aug 14, 2024

Git worktree is a feature that allows you to have multiple working directories associated with a single Git repository. This means you can check out different branches or commits into separate directories on your filesystem, all linked to the same repository.

This demo walks through using Git worktree with an example using different npm dependencies to demonstrate a use case for multiple worktrees.

Our demonstration will emulate (albeit contrived and small) a "major" change within the repository. We're going to dumb it down to something contrived like migrating from Lodash to es-toolkit, but you'll need to use your imagination and pretend like our work may take something like a week or so to complete, and that you may need to ship other features while doing the migration.

It really doesn't matter which language you're working with, but I'm using JavaScript to demonstrate here because you have likely found yourself jump between trees and needing to re-install different dependencies at some stage as you switch back-and-forth.

The process will look like this:

  1. Have a base worktree with Lodash.
  2. Create a separate worktree for the migration.
  3. Re-enact the process where we need to need to add a new feature on the base tree.
  4. Continue on with the migration.

Getting started

# Configure git, initialise the project $ mkdir demo-git-worktree $ cd demo-git-worktree $ npm init -y $ npm install lodash $ touch index.js README.md $ git init $ echo node_modules > .gitignore

In index.js, add the following to represent our large repository code:

const _ = require("lodash"); function main() { console.log(_.camelCase("Our crazy big home project")); } main();

You can now run this code to see what the project is doing:

$ node index.js ourCrazyBigHomeProject

Let's commit what's we've got:

# Add our commits $ git add --all $ git commit -m "feat: first commit" [main (root-commit) 3fd5292] feat: first commit 5 files changed, 86 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 index.js create mode 100644 package-lock.json create mode 100644 package.json

Perfect! At this stage, you git log should show something like this:

# Show git log $ git log commit 3fd5292b0a49ed8a5a6c4a5e2b9ee7394453e290 (HEAD -> main) Author: Dennis O'Keeffe <hello@dennisokeeffe.com> Date: Wed Aug 14 08:20:12 2024 +1000 feat: first commit

Now let's emulate our project to migrate over.

Using Git worktree

We're creating a new worktree for this.

Run the following:

# List our worktree $ git worktree list /path/to/okeeffed/demo-git-worktree 3fd5292 [main] # Create a new branch for our migration $ git branch feat/estoolkit-migration # Create a new worktree $ git worktree add ../demo-git-worktree-estoolkit-migration feat/estoolkit-migration Preparing worktree (checking out 'feat/estoolkit-migration') HEAD is now at 3fd5292 feat: first commit # List our trees again $ git worktree list git worktree list /path/to/okeeffed/demo-git-worktree 3fd5292 [main] /path/to/okeeffed/demo-git-worktree-estoolkit-migration 3fd5292 [feat/estoolkit-migration]

Great! At this point, we now have another directory at ../demo-git-worktree-estoolkit-migration which represents our estoolkit-migration worktree. It is checked out onto feat/estoolkit-migration.

Git operates differently between the main worktree and a linked worktree. It's best to reference the docs, but here are some quick pointers.

The main differences in created linked work trees are:

  1. Separate working directories: Each worktree has its own working directory, allowing you to work on different branches simultaneously without switching.
  2. Shared repository: All worktrees share the same Git repository (objects and refs).
  3. Independent HEAD: Each worktree has its own HEAD, index, and config file.
  4. Branch isolation: You can't check out the same branch in multiple worktrees simultaneously (except for bare repositories).

At this point, we can work separately between the two folders for our changes.

Making our updates for the toolkit migration

Heading into ../demo-git-worktree-estoolkit-migration and make the following changes:

# Change into our linked worktree directory $ cd ../demo-git-worktree-estoolkit-migration # Add es-toolkit and remove Lodash $ npm install es-toolkit # Remove Lodash $ npm uninstall lodash

Now let's update index.js:

const { camelCase } = require("es-toolkit"); function main() { console.log(camelCase("Our crazy big home project")); } main();

If we run node index.js again, we'll notice that nothing has changed:

$ node index.js ourCrazyBigHomeProject

Great! Let's commit.

# Our commit $ git add --all $ git commit -m "feat: migrated lodash camelCase"

Going back for our "new feature"

Let's say now that we need to pivot midway through our migration for something else.

Back in the main worktree, let's go through a flow of adding another feature. Again, this will be contrived, but work with me.

# Back to our main worktree $ cd ../demo-git-worktree

Even though Lodash is used in the repo, notice that if we run index.js, everything still works even though we remove that dependency in the linked worktree:

# Running on our main worktree without re-installations $ node index.js ourCrazyBigHomeProject

Great! Let's branch off, create a new feature and then merge back in.

# Create a new branch $ git branch feat/snakecase $ git checkout feat/snakecase

Let's update our index.js file:

const _ = require("lodash"); function main() { console.log(_.camelCase("Our crazy big home project")); console.log(_.snakeCase("Our crazy big feature update")); } main();

Running this will log out the following:

$ node index.js ourCrazyBigHomeProject our_crazy_big_feature_update

Let's commit and merge to main:

# Commit $ git add index.js $ git commit -m "feat: added snakecase" [feat/snakecase ab1e6d5] feat: added snakecase 2 files changed, 173 insertions(+), 1 deletion(-) # Head back to the target branch $ git checkout main # Merge into main $ git merge feat/snakecase Updating 3fd5292..37ed0e0 Fast-forward index.js | 1 + 2 files changed, 174 insertions(+), 1 deletion(-)

Normally in the work setting we would have our pull request made for the merge.

At this point, our target main branch has also differed from our worktree. Let's fix that.

Rebasing from another worktree

We can rebase from our worktree just like we normally would.

Let's head to our worktree and rebase:

# Go back to the linked tree $ cd ../demo-git-worktree-estoolkit-migration # Run the rebase $ git rebase main Auto-merging index.js CONFLICT (content): Merge conflict in index.js error: could not apply 8325647... feat: migrated lodash camelCase hint: Resolve all conflicts manually, mark them as resolved with hint: "git add/rm <conflicted_files>", then run "git rebase --continue". hint: You can instead skip this commit: run "git rebase --skip". hint: To abort and get back to the state before "git rebase", run "git rebase --abort". hint: Disable this message with "git config advice.mergeConflict false" Could not apply 8325647... feat: migrated lodash camelCase

At this point, we have our conflict that we expected from the change.

Let's fix our conflict by using snakeCase from es-toolkit as well. Our code should look like this:

const { camelCase, snakeCase } = require("es-toolkit"); function main() { console.log(camelCase("Our crazy big home project")); console.log(snakeCase("Our crazy big feature update")); } main();

Again, we can check it works without the need to re-install anything:

# Running the changes $ node index.js ourCrazyBigHomeProject our_crazy_big_feature_update

Perfect! Let's finish the rebase by continuing.

# Continue the rebase $ git add index.js $ git rebase --continue

Let's bring this project to a close after merging a our linked tree code to our main tree.

Merging back to main

Head back to the main tree and merge in the branch.

# Make sure we commit from within our merge tree $ cd ../demo-git-worktree $ git merge feat/estoolkit-migration Merge made by the 'ort' strategy. index.js | 6 +++--- package-lock.json | 10 +++++----- package.json | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-)

Again, we merged locally but you'd normally have a pull request for this.

At this point, you would notice that if you tried to run index.js, then finally we would run into an issue:

# Demoing the issue $ node index.js node:internal/modules/cjs/loader:1147 throw err; ^ Error: Cannot find module 'es-toolkit' Require stack: - /path/to/okeeffed/okeeffed/demo-git-worktree/index.js at Module._resolveFilename (node:internal/modules/cjs/loader:1144:15) at Module._load (node:internal/modules/cjs/loader:985:27) at Module.require (node:internal/modules/cjs/loader:1235:19) at require (node:internal/modules/helpers:176:18) at Object.<anonymous> (/path/to/okeeffed/okeeffed/demo-git-worktree/index.js:1:34) at Module._compile (node:internal/modules/cjs/loader:1376:14) at Module._extensions..js (node:internal/modules/cjs/loader:1435:10) at Module.load (node:internal/modules/cjs/loader:1207:32) at Module._load (node:internal/modules/cjs/loader:1023:12) at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:135:12) { code: 'MODULE_NOT_FOUND', requireStack: [ '/path/to/okeeffed/demo-git-worktree/index.js' ] } Node.js v20.10.0

Naturally, both of our worktrees maintained a different node_modules folder. That's how we were able to jump between both seamlessly.

All we need to do is update our main worktree to install the latest package.json configuration.

# Install current configuration $ npm install added 1 package, removed 1 package, and audited 2 packages in 1s found 0 vulnerabilities # Attempt run again $ node index.js ourCrazyBigHomeProject our_crazy_big_feature_update

Happy days! All we have left to do is cleanup.

Cleaning up our linked tree

We can do so with the following:

# Remove the worktree $ git worktree remove ../demo-git-worktree-estoolkit-migration $ git worktree list /Users/dennis.okeeffe/code/okeeffed/demo-git-worktree 8a9f3a1 [main]

At this point, our linked tree has been cleaned up.

If you run git branch --list, you'll notice that although the worktree is gone, our branch remains.

# List our branches $ git branch --list feat/estoolkit-migration feat/snakecase * main

Given the rules of worktrees, now that our other worktree is gone, we're free to checkout feat/estoolkit-migration from our main worktree now.

Photo credit: roadtripwithraj

Personal image

Dennis O'Keeffe

@dennisokeeffe92
  • Melbourne, Australia

Hi, I am a professional Software Engineer. Formerly of Culture Amp, UsabilityHub, Present Company and NightGuru.
I am currently working on Visibuild.

1,200+ PEOPLE ALREADY JOINED ❤️️

Get fresh posts + news direct to your inbox.

No spam. We only send you relevant content.

Exploring Git Worktree

Introduction

Share this post