Auto-copy files when creating Git worktrees or Jujutsu workspaces
Worktrees and workspaces are one of the best ways to work across multiple branches simultaneously — and they’re especially powerful when pairing with AI coding tools like Claude Code or Cursor, where you might spin up a fresh workspace per task or conversation to keep things isolated and clean.
The friction point: ignored files don’t carry over. Your .env, .env.local, API keys, local config — none of it comes with you. Every new worktree means manually hunting down and copying those files before you can actually get to work.
The solution: your .gitignore is already the list
The files that don’t carry over are, by definition, the ones in your .gitignore. So instead of maintaining a separate config file, ask Git directly which ignored files exist in the main worktree:
git ls-files --others --ignored --exclude-standard --directory
This lists every untracked file matched by your ignore rules (.gitignore, .git/info/exclude, global excludes). The --directory flag collapses fully-ignored directories like node_modules/ into a single entry, so a cp -r copies them in one go instead of file-by-file.
No extra config to commit, and the list stays correct as your .gitignore evolves.
Setup
Git — automatic via hook
This approach runs transparently every time git worktree add is used, with no change to your normal workflow.
Create .git/hooks/post-checkout with the following content, then make it executable with chmod +x .git/hooks/post-checkout:
#!/bin/bash
MAIN_WORKTREE=$(git worktree list | head -1 | awk '{print $1}')
# Only act in linked worktrees, not on regular checkouts in the main one
[[ "$(git rev-parse --show-toplevel)" == "$MAIN_WORKTREE" ]] && exit 0
git -C "$MAIN_WORKTREE" ls-files --others --ignored --exclude-standard --directory -z |
while IFS= read -r -d '' f; do
if [[ -e "$MAIN_WORKTREE/$f" && ! -e "$f" ]]; then
mkdir -p "$(dirname "$f")"
cp -r "$MAIN_WORKTREE/$f" "$f"
echo "Copied $f"
fi
done
Note: Git hooks aren’t shared via the repo, so each contributor needs to add this themselves — or you can use a hook manager like Lefthook or Husky to distribute them.
Git — shell wrapper command
If you prefer an explicit command (or want something more portable across machines without setting up hooks), add a gwt function to your .bashrc or .zshrc:
gwt() {
git worktree add "$@" || return
local dest="$1"
local main=$(git worktree list | head -1 | awk '{print $1}')
git -C "$main" ls-files --others --ignored --exclude-standard --directory -z |
while IFS= read -r -d '' f; do
if [[ -e "$main/$f" && ! -e "$dest/$f" ]]; then
mkdir -p "$dest/$(dirname "$f")"
cp -r "$main/$f" "$dest/$f"
echo "Copied $f"
fi
done
}
Usage is identical to git worktree add, with one rule: the worktree path comes first (Git happily accepts options after it):
gwt ../my-project-feature feature-branch
gwt ../my-project-feature -b new-branch
For Fish shell, add to ~/.config/fish/functions/gwt.fish:
View Fish code
function gwt
git worktree add $argv; or return
set dest $argv[1]
set main (git worktree list | head -1 | awk '{print $1}')
git -C $main ls-files --others --ignored --exclude-standard --directory -z | while read -lz f
if test -e "$main/$f"; and not test -e "$dest/$f"
mkdir -p "$dest/"(dirname $f)
cp -r "$main/$f" "$dest/$f"
echo "Copied $f"
end
end
end
Jujutsu — shell wrapper command
Jujutsu workspaces share the same underlying store, so there’s no hook equivalent. Since Jujutsu reads .gitignore files too, the same Git command enumerates the ignored files — this assumes a colocated repository (created with jj git init --colocate or jj git clone --colocate), which is the common setup.
Add a jjws function to your .bashrc or .zshrc, and run it from your main workspace:
jjws() {
jj workspace add "$@" || return
local dest="$1"
local main=$(jj root)
git -C "$main" ls-files --others --ignored --exclude-standard --directory -z -- ':(exclude).jj' |
while IFS= read -r -d '' f; do
if [[ -e "$main/$f" && ! -e "$dest/$f" ]]; then
mkdir -p "$dest/$(dirname "$f")"
cp -r "$main/$f" "$dest/$f"
echo "Copied $f"
fi
done
}
The ':(exclude).jj' pathspec matters: in a colocated repo, Git sees Jujutsu’s internal .jj/ directory as ignored, and copying it into the new workspace would corrupt it.
Usage mirrors jj workspace add, with the destination path first:
jjws ../my-project-feature
For Fish shell, add to ~/.config/fish/functions/jjws.fish:
View Fish code
function jjws
jj workspace add $argv; or return
set dest $argv[1]
set main (jj root)
git -C $main ls-files --others --ignored --exclude-standard --directory -z -- ':(exclude).jj' | while read -lz f
if test -e "$main/$f"; and not test -e "$dest/$f"
mkdir -p "$dest/"(dirname $f)
cp -r "$main/$f" "$dest/$f"
echo "Copied $f"
end
end
end
Tips
Install with AI — Ask your AI agent to install this post to your repository or to your shell.
Skip the heavy stuff — your ignore list probably includes things you’d rather rebuild than copy, like node_modules/ or build output. Filter them out of the pipeline:
git -C "$main" ls-files --others --ignored --exclude-standard --directory -z |
grep -zEv '^(node_modules|dist|target)/'
(Or leave node_modules/ in — copying it is often faster than reinstalling.)
Symlink instead of copy — if the file should stay in sync across all worktrees (e.g. a shared .env with no per-branch variation), symlink it instead of copying:
ln -s "$main/$f" "$dest/$f"
Just swap the cp for ln -s in whichever script you’re using.
Don’t copy if the file already exists — all examples above skip copying if the destination file is already present, so re-running is safe and won’t clobber local overrides.
Who am I?
Hello! My name is Cretezy and I am a software developer. I write about programming, personal projects, and more.
See more posts here.