Created
February 20, 2026 21:10
-
-
Save malcolmgreaves/c83e018cb21399da7fca85dbbfba74f8 to your computer and use it in GitHub Desktop.
Managed git worktrees: `git-wt` for creating managed worktreees and `git-wt-done` for cleaning them up.
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 | |
| # Creates a new managed git worktree: `git-wt BRANCH_NAME` | |
| # Puts it under `$GIT_WORKTREE_SOURCES` as `<REPO_NAME>/<BRANCH_NAME>`. | |
| # Creates a new branch if necessary or switches to one if it exists. | |
| # Switches to the workspace directory at the end. | |
| git-wt() { | |
| ### Get branch name | |
| local BRANCH="${1}" | |
| if [[ -z "${BRANCH}" ]] || [[ "${BRANCH}" == "--help" ]] || [[ "${BRANCH}" == "-h" ]]; then | |
| echo "ERROR: Must supply branch name as first argument!" >&2 | |
| return 1 | |
| fi | |
| ### Where all worktrees for all repositories are stored. | |
| local GIT_WORKTREE_SOURCES="${GIT_WORKTREE_SOURCES:-${HOME}/.local/state/git/worktrees}" | |
| if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then | |
| echo "ERROR: Not in a git repository" >&2 | |
| return 1 | |
| fi | |
| local REPO_NAME GIT_DIR_PATH | |
| GIT_DIR_PATH="$(git rev-parse --git-dir)" | |
| if [[ "${GIT_DIR_PATH}" == *.git/worktrees/* ]]; then | |
| # Inside a worktree: --git-dir is <repo>/.git/worktrees/<name> | |
| # Use --git-common-dir to get <repo>/.git, then go up one level. | |
| REPO_NAME="$(basename "$(dirname "$(git rev-parse --git-common-dir)")")" | |
| else | |
| # Inside the main repo | |
| REPO_NAME="$(basename "$(git rev-parse --show-toplevel)")" | |
| fi | |
| local WORKTREE_DIR="${GIT_WORKTREE_SOURCES}/${REPO_NAME}/${BRANCH}" | |
| ### Create the branch if it doesn't exist, handling checked-out case | |
| if [[ -n "$(git branch --list "${BRANCH}")" ]]; then | |
| # branch exists — check if it's currently checked out | |
| local CURRENT_BRANCH | |
| CURRENT_BRANCH="$(git branch --show-current)" | |
| if [[ "${CURRENT_BRANCH}" == "${BRANCH}" ]]; then | |
| # branch is checked out — error if dirty, otherwise switch to main | |
| if ! git diff --quiet || ! git diff --cached --quiet; then | |
| echo "ERROR: Branch '${BRANCH}' is checked out with unstaged/staged changes. Commit or stash first." >&2 | |
| return 1 | |
| fi | |
| git checkout main || { echo "ERROR: Failed to switch to main" >&2; return 1; } | |
| fi | |
| else | |
| # branch doesn't exist — create it | |
| git branch "${BRANCH}" || { echo "ERROR: Failed to create branch '${BRANCH}'" >&2; return 1; } | |
| fi | |
| ### Create the worktree if one doesn't already exist for this branch | |
| if git worktree list --porcelain | grep -q "^branch refs/heads/${BRANCH}$"; then | |
| echo "Worktree for '${BRANCH}' already exists, skipping creation." | |
| else | |
| git worktree add "${WORKTREE_DIR}" "${BRANCH}" || { echo "ERROR: Failed to create worktree" >&2; return 1; } | |
| fi | |
| ### Switch to the worktree directory | |
| cd "${WORKTREE_DIR}" || { echo "ERROR: Failed to cd to '${WORKTREE_DIR}'" >&2; return 1; } | |
| echo "Switched to worktree '${BRANCH}' in '${REPO_NAME}' (\$GIT_WORKTREE_SOURCES)" | |
| ### iTerm2: change the name of the active tab | |
| if [[ -n "${ITERM_SESSION_ID}" ]]; then | |
| echo -ne "\033]1;Worktree ${REPO_NAME} ${BRANCH}\007" | |
| fi | |
| } | |
| # Delete the worktree: `git-wt-done BRANCH_NAME` or `git-wt-done` if in a worktree. | |
| # Removes the worktree, keeping the branch around. | |
| # Moves back to the base directory. | |
| git-wt-done() { | |
| ### Get branch name | |
| local BRANCH="${1}" | |
| if [[ "${BRANCH}" == "--help" ]] || [[ "${BRANCH}" == "-h" ]]; then | |
| echo "Usage: git-wt-done [<branch>]" >&2 | |
| echo " If <branch> is omitted, auto-detects from the current worktree." >&2 | |
| return 1 | |
| fi | |
| if ! git rev-parse HEAD > /dev/null 2>&1; then | |
| echo "ERROR: Not in a git repository" >&2 | |
| return 1 | |
| fi | |
| if [[ -z "${BRANCH}" ]]; then | |
| # Auto-detect branch from current worktree | |
| if git rev-parse --is-inside-work-tree > /dev/null 2>&1; then | |
| BRANCH="$(git branch --show-current)" | |
| else | |
| echo "ERROR: Must supply branch name as first argument (or run from within a worktree)!" >&2 | |
| return 1 | |
| fi | |
| fi | |
| ### Where all worktrees for all repositories are stored. | |
| local GIT_WORKTREE_SOURCES="${GIT_WORKTREE_SOURCES:-${HOME}/.local/state/git/worktrees}" | |
| ### Resolve the real repo name (works from main checkout or a worktree) | |
| local REPO_NAME GIT_DIR_PATH REPO_ROOT | |
| GIT_DIR_PATH="$(cd $(git rev-parse --git-common-dir) && pwd -P)" | |
| REPO_ROOT="${GIT_DIR_PATH%/*}" | |
| REPO_NAME="${REPO_ROOT##*/}" | |
| local WORKTREE_DIR="${GIT_WORKTREE_SOURCES}/${REPO_NAME}/${BRANCH}" | |
| ### Verify the worktree exists | |
| if ! git worktree list --porcelain | grep -q "^branch refs/heads/${BRANCH}$"; then | |
| echo "ERROR: No worktree found for branch '${BRANCH}'" >&2 | |
| return 1 | |
| fi | |
| ### If we're currently inside the worktree being removed, move out first | |
| if [[ "$(pwd)" == "${WORKTREE_DIR}"* ]]; then | |
| cd "${REPO_ROOT}" || { echo "ERROR: Failed to cd to '${REPO_ROOT}'" >&2; return 1; } | |
| fi | |
| ### Remove the worktree (deletes the directory and cleans up git internal tracking) | |
| git worktree remove "${WORKTREE_DIR}" || { echo "ERROR: Failed to remove worktree at '${WORKTREE_DIR}'" >&2; return 1; } | |
| ### cd to the main repo root | |
| cd "${REPO_ROOT}" || { echo "ERROR: Failed to cd to '${REPO_ROOT}'" >&2; return 1; } | |
| echo "Worktree '${BRANCH}' deleted. Moved back to ${REPO_NAME} ($(pwd))" | |
| ### iTerm2: change the name of the active tab | |
| if [[ -n "${ITERM_SESSION_ID}" ]]; then | |
| echo -ne "\033]1;${REPO_NAME}\007" | |
| fi | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment