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:
- Have a base worktree with Lodash.
- Create a separate worktree for the migration.
- Re-enact the process where we need to need to add a new feature on the base tree.
- 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:
- Separate working directories: Each worktree has its own working directory, allowing you to work on different branches simultaneously without switching.
- Shared repository: All worktrees share the same Git repository (objects and refs).
- Independent HEAD: Each worktree has its own HEAD, index, and config file.
- 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.
Further links and resources
Photo credit: roadtripwithraj
Exploring Git Worktree
Introduction