Created
April 6, 2026 11:44
-
-
Save Konfekt/064e29e0de65680d66acaa47a65f17dc to your computer and use it in GitHub Desktop.
auto-install common dependencies on git worktree creation
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 | |
| # git worktrees enable working with differing dependencies: | |
| # auto-install common dependencies on worktree creation by this | |
| # post-checkout hook; drop insto post-checkout.d with multihook : | |
| # https://gist.github.com/Konfekt/d9e86763b0f3febd7b2f7ca589f6c482 | |
| set -Eeuo pipefail | |
| if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4))); then | |
| shopt -s inherit_errexit | |
| fi | |
| PS4='+\t ' | |
| have() { command -v "$1" >/dev/null 2>&1; } | |
| log() { printf '%s\n' "$*"; } | |
| notify=0 | |
| if [[ ! -t 2 && -n "${DBUS_SESSION_BUS_ADDRESS:-}" ]] && have notify-send; then | |
| notify=1 | |
| fi | |
| error_handler() { | |
| local lineno="$1" cmd="$2" status="$3" | |
| # fallback if error occurs in main body of script | |
| local src="${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}" | |
| local start=$((lineno > 3 ? lineno - 3 : 1)) | |
| local end=$((lineno + 3)) | |
| local body summary | |
| body="$( | |
| awk -v s="$start" -v e="$end" -v l="$lineno" ' | |
| NR < s { next } | |
| NR > e { exit } | |
| { | |
| line = sprintf("%6d\t%s", NR, $0) | |
| if (NR == l) line = ">> " line | |
| print line | |
| } | |
| ' "$src" | |
| )" | |
| summary="Error: ${src}:${lineno}: '${cmd}' exited with status ${status}" | |
| printf '%s\n%s\n' "$summary" "$body" >&2 | |
| ((notify)) && notify-send --urgency=critical "$summary" "$body" || true | |
| exit "$status" | |
| } | |
| trap 'error_handler "$LINENO" "$BASH_COMMAND" "$?"' ERR | |
| # --------------------------------------------------------------------------- | |
| # Ensure a clean Git environment so that commands like `git -C <dir>` actually | |
| # discover the repository from <dir> rather than from inherited GIT_DIR. | |
| # See: https://github.com/git/git/commit/772f8ff826fcb15cba94bfd8f23eb0917f3e9edc | |
| # | |
| # The multihook dispatcher should already have done this, but we do it | |
| # defensively in case this script is invoked directly or by a different runner. | |
| # --------------------------------------------------------------------------- | |
| # shellcheck disable=SC2046 | |
| unset $(git rev-parse --local-env-vars 2>/dev/null || true) | |
| copy_item_if_missing() { | |
| local src_root="$1" dst_root="$2" item="$3" | |
| local src="${src_root}/${item}" dst="${dst_root}/${item}" | |
| [[ -e "$src" && ! -e "$dst" ]] || return 0 | |
| [[ "$item" == */* ]] && mkdir -p "${dst%/*}" | |
| cp -a -- "$src" "$dst" | |
| } | |
| copy_git_exclude_if_missing() { | |
| local src_root="$1" dst_root="$2" | |
| local src dst | |
| src="$(git -C "$src_root" rev-parse --path-format=absolute --git-path info/exclude)" | |
| dst="$(git -C "$dst_root" rev-parse --path-format=absolute --git-path info/exclude)" | |
| [[ -e "$src" && ! -e "$dst" ]] || return 0 | |
| mkdir -p "${dst%/*}" | |
| cp -a "$src" "$dst" | |
| } | |
| copy_important_files_from_main() { | |
| local src_root="$1" dst_root="$2" | |
| local items=( | |
| ".envrc" | |
| "mise.local.toml" | |
| ".devcontainer.local.json" | |
| "AGENTS.md" | |
| ".agents" | |
| ".vimrc" | |
| ".vscode" | |
| ) | |
| if [[ -n "${WORKTREE_COPY_ITEMS:-}" ]]; then | |
| mapfile -t items < <(sed '/^[[:space:]]*$/d' <<<"$WORKTREE_COPY_ITEMS") | |
| fi | |
| for item in "${items[@]}"; do | |
| copy_item_if_missing "$src_root" "$dst_root" "$item" | |
| done | |
| copy_git_exclude_if_missing "$src_root" "$dst_root" | |
| } | |
| run_mise_install() { | |
| [[ "${WORKTREE_RUN_MISE_INSTALL:-1}" == "1" ]] || return 0 | |
| have mise || return 0 | |
| [[ -f "mise.toml" || -f ".mise.toml" ]] || return 0 | |
| log "-> mise install" | |
| mise trust --all --yes --verbose | |
| # mise install --yes --verbose | |
| } | |
| node_install() { | |
| [[ "${WORKTREE_NODE_INSTALL:-1}" == "1" ]] || return 0 | |
| [[ -f "package.json" ]] || return 0 | |
| if [[ -f "pnpm-lock.yaml" ]] && (have pnpm || have corepack); then | |
| log "-> pnpm install --frozen-lockfile" | |
| if have pnpm; then pnpm install --frozen-lockfile; else corepack pnpm install --frozen-lockfile; fi | |
| return 0 | |
| fi | |
| if [[ -f "package-lock.json" || -f "npm-shrinkwrap.json" ]]; then | |
| log "-> npm ci" | |
| npm ci | |
| else | |
| log "-> npm install" | |
| npm install | |
| fi | |
| } | |
| python_scaffold() { | |
| [[ "${WORKTREE_PYTHON_INSTALL:-1}" == "1" ]] || return 0 | |
| [[ -f "pyproject.toml" || -f "requirements.txt" || -f "uv.lock" ]] || return 0 | |
| local venv="${WORKTREE_VENV_PATH:-.venv}" | |
| if [[ ! -d "$venv" ]]; then | |
| log "-> python3 -m venv ${venv}" | |
| python3 -m venv "$venv" | |
| fi | |
| if have uv && [[ -f "pyproject.toml" ]]; then | |
| log "-> uv sync" | |
| local -a uv_flags=() | |
| if [[ -n "${WORKTREE_UV_FLAGS:-}" ]]; then | |
| read -ra uv_flags <<<"$WORKTREE_UV_FLAGS" | |
| fi | |
| UV_PROJECT_ENVIRONMENT="$venv" uv sync "${uv_flags[@]}" | |
| return 0 | |
| fi | |
| if [[ -f "requirements.txt" ]]; then | |
| log "-> pip install -r requirements.txt" | |
| "$venv/bin/python" -m pip install -U pip | |
| "$venv/bin/python" -m pip install -r requirements.txt | |
| return 0 | |
| fi | |
| log "-> Python project detected, dependency install skipped" | |
| } | |
| precommit_scaffold() { | |
| [[ "${WORKTREE_PRECOMMIT_INSTALL:-0}" == "1" ]] || return 0 | |
| [[ -f ".pre-commit-config.yaml" ]] || return 0 | |
| have pre-commit || return 0 | |
| log "-> pre-commit install" | |
| pre-commit install | |
| } | |
| is_initial_linked_worktree_checkout() { | |
| local prev="${1:-}" new="${2:-}" mode="${3:-}" | |
| local only_under="${WORKTREE_SCAFFOLD_ONLY_UNDER:-.worktrees}" | |
| [[ "$mode" == "1" ]] || return 1 | |
| # Check this is a linked worktree (not the main one) | |
| local git_dir common_dir | |
| git_dir="$(git rev-parse --path-format=absolute --git-dir)" | |
| common_dir="$(git rev-parse --path-format=absolute --git-common-dir)" | |
| [[ "$git_dir" != "$common_dir" ]] || return 1 | |
| [[ -z "$only_under" ]] && return 0 | |
| local top | |
| top="$(git rev-parse --show-toplevel)" | |
| case "$top" in | |
| *"/$only_under" | *"/$only_under/"*) return 0 ;; | |
| *) return 1 ;; | |
| esac | |
| } | |
| main() { | |
| local top git_dir stamp main_root | |
| local only_under="${WORKTREE_SCAFFOLD_ONLY_UNDER:-.worktrees}" | |
| is_initial_linked_worktree_checkout "$@" || return 0 | |
| top="$(git rev-parse --show-toplevel)" | |
| git_dir="$(git rev-parse --path-format=absolute --git-dir)" | |
| stamp="${git_dir}/.scaffolded" | |
| if ! ( set -o noclobber; : >"$stamp" ) 2>/dev/null; then | |
| return 0 # another invocation already claimed it | |
| fi | |
| # If scaffold fails, remove the stamp so it can be retried | |
| trap 'rm -f "$stamp"' EXIT | |
| main_root="$(git -C "$(git rev-parse --path-format=absolute --git-common-dir)/../" \ | |
| rev-parse --show-toplevel 2>/dev/null || true)" | |
| log "-> new linked worktree detected at ${top}" | |
| copy_important_files_from_main "$main_root" "$top" | |
| run_mise_install | |
| node_install | |
| python_scaffold | |
| precommit_scaffold | |
| : >"$stamp" | |
| log "-> scaffold complete" | |
| trap - EXIT | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment