Last active
February 6, 2026 20:43
-
-
Save gadenbuie/6a9d1b8088f6bc9154b6c534896bbd25 to your computer and use it in GitHub Desktop.
tiny bash script to quickly spin up a new worktree and open a new ide session
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 | |
| # | |
| # Source: https://gist.github.com/gadenbuie/6a9d1b8088f6bc9154b6c534896bbd25 | |
| set -e | |
| # ============================================================================== | |
| # Color and formatting setup (respects NO_COLOR) | |
| # ============================================================================== | |
| setup_colors() { | |
| if [[ -z "${NO_COLOR:-}" && -t 1 ]]; then | |
| BOLD='\033[1m' | |
| DIM='\033[2m' | |
| RESET='\033[0m' | |
| GREEN='\033[32m' | |
| BLUE='\033[34m' | |
| YELLOW='\033[33m' | |
| RED='\033[31m' | |
| CYAN='\033[36m' | |
| MAGENTA='\033[35m' | |
| else | |
| BOLD='' | |
| DIM='' | |
| RESET='' | |
| GREEN='' | |
| BLUE='' | |
| YELLOW='' | |
| CYAN='' | |
| MAGENTA='' | |
| fi | |
| } | |
| setup_colors | |
| # ============================================================================== | |
| # Output helpers | |
| # ============================================================================== | |
| header() { | |
| echo "" | |
| echo -e "${BOLD}${BLUE}▶ ${1}${RESET}" | |
| } | |
| info() { | |
| echo -e " ${DIM}ℹ${RESET} ${1}" | |
| } | |
| success() { | |
| echo -e " ${GREEN}✔${RESET} ${1}" | |
| } | |
| warn() { | |
| echo -e " ${YELLOW}!${RESET} ${1}" | |
| } | |
| danger() { | |
| echo -e " ${RED}✘${RESET} ${1}" | |
| } | |
| step() { | |
| echo -e " ${CYAN}●${RESET} ${1}" | |
| } | |
| clear_last_line() { | |
| if [[ -z "${NO_COLOR:-}" && -t 1 ]]; then | |
| echo -en "\033[A\033[2K\r" | |
| fi | |
| } | |
| step_done() { | |
| clear_last_line | |
| success "$1" | |
| } | |
| step_fail() { | |
| clear_last_line | |
| danger "${1}" | |
| } | |
| path_output() { | |
| echo -e " ${MAGENTA} ${1}${RESET}" | |
| } | |
| run_cmd() { | |
| if [[ "$VERBOSE" == true ]]; then | |
| "$@" | |
| else | |
| "$@" > /dev/null 2>&1 | |
| fi | |
| } | |
| # ============================================================================== | |
| # Help | |
| # ============================================================================== | |
| help() { | |
| echo -e "${BOLD}Usage:${RESET} create-worktree <branch-name> [options]" | |
| echo -e " create-worktree remove [worktree-name] [options]" | |
| echo "" | |
| echo "Creates a git worktree with a new branch." | |
| echo "" | |
| echo -e "${BOLD}Subcommands:${RESET}" | |
| echo " remove Remove a worktree (interactive picker if no name given)" | |
| echo "" | |
| echo -e "${BOLD}Options:${RESET}" | |
| echo " --base <branch> Base branch to create from (default: current branch)" | |
| echo " --open [mode] How to open the worktree folder:" | |
| echo " auto - detect editor from environment (default)" | |
| echo " false - don't open" | |
| echo " <cmd> - use specified command (e.g., 'code', 'cursor')" | |
| echo " (bare) - use 'open .' (Finder on macOS)" | |
| echo " --no-setup Skip running setup commands (npm, uv, make)" | |
| echo " -v, --verbose Show command output" | |
| echo " -h, --help Show this help message" | |
| echo "" | |
| echo -e "${BOLD}To remove a worktree:${RESET}" | |
| echo " create-worktree remove # interactive picker" | |
| echo " create-worktree remove <name> # by name" | |
| echo " create-worktree remove <name> --delete-branch" | |
| } | |
| auto_detect_editor() { | |
| # Check for Positron first, since it is based on VS Code | |
| if [[ "${POSITRON:-}" == "1" ]]; then | |
| echo "positron" | |
| return | |
| fi | |
| # Check for Cursor (VS Code fork) - has its own env vars | |
| if [[ -n "${CURSOR_TRACE_ID:-}" || -n "${CURSOR_SESSION_ID:-}" ]]; then | |
| echo "cursor" | |
| return | |
| fi | |
| # Check for Windsurf (Codeium's VS Code fork) | |
| if [[ -n "${WINDSURF:-}" || -n "${CODEIUM_WIND_SURF:-}" ]]; then | |
| echo "windsurf" | |
| return | |
| fi | |
| # Check for Zed | |
| if [[ -n "${ZED_SESSION:-}" || "${TERM_PROGRAM:-}" == "zed" ]]; then | |
| echo "zed" | |
| return | |
| fi | |
| # Check TERM_PROGRAM (set by many terminal-integrated editors) | |
| # Note: Cursor/Windsurf may report as "vscode", so check them first above | |
| case "${TERM_PROGRAM:-}" in | |
| vscode) | |
| # Could be VS Code or a fork - check for Insiders | |
| if [[ "${VSCODE_GIT_IPC_HANDLE:-}" == *"Code - Insiders"* ]]; then | |
| echo "code-insiders" | |
| else | |
| echo "code" | |
| fi | |
| return | |
| ;; | |
| esac | |
| # Fallback: check for VS Code via other env vars | |
| if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_PID:-}" ]]; then | |
| if [[ "${VSCODE_GIT_IPC_HANDLE:-}" == *"Code - Insiders"* ]]; then | |
| echo "code-insiders" | |
| else | |
| echo "code" | |
| fi | |
| return | |
| fi | |
| # No editor detected | |
| echo "" | |
| } | |
| # ============================================================================== | |
| # Remove subcommand | |
| # ============================================================================== | |
| help_remove() { | |
| echo -e "${BOLD}Usage:${RESET} create-worktree remove [worktree-name] [options]" | |
| echo "" | |
| echo "Removes a git worktree and optionally deletes its branch." | |
| echo "" | |
| echo -e "${BOLD}Options:${RESET}" | |
| echo " --delete-branch Also delete the associated branch" | |
| echo " -v, --verbose Show command output" | |
| echo " -h, --help Show this help message" | |
| echo "" | |
| echo "If no worktree name is given, an interactive picker is shown." | |
| } | |
| do_remove() { | |
| local REMOVE_NAME="" | |
| local DELETE_BRANCH=false | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| --delete-branch) | |
| DELETE_BRANCH=true | |
| shift | |
| ;; | |
| -v|--verbose) | |
| VERBOSE=true | |
| shift | |
| ;; | |
| -h|--help) | |
| help_remove | |
| exit 0 | |
| ;; | |
| -*) | |
| warn "Unknown option: $1" | |
| echo "" | |
| help_remove | |
| exit 1 | |
| ;; | |
| *) | |
| if [[ -z "$REMOVE_NAME" ]]; then | |
| REMOVE_NAME="$1" | |
| else | |
| warn "Unexpected argument: $1" | |
| echo "" | |
| help_remove | |
| exit 1 | |
| fi | |
| shift | |
| ;; | |
| esac | |
| done | |
| # Get repo root and name | |
| local REPO_ROOT REPO_NAME WORKTREES_DIR | |
| REPO_ROOT=$(git rev-parse --show-toplevel) | |
| REPO_NAME=$(basename "$REPO_ROOT") | |
| WORKTREES_DIR="$(dirname "$REPO_ROOT")/${REPO_NAME}.worktrees" | |
| # Parse worktree list (excluding the main working tree) | |
| local worktree_paths=() | |
| local worktree_branches=() | |
| local current_path="" current_branch="" | |
| while IFS= read -r line; do | |
| if [[ "$line" == "worktree "* ]]; then | |
| current_path="${line#worktree }" | |
| elif [[ "$line" == "branch "* ]]; then | |
| current_branch="${line#branch refs/heads/}" | |
| elif [[ -z "$line" ]]; then | |
| if [[ -n "$current_path" && "$current_path" != "$REPO_ROOT" ]]; then | |
| worktree_paths+=("$current_path") | |
| worktree_branches+=("${current_branch:-}") | |
| fi | |
| current_path="" | |
| current_branch="" | |
| fi | |
| done < <(git worktree list --porcelain) | |
| if [[ ${#worktree_paths[@]} -eq 0 ]]; then | |
| info "No worktrees found (besides the main working tree)" | |
| exit 0 | |
| fi | |
| # Helper: get display name for a worktree path | |
| _wt_display_name() { | |
| if [[ "$1" == "$WORKTREES_DIR/"* ]]; then | |
| echo "${1#$WORKTREES_DIR/}" | |
| else | |
| echo "$1" | |
| fi | |
| } | |
| local selected_idx=-1 | |
| if [[ -n "$REMOVE_NAME" ]]; then | |
| # Match by relative name or basename | |
| for i in "${!worktree_paths[@]}"; do | |
| local rel_name | |
| rel_name=$(_wt_display_name "${worktree_paths[$i]}") | |
| if [[ "$rel_name" == "$REMOVE_NAME" || "$(basename "${worktree_paths[$i]}")" == "$REMOVE_NAME" ]]; then | |
| selected_idx=$i | |
| break | |
| fi | |
| done | |
| if [[ $selected_idx -eq -1 ]]; then | |
| danger "Worktree '${REMOVE_NAME}' not found" | |
| echo "" | |
| info "Available worktrees:" | |
| for i in "${!worktree_paths[@]}"; do | |
| local rel_name branch_info | |
| rel_name=$(_wt_display_name "${worktree_paths[$i]}") | |
| branch_info="${worktree_branches[$i]}" | |
| if [[ -n "$branch_info" ]]; then | |
| echo -e " ${rel_name} ${DIM}(${branch_info})${RESET}" | |
| else | |
| echo " ${rel_name}" | |
| fi | |
| done | |
| exit 1 | |
| fi | |
| else | |
| # Interactive picker | |
| header "Select a worktree to remove" | |
| echo "" | |
| for i in "${!worktree_paths[@]}"; do | |
| local rel_name branch_info | |
| rel_name=$(_wt_display_name "${worktree_paths[$i]}") | |
| branch_info="${worktree_branches[$i]}" | |
| if [[ -n "$branch_info" ]]; then | |
| echo -e " ${BOLD}$((i + 1))${RESET}) ${rel_name} ${DIM}(${branch_info})${RESET}" | |
| else | |
| echo -e " ${BOLD}$((i + 1))${RESET}) ${rel_name} ${DIM}(detached)${RESET}" | |
| fi | |
| done | |
| echo "" | |
| local choice | |
| while true; do | |
| echo -en " ${CYAN}Pick a worktree [1-${#worktree_paths[@]}]:${RESET} " | |
| read -r choice | |
| if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#worktree_paths[@]} )); then | |
| selected_idx=$((choice - 1)) | |
| break | |
| fi | |
| warn "Invalid selection, try again" | |
| done | |
| fi | |
| local selected_path="${worktree_paths[$selected_idx]}" | |
| local selected_branch="${worktree_branches[$selected_idx]}" | |
| local selected_name | |
| selected_name=$(_wt_display_name "$selected_path") | |
| # Remove the worktree | |
| header "Removing worktree '${selected_name}'" | |
| step "git worktree remove \"${selected_name}\"" | |
| if run_cmd git worktree remove "$selected_path"; then | |
| step_done "Worktree removed" | |
| else | |
| step_fail "Could not remove worktree (it may have uncommitted changes)" | |
| info "Use ${CYAN}git worktree remove --force \"$selected_path\"${RESET} to force removal" | |
| exit 1 | |
| fi | |
| # Handle branch deletion | |
| if [[ -n "$selected_branch" ]]; then | |
| if [[ "$DELETE_BRANCH" == true ]]; then | |
| step "Deleting branch '${selected_branch}'" | |
| if run_cmd git branch -D "$selected_branch"; then | |
| step_done "Branch '${selected_branch}' deleted" | |
| else | |
| step_fail "Could not delete branch '${selected_branch}'" | |
| fi | |
| else | |
| echo "" | |
| echo -en " Delete branch '${selected_branch}'? ${DIM}[y/N]${RESET} " | |
| read -r confirm | |
| if [[ "$confirm" =~ ^[Yy]$ ]]; then | |
| step "Deleting branch '${selected_branch}'" | |
| if git branch -d "$selected_branch" > /dev/null 2>&1; then | |
| step_done "Branch '${selected_branch}' deleted" | |
| else | |
| step_fail "Branch '${selected_branch}' is not fully merged" | |
| echo -en " Force delete? ${DIM}[y/N]${RESET} " | |
| read -r force_confirm | |
| if [[ "$force_confirm" =~ ^[Yy]$ ]]; then | |
| step "Force deleting branch '${selected_branch}'" | |
| if run_cmd git branch -D "$selected_branch"; then | |
| step_done "Branch '${selected_branch}' deleted" | |
| else | |
| step_fail "Could not delete branch '${selected_branch}'" | |
| fi | |
| else | |
| info "Branch '${selected_branch}' kept" | |
| fi | |
| fi | |
| else | |
| info "Branch '${selected_branch}' kept" | |
| fi | |
| fi | |
| fi | |
| header "Done" | |
| echo "" | |
| } | |
| # ============================================================================== | |
| # Subcommand dispatch | |
| # ============================================================================== | |
| VERBOSE=false | |
| if [[ "${1:-}" == "remove" ]]; then | |
| shift | |
| do_remove "$@" | |
| exit 0 | |
| fi | |
| # ============================================================================== | |
| # Parse arguments | |
| # ============================================================================== | |
| BRANCH_NAME="" | |
| BASE_BRANCH="" | |
| OPEN_MODE="auto" | |
| RUN_SETUP=true | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| --base) | |
| BASE_BRANCH="$2" | |
| shift 2 | |
| ;; | |
| --no-setup) | |
| RUN_SETUP=false | |
| shift | |
| ;; | |
| --open) | |
| # Check if next arg exists and is not a flag | |
| if [[ -z "${2:-}" || "$2" == -* ]]; then | |
| # Bare --open flag | |
| OPEN_MODE="finder" | |
| shift | |
| else | |
| OPEN_MODE="$2" | |
| shift 2 | |
| fi | |
| ;; | |
| -v|--verbose) | |
| VERBOSE=true | |
| shift | |
| ;; | |
| -h|--help) | |
| help | |
| exit 0 | |
| ;; | |
| -*) | |
| warn "Unknown option: $1" | |
| echo "" | |
| help | |
| exit 1 | |
| ;; | |
| *) | |
| if [[ -z "$BRANCH_NAME" ]]; then | |
| BRANCH_NAME="$1" | |
| else | |
| warn "Unexpected argument: $1" | |
| echo "" | |
| help | |
| exit 1 | |
| fi | |
| shift | |
| ;; | |
| esac | |
| done | |
| if [[ -z "$BRANCH_NAME" ]]; then | |
| help | |
| exit 1 | |
| fi | |
| # ============================================================================== | |
| # Main logic | |
| # ============================================================================== | |
| # Get repo root and name | |
| REPO_ROOT=$(git rev-parse --show-toplevel) | |
| REPO_NAME=$(basename "$REPO_ROOT") | |
| # Default base branch to current branch | |
| if [[ -z "$BASE_BRANCH" ]]; then | |
| BASE_BRANCH=$(git branch --show-current) | |
| fi | |
| # Calculate worktree path | |
| WORKTREE_DIR="$(dirname "$REPO_ROOT")/${REPO_NAME}.worktrees/${BRANCH_NAME}" | |
| header "Creating worktree for ${BRANCH_NAME}" | |
| info "Repository: ${REPO_NAME}" | |
| info "Base branch: ${BASE_BRANCH}" | |
| # Create the branch from base (if it doesn't already exist) | |
| if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then | |
| warn "Branch '${BRANCH_NAME}' already exists, using existing branch" | |
| else | |
| step "Creating branch '${BRANCH_NAME}' from '${BASE_BRANCH}'" | |
| run_cmd git branch "$BRANCH_NAME" "$BASE_BRANCH" | |
| step_done "Branch created" | |
| fi | |
| # Create the worktree | |
| step "Creating worktree directory" | |
| mkdir -p "$(dirname "$WORKTREE_DIR")" | |
| if [[ "$VERBOSE" == true ]]; then | |
| git worktree add "$WORKTREE_DIR" "$BRANCH_NAME" | |
| else | |
| git worktree add "$WORKTREE_DIR" "$BRANCH_NAME" 2>&1 | grep -v "^Preparing" || true | |
| fi | |
| step_done "Worktree created" | |
| path_output "$WORKTREE_DIR" | |
| # Symlink _dev if it exists | |
| if [[ -d "$REPO_ROOT/_dev" ]]; then | |
| step "Symlinking _dev folder" | |
| ln -s "$REPO_ROOT/_dev" "$WORKTREE_DIR/_dev" | |
| step_done "Symlinked _dev" | |
| fi | |
| # Symlink .claude if it exists in the source repo | |
| if [[ -d "$REPO_ROOT/.claude" ]]; then | |
| if [[ ! -d "$WORKTREE_DIR/.claude" ]]; then | |
| # .claude folder doesn't exist in worktree, symlink the entire folder | |
| step "Symlinking .claude folder" | |
| ln -s "$REPO_ROOT/.claude" "$WORKTREE_DIR/.claude" | |
| step_done "Symlinked .claude" | |
| else | |
| # .claude exists in worktree, check for settings.local.json | |
| if [[ -f "$REPO_ROOT/.claude/settings.local.json" ]] && [[ ! -f "$WORKTREE_DIR/.claude/settings.local.json" ]]; then | |
| step "Symlinking .claude/settings.local.json" | |
| ln -s "$REPO_ROOT/.claude/settings.local.json" "$WORKTREE_DIR/.claude/settings.local.json" | |
| step_done "Symlinked settings.local.json" | |
| fi | |
| fi | |
| fi | |
| # Change to worktree directory | |
| cd "$WORKTREE_DIR" | |
| # Run setup commands if applicable | |
| if [[ "$RUN_SETUP" == true ]]; then | |
| SETUP_RAN=false | |
| if [[ -f "package.json" ]] && command -v npm &> /dev/null; then | |
| header "Running project setup" | |
| step "npm install" | |
| run_cmd npm install | |
| step_done "npm dependencies installed" | |
| SETUP_RAN=true | |
| fi | |
| if [[ -f "pyproject.toml" ]] && command -v uv &> /dev/null; then | |
| [[ "$SETUP_RAN" == false ]] && header "Running project setup" | |
| step "uv sync --all-groups" | |
| run_cmd uv sync --all-groups | |
| step_done "Python dependencies installed" | |
| SETUP_RAN=true | |
| fi | |
| if [[ -f "Makefile" ]] && grep -q '^setup:' Makefile; then | |
| [[ "$SETUP_RAN" == false ]] && header "Running project setup" | |
| step "make setup" | |
| run_cmd make setup | |
| step_done "Make setup complete" | |
| SETUP_RAN=true | |
| fi | |
| fi | |
| # Determine open command | |
| OPEN_CMD="" | |
| case "$OPEN_MODE" in | |
| false) | |
| # Don't open | |
| ;; | |
| auto) | |
| EDITOR=$(auto_detect_editor) | |
| if [[ -n "$EDITOR" ]]; then | |
| OPEN_CMD="$EDITOR ." | |
| fi | |
| ;; | |
| finder) | |
| OPEN_CMD="open ." | |
| ;; | |
| *) | |
| # Custom command | |
| OPEN_CMD="$OPEN_MODE ." | |
| ;; | |
| esac | |
| if [[ -n "$OPEN_CMD" ]]; then | |
| header "Opening worktree" | |
| step "$OPEN_CMD" | |
| $OPEN_CMD | |
| step_done "Opened" | |
| fi | |
| # ============================================================================== | |
| # Summary | |
| # ============================================================================== | |
| header "Done" | |
| echo "" | |
| echo -e " ${DIM}To enter the worktree:${RESET}" | |
| echo -e " ${CYAN}cd \"$WORKTREE_DIR\"${RESET}" | |
| echo "" | |
| echo -e " ${DIM}To delete this worktree later:${RESET}" | |
| echo -e " ${CYAN}create-worktree remove ${BRANCH_NAME}${RESET}" | |
| echo "" |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Installation
Make sure
~/.local/binis in yourPATH. Add this to your shell profile if needed: