Skip to content

Instantly share code, notes, and snippets.

@thomashartm
Created January 20, 2026 12:02
Show Gist options
  • Select an option

  • Save thomashartm/67851e82a32310385ec14c429f90ad0c to your computer and use it in GitHub Desktop.

Select an option

Save thomashartm/67851e82a32310385ec14c429f90ad0c to your computer and use it in GitHub Desktop.
Git Worktree Manager - A simple CLI to create and manage git worktrees
#!/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