Last active
September 24, 2025 02:51
-
-
Save timrichardson/0b275fe10e5303b11f65285db9189d90 to your computer and use it in GitHub Desktop.
Create sibling worktree and unlock git-crypt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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