|
#!/usr/bin/env bash |
|
set -euo pipefail |
|
|
|
# ════════════════════════════════════════════════════════════════════ |
|
# Git Worktree Manager |
|
# |
|
# Manages parallel development environments with isolated Docker |
|
# containers, ports, databases, and volumes. |
|
# ════════════════════════════════════════════════════════════════════ |
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" |
|
|
|
# Default port offsets from base |
|
PORT_INCREMENT=1000 |
|
FIRST_BASE_PORT=4000 |
|
# Ports reserved by the OS (e.g. macOS AirPlay Receiver on 5000/7000) |
|
SKIP_BASE_PORTS="5000 7000" |
|
|
|
# Colors |
|
RED='\033[0;31m' |
|
GREEN='\033[0;32m' |
|
YELLOW='\033[1;33m' |
|
BLUE='\033[0;34m' |
|
CYAN='\033[0;36m' |
|
BOLD='\033[1m' |
|
NC='\033[0m' |
|
|
|
# ──────────────────────────────────────────────────────────────────── |
|
# Helpers |
|
# ──────────────────────────────────────────────────────────────────── |
|
|
|
info() { echo -e "${BLUE}[INFO]${NC} $*"; } |
|
success() { echo -e "${GREEN}[OK]${NC} $*"; } |
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } |
|
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } |
|
die() { error "$@"; exit 1; } |
|
|
|
usage() { |
|
cat <<EOF |
|
${BOLD}Usage:${NC} bin/worktree <command> [options] |
|
|
|
${BOLD}Commands:${NC} |
|
create <branch> [--base-port N] Create a worktree with Docker config |
|
list List worktrees with ports and status |
|
remove <name> [--force] Remove worktree + containers + volumes |
|
status [name] Show detailed status |
|
start [name] Start Docker containers |
|
stop [name] Stop Docker containers |
|
|
|
${BOLD}Examples:${NC} |
|
bin/worktree create feature/auth |
|
bin/worktree create feature/auth --base-port 5000 |
|
bin/worktree start feature-auth |
|
bin/worktree stop feature-auth |
|
bin/worktree remove feature-auth |
|
bin/worktree list |
|
|
|
${BOLD}Port allocation (increments of $PORT_INCREMENT from $FIRST_BASE_PORT):${NC} |
|
Worktree 1: base=$FIRST_BASE_PORT → nginx=$((FIRST_BASE_PORT+1)), api=$FIRST_BASE_PORT, ... |
|
Worktree 2: base=$((FIRST_BASE_PORT+PORT_INCREMENT)) → nginx=$((FIRST_BASE_PORT+PORT_INCREMENT+1)), ... |
|
EOF |
|
} |
|
|
|
# Sanitize branch name to a valid worktree directory name |
|
sanitize_name() { |
|
echo "$1" | sed 's|/|-|g' | sed 's|[^a-zA-Z0-9_-]|-|g' |
|
} |
|
|
|
# Calculate ports from a base port |
|
calc_ports() { |
|
local base=$1 |
|
NGINX_PORT=$((base + 1)) |
|
API_PORT=$base |
|
WEB_PORT=$((base + 2173)) |
|
CHAT_PORT=$((base + 5080)) |
|
POSTGRES_PORT=$((base + 2432)) |
|
REDIS_PORT=$((base + 3379)) |
|
DEBUG_API_PORT=$((base + 35697)) |
|
DEBUG_SIDEKIQ_PORT=$((base + 35698)) |
|
} |
|
|
|
# Find the next available base port |
|
find_next_base_port() { |
|
local port=$FIRST_BASE_PORT |
|
while true; do |
|
local in_use=false |
|
# Check all worktrees' .env.worktree files for this base port |
|
while IFS= read -r wt_path; do |
|
[ -z "$wt_path" ] && continue |
|
local env_file="$wt_path/.env.worktree" |
|
if [ -f "$env_file" ]; then |
|
local existing_base |
|
existing_base=$(grep "^API_PORT=" "$env_file" 2>/dev/null | cut -d= -f2) |
|
if [ "$existing_base" = "$port" ]; then |
|
in_use=true |
|
break |
|
fi |
|
fi |
|
done < <(git -C "$REPO_ROOT" worktree list --porcelain | grep "^worktree " | sed 's/^worktree //') |
|
|
|
# Skip ports reserved by the OS |
|
if echo "$SKIP_BASE_PORTS" | grep -qw "$port"; then |
|
in_use=true |
|
fi |
|
|
|
if ! $in_use; then |
|
echo "$port" |
|
return |
|
fi |
|
port=$((port + PORT_INCREMENT)) |
|
done |
|
} |
|
|
|
# Check if a port is already bound on the host |
|
check_port_available() { |
|
local port=$1 |
|
if lsof -i :"$port" -sTCP:LISTEN >/dev/null 2>&1; then |
|
return 1 |
|
fi |
|
return 0 |
|
} |
|
|
|
# Get the worktree path by name |
|
get_worktree_path() { |
|
local name="$1" |
|
local worktrees_dir="$REPO_ROOT/.worktrees" |
|
local wt_path="$worktrees_dir/$name" |
|
|
|
if [ -d "$wt_path" ]; then |
|
echo "$wt_path" |
|
return 0 |
|
fi |
|
|
|
# Try to find by matching in git worktree list |
|
while IFS= read -r wt_path; do |
|
[ -z "$wt_path" ] && continue |
|
local wt_name |
|
wt_name=$(basename "$wt_path") |
|
if [ "$wt_name" = "$name" ]; then |
|
echo "$wt_path" |
|
return 0 |
|
fi |
|
done < <(git -C "$REPO_ROOT" worktree list --porcelain | grep "^worktree " | sed 's/^worktree //') |
|
|
|
return 1 |
|
} |
|
|
|
# Resolve the real .git directory (handles worktrees where .git is a file) |
|
resolve_git_dir() { |
|
local repo="$1" |
|
if [ -f "$repo/.git" ]; then |
|
# Worktree: .git is a file pointing to the real git dir |
|
local gitdir |
|
gitdir=$(grep "^gitdir:" "$repo/.git" | sed 's/^gitdir: //') |
|
# Resolve relative path |
|
if [[ ! "$gitdir" = /* ]]; then |
|
gitdir="$repo/$gitdir" |
|
fi |
|
# Go up from .git/worktrees/<name> to .git |
|
echo "$(cd "$(dirname "$(dirname "$gitdir")")" && pwd)" |
|
elif [ -d "$repo/.git" ]; then |
|
echo "$repo/.git" |
|
else |
|
die "Cannot find .git in $repo" |
|
fi |
|
} |
|
|
|
# ──────────────────────────────────────────────────────────────────── |
|
# Commands |
|
# ──────────────────────────────────────────────────────────────────── |
|
|
|
cmd_create() { |
|
local branch="" |
|
local base_port="" |
|
|
|
# Parse arguments |
|
while [[ $# -gt 0 ]]; do |
|
case "$1" in |
|
--base-port) |
|
base_port="$2" |
|
shift 2 |
|
;; |
|
-*) |
|
die "Unknown option: $1" |
|
;; |
|
*) |
|
if [ -z "$branch" ]; then |
|
branch="$1" |
|
else |
|
die "Unexpected argument: $1" |
|
fi |
|
shift |
|
;; |
|
esac |
|
done |
|
|
|
[ -z "$branch" ] && die "Branch name required. Usage: bin/worktree create <branch> [--base-port N]" |
|
|
|
local name |
|
name=$(sanitize_name "$branch") |
|
local worktrees_dir="$REPO_ROOT/.worktrees" |
|
local wt_path="$worktrees_dir/$name" |
|
|
|
# Check if worktree already exists |
|
if [ -d "$wt_path" ]; then |
|
die "Worktree '$name' already exists at $wt_path" |
|
fi |
|
|
|
mkdir -p "$worktrees_dir" |
|
|
|
# Determine base port |
|
if [ -z "$base_port" ]; then |
|
base_port=$(find_next_base_port) |
|
fi |
|
calc_ports "$base_port" |
|
|
|
info "Creating worktree '${BOLD}$name${NC}' from branch '${BOLD}$branch${NC}'" |
|
info "Base port: $base_port" |
|
|
|
# Check if branch exists (local or remote) |
|
if git -C "$REPO_ROOT" show-ref --verify --quiet "refs/heads/$branch" 2>/dev/null; then |
|
info "Using existing local branch '$branch'" |
|
git -C "$REPO_ROOT" worktree add "$wt_path" "$branch" |
|
elif git -C "$REPO_ROOT" show-ref --verify --quiet "refs/remotes/origin/$branch" 2>/dev/null; then |
|
info "Creating local branch from 'origin/$branch'" |
|
git -C "$REPO_ROOT" worktree add "$wt_path" -b "$branch" "origin/$branch" |
|
else |
|
info "Creating new branch '$branch' from current HEAD" |
|
git -C "$REPO_ROOT" worktree add "$wt_path" -b "$branch" |
|
fi |
|
|
|
# Resolve the main repo's .git directory |
|
local git_dir |
|
git_dir=$(resolve_git_dir "$REPO_ROOT") |
|
|
|
local project_name="myproject-$name" |
|
|
|
# Generate .env.worktree |
|
cat > "$wt_path/.env.worktree" <<EOF |
|
# Generated by bin/worktree - do not edit manually |
|
# Worktree: $name |
|
# Branch: $branch |
|
# Created: $(date -u +"%Y-%m-%dT%H:%M:%SZ") |
|
|
|
COMPOSE_PROJECT_NAME=$project_name |
|
GIT_DIR=$git_dir |
|
|
|
# Ports |
|
NGINX_PORT=$NGINX_PORT |
|
API_PORT=$API_PORT |
|
WEB_PORT=$WEB_PORT |
|
CHAT_PORT=$CHAT_PORT |
|
POSTGRES_PORT=$POSTGRES_PORT |
|
REDIS_PORT=$REDIS_PORT |
|
DEBUG_API_PORT=$DEBUG_API_PORT |
|
DEBUG_SIDEKIQ_PORT=$DEBUG_SIDEKIQ_PORT |
|
EOF |
|
|
|
success "Created .env.worktree" |
|
|
|
# Copy api/.env if it exists |
|
if [ -f "$REPO_ROOT/api/.env" ]; then |
|
cp "$REPO_ROOT/api/.env" "$wt_path/api/.env" |
|
success "Copied api/.env" |
|
fi |
|
|
|
# Copy and adapt chat/.env if it exists |
|
if [ -f "$REPO_ROOT/chat/.env" ]; then |
|
sed "s|localhost:3001|localhost:$NGINX_PORT|g" "$REPO_ROOT/chat/.env" > "$wt_path/chat/.env" |
|
success "Copied and adapted chat/.env (port: $NGINX_PORT)" |
|
fi |
|
|
|
echo "" |
|
echo -e "${BOLD}════════════════════════════════════════════════════════════════${NC}" |
|
echo -e "${GREEN}${BOLD} Worktree '$name' created successfully!${NC}" |
|
echo -e "${BOLD}════════════════════════════════════════════════════════════════${NC}" |
|
echo "" |
|
echo -e "${BOLD}Path:${NC} $wt_path" |
|
echo -e "${BOLD}Branch:${NC} $branch" |
|
echo -e "${BOLD}Project:${NC} $project_name" |
|
echo "" |
|
echo -e "${BOLD}URLs:${NC}" |
|
echo -e " API: ${CYAN}http://localhost:$NGINX_PORT${NC}" |
|
echo -e " Web: ${CYAN}http://localhost:$NGINX_PORT/next${NC}" |
|
echo -e " Chat: ${CYAN}http://localhost:$NGINX_PORT/chat${NC}" |
|
echo "" |
|
echo -e "${BOLD}To start:${NC}" |
|
echo -e " cd $wt_path" |
|
echo -e " docker compose --env-file .env.worktree up -d" |
|
echo -e " ${YELLOW}# or: make up (auto-detects .env.worktree)${NC}" |
|
echo "" |
|
echo -e "${BOLD}To setup database:${NC}" |
|
echo -e " docker compose --env-file .env.worktree exec api bundle exec rails db:schema:load db:seed" |
|
echo "" |
|
} |
|
|
|
cmd_list() { |
|
echo -e "${BOLD}Git Worktrees:${NC}" |
|
echo "" |
|
|
|
local format=" %-25s %-20s %-10s %-8s %s\n" |
|
printf "$format" "NAME" "BRANCH" "NGINX" "DOCKER" "PATH" |
|
printf "$format" "────────────────────────" "───────────────────" "─────────" "───────" "────────────────────" |
|
|
|
while IFS= read -r line; do |
|
local wt_path wt_branch wt_bare |
|
wt_path=$(echo "$line" | awk '{print $1}') |
|
wt_branch=$(echo "$line" | awk '{print $3}' | tr -d '[]') |
|
|
|
[ -z "$wt_path" ] && continue |
|
|
|
local wt_name |
|
wt_name=$(basename "$wt_path") |
|
local nginx_port="-" |
|
local docker_status="-" |
|
|
|
# Read .env.worktree if it exists |
|
if [ -f "$wt_path/.env.worktree" ]; then |
|
nginx_port=$(grep "^NGINX_PORT=" "$wt_path/.env.worktree" 2>/dev/null | cut -d= -f2) |
|
local project |
|
project=$(grep "^COMPOSE_PROJECT_NAME=" "$wt_path/.env.worktree" 2>/dev/null | cut -d= -f2) |
|
if [ -n "$project" ]; then |
|
local running |
|
running=$(docker ps --filter "name=${project}-" --format '{{.Names}}' 2>/dev/null | wc -l | tr -d ' ') |
|
if [ "$running" -gt 0 ]; then |
|
docker_status="${GREEN}up ($running)${NC}" |
|
else |
|
docker_status="${YELLOW}down${NC}" |
|
fi |
|
fi |
|
elif [ "$wt_path" = "$REPO_ROOT" ]; then |
|
nginx_port="3001" |
|
local running |
|
running=$(docker ps --filter "name=myproject-" --format '{{.Names}}' 2>/dev/null | wc -l | tr -d ' ') |
|
if [ "$running" -gt 0 ]; then |
|
docker_status="${GREEN}up ($running)${NC}" |
|
else |
|
docker_status="${YELLOW}down${NC}" |
|
fi |
|
fi |
|
|
|
printf " %-25s %-20s %-10s " "$wt_name" "$wt_branch" "$nginx_port" |
|
echo -e "$docker_status\t$wt_path" |
|
|
|
done < <(git -C "$REPO_ROOT" worktree list) |
|
|
|
echo "" |
|
} |
|
|
|
cmd_remove() { |
|
local name="" |
|
local force=false |
|
|
|
while [[ $# -gt 0 ]]; do |
|
case "$1" in |
|
--force|-f) |
|
force=true |
|
shift |
|
;; |
|
-*) |
|
die "Unknown option: $1" |
|
;; |
|
*) |
|
name="$1" |
|
shift |
|
;; |
|
esac |
|
done |
|
|
|
[ -z "$name" ] && die "Worktree name required. Usage: bin/worktree remove <name> [--force]" |
|
|
|
local wt_path |
|
wt_path=$(get_worktree_path "$name") || die "Worktree '$name' not found" |
|
|
|
# Don't allow removing the main worktree |
|
if [ "$wt_path" = "$REPO_ROOT" ]; then |
|
die "Cannot remove the main worktree" |
|
fi |
|
|
|
info "Removing worktree '${BOLD}$name${NC}' at $wt_path" |
|
|
|
# Stop Docker containers if running |
|
if [ -f "$wt_path/.env.worktree" ]; then |
|
local project |
|
project=$(grep "^COMPOSE_PROJECT_NAME=" "$wt_path/.env.worktree" 2>/dev/null | cut -d= -f2) |
|
if [ -n "$project" ]; then |
|
local running |
|
running=$(docker ps --filter "name=${project}-" --format '{{.Names}}' 2>/dev/null | wc -l | tr -d ' ') |
|
if [ "$running" -gt 0 ]; then |
|
info "Stopping Docker containers for project '$project'..." |
|
(cd "$wt_path" && docker compose --env-file .env.worktree down -v 2>/dev/null) || true |
|
success "Containers stopped and volumes removed" |
|
else |
|
# Remove volumes even if containers are down |
|
info "Cleaning up Docker volumes for project '$project'..." |
|
(cd "$wt_path" && docker compose --env-file .env.worktree down -v 2>/dev/null) || true |
|
fi |
|
fi |
|
fi |
|
|
|
# Remove git worktree |
|
if $force; then |
|
git -C "$REPO_ROOT" worktree remove --force "$wt_path" 2>/dev/null || true |
|
else |
|
git -C "$REPO_ROOT" worktree remove "$wt_path" 2>/dev/null || { |
|
warn "Worktree has uncommitted changes. Use --force to remove anyway." |
|
exit 1 |
|
} |
|
fi |
|
|
|
success "Worktree '$name' removed" |
|
} |
|
|
|
cmd_status() { |
|
local name="${1:-}" |
|
|
|
if [ -n "$name" ]; then |
|
local wt_path |
|
wt_path=$(get_worktree_path "$name") || die "Worktree '$name' not found" |
|
_show_status "$wt_path" "$name" |
|
else |
|
# Show status of current directory if it's a worktree |
|
if [ -f "$PWD/.env.worktree" ]; then |
|
_show_status "$PWD" "$(basename "$PWD")" |
|
else |
|
cmd_list |
|
fi |
|
fi |
|
} |
|
|
|
_show_status() { |
|
local wt_path="$1" |
|
local name="$2" |
|
|
|
echo -e "${BOLD}Worktree: $name${NC}" |
|
echo -e "${BOLD}Path:${NC} $wt_path" |
|
|
|
# Branch info |
|
local branch |
|
branch=$(git -C "$wt_path" branch --show-current 2>/dev/null || echo "unknown") |
|
echo -e "${BOLD}Branch:${NC} $branch" |
|
|
|
if [ -f "$wt_path/.env.worktree" ]; then |
|
echo "" |
|
echo -e "${BOLD}Configuration (.env.worktree):${NC}" |
|
while IFS='=' read -r key value; do |
|
[[ "$key" =~ ^#.* ]] && continue |
|
[ -z "$key" ] && continue |
|
printf " %-22s %s\n" "$key" "$value" |
|
done < "$wt_path/.env.worktree" |
|
|
|
local project |
|
project=$(grep "^COMPOSE_PROJECT_NAME=" "$wt_path/.env.worktree" 2>/dev/null | cut -d= -f2) |
|
if [ -n "$project" ]; then |
|
echo "" |
|
echo -e "${BOLD}Docker containers:${NC}" |
|
docker ps --filter "name=${project}-" --format ' {{.Names}}\t{{.Status}}\t{{.Ports}}' 2>/dev/null || echo " (none running)" |
|
fi |
|
|
|
local nginx_port |
|
nginx_port=$(grep "^NGINX_PORT=" "$wt_path/.env.worktree" 2>/dev/null | cut -d= -f2) |
|
if [ -n "$nginx_port" ]; then |
|
echo "" |
|
echo -e "${BOLD}URLs:${NC}" |
|
echo -e " API: ${CYAN}http://localhost:$nginx_port${NC}" |
|
echo -e " Web: ${CYAN}http://localhost:$nginx_port/next${NC}" |
|
echo -e " Chat: ${CYAN}http://localhost:$nginx_port/chat${NC}" |
|
fi |
|
else |
|
echo -e " ${YELLOW}No .env.worktree found (main worktree)${NC}" |
|
fi |
|
echo "" |
|
} |
|
|
|
cmd_start() { |
|
local name="${1:-}" |
|
local wt_path |
|
|
|
if [ -n "$name" ]; then |
|
wt_path=$(get_worktree_path "$name") || die "Worktree '$name' not found" |
|
elif [ -f "$PWD/.env.worktree" ]; then |
|
wt_path="$PWD" |
|
name=$(basename "$PWD") |
|
else |
|
die "No worktree name specified and not inside a worktree directory" |
|
fi |
|
|
|
[ -f "$wt_path/.env.worktree" ] || die "No .env.worktree found in $wt_path" |
|
|
|
info "Starting Docker containers for worktree '${BOLD}$name${NC}'..." |
|
(cd "$wt_path" && docker compose --env-file .env.worktree up -d) |
|
success "Containers started" |
|
|
|
local nginx_port |
|
nginx_port=$(grep "^NGINX_PORT=" "$wt_path/.env.worktree" | cut -d= -f2) |
|
echo "" |
|
echo -e "${BOLD}Access URLs:${NC}" |
|
echo -e " API: ${CYAN}http://localhost:$nginx_port${NC}" |
|
echo -e " Web: ${CYAN}http://localhost:$nginx_port/next${NC}" |
|
echo -e " Chat: ${CYAN}http://localhost:$nginx_port/chat${NC}" |
|
} |
|
|
|
cmd_stop() { |
|
local name="${1:-}" |
|
local wt_path |
|
|
|
if [ -n "$name" ]; then |
|
wt_path=$(get_worktree_path "$name") || die "Worktree '$name' not found" |
|
elif [ -f "$PWD/.env.worktree" ]; then |
|
wt_path="$PWD" |
|
name=$(basename "$PWD") |
|
else |
|
die "No worktree name specified and not inside a worktree directory" |
|
fi |
|
|
|
[ -f "$wt_path/.env.worktree" ] || die "No .env.worktree found in $wt_path" |
|
|
|
info "Stopping Docker containers for worktree '${BOLD}$name${NC}'..." |
|
(cd "$wt_path" && docker compose --env-file .env.worktree down) |
|
success "Containers stopped" |
|
} |
|
|
|
# ──────────────────────────────────────────────────────────────────── |
|
# Main |
|
# ──────────────────────────────────────────────────────────────────── |
|
|
|
main() { |
|
local cmd="${1:-}" |
|
shift 2>/dev/null || true |
|
|
|
case "$cmd" in |
|
create) cmd_create "$@" ;; |
|
list|ls) cmd_list ;; |
|
remove|rm) cmd_remove "$@" ;; |
|
status) cmd_status "$@" ;; |
|
start) cmd_start "$@" ;; |
|
stop) cmd_stop "$@" ;; |
|
help|-h|--help) usage ;; |
|
"") usage; exit 1 ;; |
|
*) die "Unknown command: $cmd. Run 'bin/worktree help' for usage." ;; |
|
esac |
|
} |
|
|
|
main "$@" |