-
-
Save thomashartm/67851e82a32310385ec14c429f90ad0c to your computer and use it in GitHub Desktop.
Git Worktree Manager - A simple CLI to create and manage git worktrees
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 | |
| # wt - Git Worktree Manager | |
| # A simple CLI to create and manage git worktrees | |
| readonly SCRIPT_NAME="wt" | |
| readonly VERSION="1.0.0" | |
| # Colors for output | |
| readonly RED='\033[0;31m' | |
| readonly GREEN='\033[0;32m' | |
| readonly YELLOW='\033[0;33m' | |
| readonly BLUE='\033[0;34m' | |
| readonly CYAN='\033[0;36m' | |
| readonly NC='\033[0m' # No Color | |
| readonly BOLD='\033[1m' | |
| # Default base branch (can be overridden with -b flag or WT_BASE_BRANCH env var) | |
| BASE_BRANCH="${WT_BASE_BRANCH:-main}" | |
| print_error() { | |
| echo -e "${RED}error:${NC} $1" >&2 | |
| } | |
| print_success() { | |
| echo -e "${GREEN}✓${NC} $1" | |
| } | |
| print_info() { | |
| echo -e "${BLUE}→${NC} $1" | |
| } | |
| print_warning() { | |
| echo -e "${YELLOW}!${NC} $1" | |
| } | |
| ensure_git_repo() { | |
| if ! git rev-parse --git-dir &>/dev/null; then | |
| print_error "Not a git repository" | |
| exit 1 | |
| fi | |
| } | |
| get_repo_root() { | |
| git rev-parse --show-toplevel 2>/dev/null || git rev-parse --git-common-dir | xargs dirname | |
| } | |
| get_main_worktree() { | |
| git worktree list --porcelain | grep -m1 "^worktree " | cut -d' ' -f2- | |
| } | |
| # Resolve the base branch - checks for main, master, or custom | |
| resolve_base_branch() { | |
| local requested_branch="$1" | |
| # If explicitly specified, verify it exists | |
| if git show-ref --verify --quiet "refs/heads/$requested_branch" 2>/dev/null; then | |
| echo "$requested_branch" | |
| return 0 | |
| fi | |
| # Try common defaults | |
| for branch in main master; do | |
| if git show-ref --verify --quiet "refs/heads/$branch" 2>/dev/null; then | |
| echo "$branch" | |
| return 0 | |
| fi | |
| done | |
| print_error "Could not find base branch '$requested_branch' or common defaults (main, master)" | |
| exit 1 | |
| } | |
| usage() { | |
| cat <<EOF | |
| wt - Git Worktree Manager v${VERSION} | |
| USAGE: | |
| Make it executable and add it to your path | |
| chmod u+x wt | |
| wt <command> [options] | |
| COMMANDS: | |
| create, c <branch-name> Create a new worktree with a feature branch | |
| list, ls List all worktrees | |
| rebase, rb [worktree] Fetch and rebase current or specified worktree | |
| remove, rm <worktree> Remove a worktree | |
| cd <worktree> Print path to worktree (use with: cd \$(wt cd name)) | |
| help, h Show this help message | |
| OPTIONS: | |
| -b, --base <branch> Base branch to create from (default: main) | |
| -d, --directory <dir> Parent directory for worktrees (default: sibling to repo) | |
| -p, --prefix <prefix> Prefix for worktree directory name | |
| EXAMPLES: | |
| wt create feature/login Create worktree with feature/login branch | |
| wt c -b develop hotfix/bug Create from develop branch | |
| wt list Show all worktrees | |
| wt rebase Fetch and rebase current worktree | |
| wt rebase feature/login Rebase specific worktree | |
| wt rm feature/login Remove worktree | |
| ENVIRONMENT: | |
| WT_BASE_BRANCH Default base branch (default: main) | |
| WT_WORKTREE_DIR Default parent directory for worktrees | |
| EOF | |
| } | |
| cmd_create() { | |
| local branch_name="" | |
| local base_branch="$BASE_BRANCH" | |
| local worktree_dir="${WT_WORKTREE_DIR:-}" | |
| local prefix="" | |
| # Parse arguments | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -b|--base) | |
| base_branch="$2" | |
| shift 2 | |
| ;; | |
| -d|--directory) | |
| worktree_dir="$2" | |
| shift 2 | |
| ;; | |
| -p|--prefix) | |
| prefix="$2" | |
| shift 2 | |
| ;; | |
| -*) | |
| print_error "Unknown option: $1" | |
| exit 1 | |
| ;; | |
| *) | |
| if [[ -z "$branch_name" ]]; then | |
| branch_name="$1" | |
| else | |
| print_error "Unexpected argument: $1" | |
| exit 1 | |
| fi | |
| shift | |
| ;; | |
| esac | |
| done | |
| if [[ -z "$branch_name" ]]; then | |
| print_error "Branch name required" | |
| echo "Usage: ${SCRIPT_NAME} create <branch-name>" | |
| exit 1 | |
| fi | |
| ensure_git_repo | |
| # Resolve the actual base branch | |
| base_branch=$(resolve_base_branch "$base_branch") | |
| print_info "Using base branch: ${CYAN}${base_branch}${NC}" | |
| # Get repo info | |
| local repo_root | |
| repo_root=$(get_repo_root) | |
| local repo_name | |
| repo_name=$(basename "$repo_root") | |
| # Determine worktree directory name (sanitize branch name for filesystem) | |
| local dir_name | |
| dir_name=$(echo "$branch_name" | tr '/' '-') | |
| if [[ -n "$prefix" ]]; then | |
| dir_name="${prefix}-${dir_name}" | |
| fi | |
| # Determine parent directory for worktrees | |
| if [[ -z "$worktree_dir" ]]; then | |
| worktree_dir=$(dirname "$repo_root") | |
| fi | |
| local worktree_path="${worktree_dir}/${repo_name}-${dir_name}" | |
| # Check if branch already exists | |
| if git show-ref --verify --quiet "refs/heads/$branch_name"; then | |
| print_warning "Branch '${branch_name}' already exists" | |
| read -p "Create worktree from existing branch? [y/N] " -n 1 -r | |
| echo | |
| if [[ ! $REPLY =~ ^[Yy]$ ]]; then | |
| exit 1 | |
| fi | |
| print_info "Creating worktree at ${CYAN}${worktree_path}${NC}" | |
| git worktree add "$worktree_path" "$branch_name" | |
| else | |
| # Fetch latest from remote | |
| print_info "Fetching latest changes..." | |
| git fetch origin "$base_branch" 2>/dev/null || git fetch origin | |
| # Create worktree with new branch from base | |
| print_info "Creating worktree at ${CYAN}${worktree_path}${NC}" | |
| print_info "Creating branch ${CYAN}${branch_name}${NC} from ${CYAN}${base_branch}${NC}" | |
| git worktree add -b "$branch_name" "$worktree_path" "origin/${base_branch}" | |
| fi | |
| print_success "Worktree created successfully" | |
| echo "" | |
| echo -e " ${BOLD}Path:${NC} ${worktree_path}" | |
| echo -e " ${BOLD}Branch:${NC} ${branch_name}" | |
| echo "" | |
| echo -e " ${CYAN}cd ${worktree_path}${NC}" | |
| } | |
| cmd_list() { | |
| ensure_git_repo | |
| echo -e "${BOLD}Git Worktrees:${NC}" | |
| echo "" | |
| local main_worktree | |
| main_worktree=$(get_main_worktree) | |
| # Parse worktree list | |
| local current_path="" | |
| local current_branch="" | |
| local current_head="" | |
| local is_bare=false | |
| while IFS= read -r line; do | |
| if [[ "$line" =~ ^worktree\ (.+)$ ]]; then | |
| # Print previous worktree if exists | |
| if [[ -n "$current_path" ]]; then | |
| print_worktree_entry "$current_path" "$current_branch" "$current_head" "$main_worktree" "$is_bare" | |
| fi | |
| current_path="${BASH_REMATCH[1]}" | |
| current_branch="" | |
| current_head="" | |
| is_bare=false | |
| elif [[ "$line" =~ ^HEAD\ (.+)$ ]]; then | |
| current_head="${BASH_REMATCH[1]:0:8}" | |
| elif [[ "$line" =~ ^branch\ refs/heads/(.+)$ ]]; then | |
| current_branch="${BASH_REMATCH[1]}" | |
| elif [[ "$line" == "bare" ]]; then | |
| is_bare=true | |
| elif [[ "$line" == "detached" ]]; then | |
| current_branch="(detached)" | |
| fi | |
| done < <(git worktree list --porcelain) | |
| # Print last worktree | |
| if [[ -n "$current_path" ]]; then | |
| print_worktree_entry "$current_path" "$current_branch" "$current_head" "$main_worktree" "$is_bare" | |
| fi | |
| echo "" | |
| } | |
| print_worktree_entry() { | |
| local path="$1" | |
| local branch="$2" | |
| local head="$3" | |
| local main_worktree="$4" | |
| local is_bare="$5" | |
| local marker="" | |
| local type_label="" | |
| if [[ "$is_bare" == "true" ]]; then | |
| type_label="${YELLOW}(bare)${NC}" | |
| elif [[ "$path" == "$main_worktree" ]]; then | |
| type_label="${GREEN}(main)${NC}" | |
| fi | |
| # Check if this is current directory | |
| local current_dir | |
| current_dir=$(pwd -P 2>/dev/null || pwd) | |
| if [[ "$current_dir" == "$path"* ]]; then | |
| marker="${GREEN}* ${NC}" | |
| else | |
| marker=" " | |
| fi | |
| local branch_display | |
| if [[ -n "$branch" ]]; then | |
| branch_display="${CYAN}${branch}${NC}" | |
| else | |
| branch_display="${YELLOW}(no branch)${NC}" | |
| fi | |
| echo -e "${marker}${BOLD}${path}${NC}" | |
| echo -e " Branch: ${branch_display} ${type_label}" | |
| echo -e " HEAD: ${head}" | |
| } | |
| cmd_rebase() { | |
| local target_worktree="" | |
| local base_branch="$BASE_BRANCH" | |
| # Parse arguments | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -b|--base) | |
| base_branch="$2" | |
| shift 2 | |
| ;; | |
| -*) | |
| print_error "Unknown option: $1" | |
| exit 1 | |
| ;; | |
| *) | |
| target_worktree="$1" | |
| shift | |
| ;; | |
| esac | |
| done | |
| ensure_git_repo | |
| local worktree_path="" | |
| if [[ -n "$target_worktree" ]]; then | |
| # Find worktree by branch name or path | |
| worktree_path=$(find_worktree "$target_worktree") | |
| if [[ -z "$worktree_path" ]]; then | |
| print_error "Worktree not found: $target_worktree" | |
| exit 1 | |
| fi | |
| else | |
| # Use current directory | |
| worktree_path=$(pwd) | |
| fi | |
| # Resolve base branch | |
| base_branch=$(resolve_base_branch "$base_branch") | |
| print_info "Fetching latest changes..." | |
| git fetch origin "$base_branch" | |
| print_info "Rebasing onto ${CYAN}origin/${base_branch}${NC}..." | |
| # Change to worktree and rebase | |
| ( | |
| cd "$worktree_path" | |
| local current_branch | |
| current_branch=$(git branch --show-current) | |
| print_info "Current branch: ${CYAN}${current_branch}${NC}" | |
| if git rebase "origin/${base_branch}"; then | |
| print_success "Rebase completed successfully" | |
| else | |
| print_error "Rebase failed. Resolve conflicts and run 'git rebase --continue'" | |
| exit 1 | |
| fi | |
| ) | |
| } | |
| find_worktree() { | |
| local search="$1" | |
| # First try exact path match | |
| if [[ -d "$search" ]]; then | |
| echo "$search" | |
| return 0 | |
| fi | |
| # Search by branch name | |
| while IFS= read -r line; do | |
| if [[ "$line" =~ ^worktree\ (.+)$ ]]; then | |
| local path="${BASH_REMATCH[1]}" | |
| local branch="" | |
| # Read next lines for branch info | |
| while IFS= read -r subline; do | |
| if [[ "$subline" =~ ^branch\ refs/heads/(.+)$ ]]; then | |
| branch="${BASH_REMATCH[1]}" | |
| break | |
| elif [[ -z "$subline" ]]; then | |
| break | |
| fi | |
| done | |
| # Check if branch matches search | |
| if [[ "$branch" == "$search" ]] || [[ "$branch" == *"$search"* ]]; then | |
| echo "$path" | |
| return 0 | |
| fi | |
| # Check if path contains search term | |
| if [[ "$path" == *"$search"* ]]; then | |
| echo "$path" | |
| return 0 | |
| fi | |
| fi | |
| done < <(git worktree list --porcelain) | |
| return 1 | |
| } | |
| cmd_remove() { | |
| local target="" | |
| local force=false | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -f|--force) | |
| force=true | |
| shift | |
| ;; | |
| -*) | |
| print_error "Unknown option: $1" | |
| exit 1 | |
| ;; | |
| *) | |
| target="$1" | |
| shift | |
| ;; | |
| esac | |
| done | |
| if [[ -z "$target" ]]; then | |
| print_error "Worktree name or path required" | |
| echo "Usage: ${SCRIPT_NAME} remove <worktree>" | |
| exit 1 | |
| fi | |
| ensure_git_repo | |
| local worktree_path | |
| worktree_path=$(find_worktree "$target") | |
| if [[ -z "$worktree_path" ]]; then | |
| print_error "Worktree not found: $target" | |
| exit 1 | |
| fi | |
| # Get branch name before removing | |
| local branch_name="" | |
| while IFS= read -r line; do | |
| if [[ "$line" =~ ^worktree\ ${worktree_path}$ ]]; then | |
| while IFS= read -r subline; do | |
| if [[ "$subline" =~ ^branch\ refs/heads/(.+)$ ]]; then | |
| branch_name="${BASH_REMATCH[1]}" | |
| break | |
| elif [[ -z "$subline" ]]; then | |
| break | |
| fi | |
| done | |
| break | |
| fi | |
| done < <(git worktree list --porcelain) | |
| print_warning "This will remove worktree at: ${worktree_path}" | |
| if [[ -n "$branch_name" ]]; then | |
| echo -e " Branch: ${CYAN}${branch_name}${NC}" | |
| fi | |
| read -p "Continue? [y/N] " -n 1 -r | |
| echo | |
| if [[ ! $REPLY =~ ^[Yy]$ ]]; then | |
| echo "Aborted." | |
| exit 1 | |
| fi | |
| if [[ "$force" == true ]]; then | |
| git worktree remove --force "$worktree_path" | |
| else | |
| git worktree remove "$worktree_path" | |
| fi | |
| print_success "Worktree removed: ${worktree_path}" | |
| # Optionally delete the branch | |
| if [[ -n "$branch_name" ]]; then | |
| read -p "Also delete branch '${branch_name}'? [y/N] " -n 1 -r | |
| echo | |
| if [[ $REPLY =~ ^[Yy]$ ]]; then | |
| git branch -d "$branch_name" 2>/dev/null || git branch -D "$branch_name" | |
| print_success "Branch deleted: ${branch_name}" | |
| fi | |
| fi | |
| } | |
| cmd_cd() { | |
| local target="$1" | |
| if [[ -z "$target" ]]; then | |
| print_error "Worktree name or path required" | |
| echo "Usage: cd \$(${SCRIPT_NAME} cd <worktree>)" | |
| exit 1 | |
| fi | |
| ensure_git_repo | |
| local worktree_path | |
| worktree_path=$(find_worktree "$target") | |
| if [[ -z "$worktree_path" ]]; then | |
| print_error "Worktree not found: $target" >&2 | |
| exit 1 | |
| fi | |
| echo "$worktree_path" | |
| } | |
| # Main entry point | |
| main() { | |
| if [[ $# -eq 0 ]]; then | |
| usage | |
| exit 0 | |
| fi | |
| local command="$1" | |
| shift | |
| case "$command" in | |
| create|c) | |
| cmd_create "$@" | |
| ;; | |
| list|ls) | |
| cmd_list "$@" | |
| ;; | |
| rebase|rb) | |
| cmd_rebase "$@" | |
| ;; | |
| remove|rm) | |
| cmd_remove "$@" | |
| ;; | |
| cd) | |
| cmd_cd "$@" | |
| ;; | |
| help|h|-h|--help) | |
| usage | |
| ;; | |
| --version|-v) | |
| echo "${SCRIPT_NAME} v${VERSION}" | |
| ;; | |
| *) | |
| print_error "Unknown command: $command" | |
| echo "Run '${SCRIPT_NAME} help' for usage" | |
| exit 1 | |
| ;; | |
| esac | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment