Skip to content

Instantly share code, notes, and snippets.

@timrichardson
Last active September 24, 2025 02:51
Show Gist options
  • Save timrichardson/0b275fe10e5303b11f65285db9189d90 to your computer and use it in GitHub Desktop.
Save timrichardson/0b275fe10e5303b11f65285db9189d90 to your computer and use it in GitHub Desktop.
Create sibling worktree and unlock git-crypt
#!/usr/bin/env bash
set -euo pipefail
# Source (GitHub Gist)
# GIST_ID: 0b275fe10e5303b11f65285db9189d90
# URL: https://gist.github.com/timrichardson/0b275fe10e5303b11f65285db9189d90
#
# To update the gist from this local file (requires GitHub CLI ‘gh’):
# gh gist edit 0b275fe10e5303b11f65285db9189d90 -f make_worktree.sh -a "$(realpath "$0")"
#
# Usage: ./make_worktree.sh <branch-name>
# - Auto-detects the project root (git top-level), even when invoked via a symlink on PATH.
# - Creates a sibling directory: <project-parent>/worktrees/<branch-name>
# - Works seamlessly with git-crypt (see below).
#
# Git-crypt support and why this script “just works”:
# - Problem: If .gitattributes defines git-crypt clean/smudge filters, normal checkouts and even
# git-crypt unlock can fail before the repo is unlocked (clean filter 'git-crypt' failed).
# - Solution implemented here:
# 1) Bootstrap materialization without filters:
# git -c filter.git-crypt.smudge= -c filter.git-crypt.clean= -c filter.git-crypt.required=false reset --hard
# This populates the worktree without invoking git-crypt filters, avoiding early failures.
# 2) Unlock with filters disabled:
# The helper with_git_attrs_disabled() runs git-crypt unlock with system/repo attributes disabled
# (GIT_ATTR_NOSYSTEM=1, GIT_ATTRIBUTES_FILE=/dev/null) and filter overrides set to no-op.
# This prevents any clean/smudge from firing during unlock (including any internal Git calls).
# 3) Re-populate with filters enabled:
# A final git reset --hard decrypts files via smudge filters now that keys are available.
# - Net effect: A robust, repeatable flow that avoids “clean filter failed” errors and yields a fully
# decrypted worktree ready for use.
#
# Prerequisites for git-crypt:
# - Exported git-crypt key file available on disk (configure GITCRYPT_KEY below), OR GPG set up for
# standard git-crypt unlock fallback.
#
# Troubleshooting tips:
# - If unlock fails and the worktree shows partial checkout, you can reset and retry:
# cd "<worktree_path>" && git reset --hard && git clean -fd && git-crypt unlock
# - Verify the exported key path and permissions:
# ls -l "$GITCRYPT_KEY"
# - Ensure gh (GitHub CLI) is authenticated if you use the gist update hint above:
# gh auth status
# - Run with set -x temporarily if you need to debug:
# bash -x ./make_worktree.sh <branch-name>
BRANCH="${1:-}"
if [[ -z "$BRANCH" ]]; then
echo "Usage: $0 <branch-name>"
exit 1
fi
# Auto-detect the project root (Git repo top-level), even when invoked via a symlink on $PATH
# 1) Resolve the script's real directory (follows symlinks)
# 2) Ask Git for the top-level from that directory
# 3) Fallback: if user runs the script from inside the repo, use the CWD
SCRIPT_PATH="$(readlink -f "$0" 2>/dev/null || python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$0")"
SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
if git -C "$SCRIPT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
PROJECT_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
elif git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
else
echo "Error: Could not detect a Git repository."
echo "Run this script from within the repo, or ensure the script itself resides inside the repo."
exit 1
fi
echo "Detected project root: $PROJECT_ROOT"
# Place worktrees as a sibling of the detected project root (same parent directory)
PARENT_DIR="$(dirname "$PROJECT_ROOT")"
WORKTREES_DIR="$PARENT_DIR/worktrees"
WORKTREE_PATH="$WORKTREES_DIR/$BRANCH"
# Path to the exported git-crypt key file (must be created via `git-crypt export-key`)
GITCRYPT_KEY="/home/tim/git-crypt-key_20230324"
# Ensure project root exists (git rev-parse above already proved it's a repo)
if [[ ! -d "$PROJECT_ROOT" ]]; then
echo "Error: Project root directory not found: $PROJECT_ROOT"
exit 1
fi
mkdir -p "$WORKTREES_DIR"
cd "$PROJECT_ROOT"
# Determine if branch exists and/or is already checked out in another worktree
if git show-ref --verify --quiet "refs/heads/$BRANCH"; then
BRANCH_EXISTS=1
else
BRANCH_EXISTS=0
fi
if git worktree list --porcelain | awk '/^branch /{print $2}' | grep -qx "refs/heads/$BRANCH"; then
BRANCH_IN_USE=1
else
BRANCH_IN_USE=0
fi
# Helper to add the worktree, choosing flags based on branch existence/in-use
add_worktree() {
local add_flags=("--no-checkout")
if [[ $BRANCH_IN_USE -eq 1 ]]; then
# Allow same branch in multiple worktrees if needed
add_flags+=("--force")
fi
if [[ $BRANCH_EXISTS -eq 1 ]]; then
git worktree add "${add_flags[@]}" "$WORKTREE_PATH" "$BRANCH"
else
git worktree add "${add_flags[@]}" -b "$BRANCH" "$WORKTREE_PATH"
fi
}
# Create/attach the worktree
if [[ ! -d "$WORKTREE_PATH" ]]; then
echo "Creating worktree directory: $WORKTREE_PATH"
mkdir -p "$WORKTREE_PATH"
echo "Adding git worktree for branch: $BRANCH (no checkout yet)"
add_worktree
else
echo "Worktree directory already exists: $WORKTREE_PATH"
# If already a git worktree, skip re-adding
if [[ -d "$WORKTREE_PATH/.git" || -f "$WORKTREE_PATH/.git" ]]; then
echo "Existing directory appears to be a git worktree; skipping add."
else
# Not a git dir. Only attach if directory is empty; otherwise, abort.
if [[ -z "$(ls -A "$WORKTREE_PATH")" ]]; then
echo "Empty existing directory detected. Adding worktree for branch: $BRANCH (no checkout yet)"
add_worktree
else
echo "Error: '$WORKTREE_PATH' exists and is not a git directory and not empty."
echo "Please move or remove this directory, or choose a different branch name."
exit 1
fi
fi
fi
# Unlock git-crypt in the worktree
if [[ ! -f "$GITCRYPT_KEY" ]]; then
echo "Error: git-crypt key not found at $GITCRYPT_KEY"
exit 1
fi
echo "Unlocking git-crypt in worktree..."
pushd "$WORKTREE_PATH" >/dev/null
# Helper: run a command with all Git attributes/filters disabled
# This prevents git-crypt clean/smudge filters from firing during unlock/status.
# Scoped to the single command invocation; does not persist any config changes.
with_git_attrs_disabled() {
GIT_ATTR_NOSYSTEM=1 \
GIT_ATTRIBUTES_FILE=/dev/null \
GIT_CONFIG_COUNT=3 \
GIT_CONFIG_KEY_0=filter.git-crypt.required \
GIT_CONFIG_VALUE_0=false \
GIT_CONFIG_KEY_1=filter.git-crypt.clean \
GIT_CONFIG_VALUE_1= \
GIT_CONFIG_KEY_2=filter.git-crypt.smudge \
GIT_CONFIG_VALUE_2= \
"$@"
}
# Step 1: Materialize files WITHOUT applying git-crypt filters.
# Rationale: after --no-checkout, the worktree appears as mass 'D' (deleted).
# Doing a reset with filters disabled populates files so the tree is clean and stable.
echo "Populating worktree without git-crypt filters (temporary bootstrap)..."
git -c filter.git-crypt.smudge= -c filter.git-crypt.clean= -c filter.git-crypt.required=false reset --hard
# Step 2: Try using the exported-key file first; fallback to standard unlock if needed
KEY_TO_USE="$GITCRYPT_KEY"
if [[ ! -r "$KEY_TO_USE" || ! -s "$KEY_TO_USE" ]]; then
echo "Error: git-crypt key file is not readable or empty: $KEY_TO_USE"
echo "Please verify the exported key path and permissions."
popd >/dev/null
exit 1
fi
# Attempt unlock with the exported key with attributes/filters disabled (show stderr on failure)
if with_git_attrs_disabled git-crypt unlock "$KEY_TO_USE"; then
echo "git-crypt unlocked using key file."
else
echo "git-crypt key unlock via file failed; trying standard 'git-crypt unlock' (GPG must be available)..."
if with_git_attrs_disabled git-crypt unlock 2>/dev/null; then
echo "git-crypt unlocked via GPG."
else
echo "Error: git-crypt unlock failed in directory: $WORKTREE_PATH"
echo "Details (git status --porcelain):"
with_git_attrs_disabled git status --porcelain || true
echo "Hint: If this worktree was previously partially checked out, you can reset then retry:"
echo " cd \"$WORKTREE_PATH\" && git reset --hard && git clean -fd && git-crypt unlock"
popd >/dev/null
exit 1
fi
fi
# Step 3: Re-populate WITH filters enabled so encrypted files are decrypted.
echo "Re-populating files with git-crypt filters enabled..."
git reset --hard
popd >/dev/null
echo "Done. Worktree ready at: $WORKTREE_PATH"
echo "You can open it in PyCharm: File > Open > $WORKTREE_PATH"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment