Last active
February 4, 2026 11:27
-
-
Save wojtha/bc41e6bdddbb78183c0d25bded21a291 to your computer and use it in GitHub Desktop.
cwm - Claude Worktree Manager | Installation: curl -fsSL "https://gist.githubusercontent.com/wojtha/bc41e6bdddbb78183c0d25bded21a291/raw/install.sh?$(date +%s)" | bash
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 | |
| # cwm - Claude Worktree Manager | |
| # Worktrees are created under .claude/worktrees/ in each repository | |
| # | |
| # This script is designed to be sourced via a shell function for cd to work: | |
| # cwm() { source ~/.local/bin/cwm.sh "$@"; } | |
| # Run everything in a subshell to avoid polluting the parent shell's state | |
| _cwm_result=$( | |
| set -euo pipefail | |
| CWM_VERSION="1.0.0" | |
| CWM_DATE="2026-02-04" | |
| # Colors and output helpers | |
| err() { echo -e "\033[0;31m✗ $*\033[0m" >&2; } | |
| ok() { echo -e "\033[0;32m✓ $*\033[0m" >&2; } | |
| warn() { echo -e "\033[0;33m⚠ $*\033[0m" >&2; } | |
| info() { echo -e "\033[0;34m$*\033[0m" >&2; } | |
| # Core helpers | |
| _cwm_main_repo() { git rev-parse --git-common-dir 2>/dev/null | xargs dirname; } | |
| _cwm_default_branch() { git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo "main"; } | |
| _cwm_current_branch() { git branch --show-current 2>/dev/null; } | |
| _cwm_sanitize() { echo "$1" | tr '/' '-'; } | |
| _cwm_in_worktree() { | |
| [[ "$(git rev-parse --git-dir 2>/dev/null)" != "$(git rev-parse --git-common-dir 2>/dev/null)" ]] | |
| } | |
| # Find branch from sanitized name | |
| _cwm_find_branch() { | |
| local input="$1" with_slash="${1/-/\/}" | |
| git show-ref --verify --quiet "refs/heads/$with_slash" 2>/dev/null && echo "$with_slash" && return | |
| git show-ref --verify --quiet "refs/heads/$input" 2>/dev/null && echo "$input" && return | |
| git for-each-ref --format='%(refname:short)' refs/heads/ | while read -r b; do | |
| [[ "$(_cwm_sanitize "$b")" == "$input" ]] && echo "$b" && return | |
| done | |
| } | |
| # Get worktree path for branch from git worktree list | |
| _cwm_get_worktree_path() { | |
| git worktree list --porcelain | awk -v b="$1" ' | |
| /^worktree / { path = substr($0, 10) } | |
| /^branch refs\/heads\// { if (substr($0, 19) == b) print path } | |
| ' | |
| } | |
| _cwm_worktree_path() { echo "$(_cwm_main_repo)/.claude/worktrees/$(_cwm_sanitize "$1")"; } | |
| _cwm_has_unpushed() { | |
| local upstream | |
| upstream="$(git rev-parse --abbrev-ref "$1@{upstream}" 2>/dev/null)" || return 1 | |
| [[ -n "$(git log "$upstream..$1" --oneline 2>/dev/null)" ]] | |
| } | |
| _cwm_ensure_gitignore() { | |
| local gitignore="$(_cwm_main_repo)/.gitignore" | |
| grep -q "^\.claude/worktrees" "$gitignore" 2>/dev/null || echo ".claude/worktrees" >> "$gitignore" | |
| } | |
| # Resolve branch: use input, find it, or fall back to input | |
| _cwm_resolve_branch() { | |
| local input="$1" found | |
| found="$(_cwm_find_branch "$input")" | |
| echo "${found:-$input}" | |
| } | |
| # Get branch for current context (arg or current worktree) | |
| _cwm_get_branch_arg() { | |
| local branch="$1" cmd="$2" | |
| if [[ -z "$branch" ]]; then | |
| if _cwm_in_worktree; then | |
| _cwm_current_branch | |
| else | |
| err "Not in a worktree. Specify branch name: cwm $cmd <branch>" | |
| return 1 | |
| fi | |
| else | |
| echo "$branch" | |
| fi | |
| } | |
| _cwm_help() { | |
| cat >&2 <<EOF | |
| cwm - Claude Worktree Manager v${CWM_VERSION} (${CWM_DATE}) | |
| Usage: | |
| cwm Show this help | |
| cwm install Add cwm function to shell rc | |
| cwm list List all worktrees | |
| cwm create Create worktree for current branch (or prompt) | |
| cwm create NAME Create worktree for branch NAME | |
| cwm remove Remove current worktree (checks for changes) | |
| cwm force-remove Force remove worktree (warns but proceeds) | |
| cwm cd NAME Change to worktree for branch NAME | |
| cwm status Show git status of current worktree | |
| Aliases: create=new=add, remove=rm, force-remove=rmf, list=ls, cd=go, status=st | |
| Worktrees are stored in .claude/worktrees/<branch-name> | |
| Branch slashes are converted to dashes (vk/feature → vk-feature) | |
| EOF | |
| } | |
| _cwm_list() { | |
| info "Worktrees in $(_cwm_main_repo):" | |
| echo >&2 | |
| git worktree list | while IFS= read -r line; do | |
| local wt_path="${line%% *}" wt_branch="${line##*\[}" wt_status="clean" | |
| wt_branch="${wt_branch%\]}" | |
| if [[ -d "$wt_path" ]]; then | |
| if ! git -C "$wt_path" diff --quiet 2>/dev/null || ! git -C "$wt_path" diff --cached --quiet 2>/dev/null; then | |
| wt_status="dirty" | |
| elif [[ -n "$(git -C "$wt_path" ls-files --others --exclude-standard 2>/dev/null)" ]]; then | |
| wt_status="untracked" | |
| fi | |
| fi | |
| printf " %-30s %s (%s)\n" "$wt_branch" "$wt_path" "$wt_status" >&2 | |
| done | |
| } | |
| _cwm_create() { | |
| local branch="$1" main_repo default_branch current_branch | |
| main_repo="$(_cwm_main_repo)" | |
| default_branch="$(_cwm_default_branch)" | |
| current_branch="$(_cwm_current_branch)" | |
| if [[ -z "$branch" ]]; then | |
| if [[ "$current_branch" == "$default_branch" ]]; then | |
| printf "Enter new branch name: " >&2 | |
| read -r branch </dev/tty 2>/dev/null || branch="" | |
| [[ -z "$branch" ]] && err "Branch name required" && return 1 | |
| else | |
| branch="$current_branch" | |
| fi | |
| fi | |
| # Check if already checked out | |
| local existing_path | |
| existing_path="$(_cwm_get_worktree_path "$branch")" | |
| if [[ -n "$existing_path" ]]; then | |
| warn "Branch '$branch' is already checked out at: $existing_path" | |
| printf "Go there instead? [Y/n]: " >&2 | |
| read -r go_there </dev/tty 2>/dev/null || go_there="" | |
| [[ "$go_there" != "n" && "$go_there" != "N" ]] && echo "CD:$existing_path" && return 0 | |
| return 1 | |
| fi | |
| local worktree_path="$(_cwm_worktree_path "$branch")" | |
| [[ -d "$worktree_path" ]] && err "Worktree already exists: $worktree_path" && return 1 | |
| mkdir -p "$main_repo/.claude/worktrees" | |
| _cwm_ensure_gitignore | |
| if ! git show-ref --verify --quiet "refs/heads/$branch" 2>/dev/null; then | |
| info "Branch '$branch' does not exist." | |
| printf "Base branch [$default_branch]: " >&2 | |
| read -r base_branch </dev/tty 2>/dev/null || base_branch="" | |
| git branch "$branch" "${base_branch:-$default_branch}" >&2 | |
| fi | |
| info "Creating worktree at $worktree_path..." | |
| git worktree add "$worktree_path" "$branch" >&2 | |
| ok "Created worktree for '$branch'" | |
| echo "CD:$worktree_path" | |
| } | |
| _cwm_remove() { | |
| local branch force="${2:-}" main_repo | |
| branch="$(_cwm_get_branch_arg "$1" "remove")" || return 1 | |
| branch="$(_cwm_resolve_branch "$branch")" | |
| main_repo="$(_cwm_main_repo)" | |
| local worktree_path="$(_cwm_worktree_path "$branch")" | |
| [[ ! -d "$worktree_path" ]] && err "Worktree not found: $worktree_path" && return 1 | |
| # Check for uncommitted work | |
| local has_changes=false has_untracked=false | |
| if ! git -C "$worktree_path" diff --quiet 2>/dev/null || ! git -C "$worktree_path" diff --cached --quiet 2>/dev/null; then | |
| has_changes=true | |
| fi | |
| if [[ -n "$(git -C "$worktree_path" ls-files --others --exclude-standard 2>/dev/null)" ]]; then | |
| has_untracked=true | |
| fi | |
| if [[ "$force" != "force" ]] && { $has_changes || $has_untracked; }; then | |
| err "Worktree has uncommitted changes or untracked files!" | |
| info "Run 'cwm status $(_cwm_sanitize "$branch")' to see the changes" | |
| info "Use 'cwm force-remove' to remove anyway" | |
| return 2 | |
| fi | |
| $has_changes && warn "Worktree has uncommitted changes (forcing anyway)" | |
| $has_untracked && warn "Worktree has untracked files (forcing anyway)" | |
| _cwm_has_unpushed "$branch" && warn "Branch has unpushed commits" | |
| info "Removing worktree '$(_cwm_sanitize "$branch")'..." | |
| git -C "$main_repo" worktree remove --force "$worktree_path" >&2 | |
| printf "Delete branch '$branch' too? [y/N]: " >&2 | |
| read -r delete_branch </dev/tty 2>/dev/null || delete_branch="" | |
| if [[ "$delete_branch" == "y" || "$delete_branch" == "Y" ]]; then | |
| if _cwm_has_unpushed "$branch"; then | |
| warn "Branch has unpushed commits!" | |
| printf "Delete anyway? [y/N]: " >&2 | |
| read -r confirm </dev/tty 2>/dev/null || confirm="" | |
| [[ "$confirm" != "y" && "$confirm" != "Y" ]] && ok "Worktree removed (branch kept)" && echo "CD:$main_repo" && return 0 | |
| fi | |
| git -C "$main_repo" branch -D "$branch" >&2 | |
| ok "Worktree and branch removed" | |
| else | |
| ok "Worktree removed (branch kept)" | |
| fi | |
| echo "CD:$main_repo" | |
| } | |
| _cwm_cd() { | |
| local branch="$1" | |
| [[ -z "$branch" ]] && err "Usage: cwm cd <branch>" && return 1 | |
| local main_repo="$(_cwm_main_repo)" default_branch="$(_cwm_default_branch)" | |
| [[ "$branch" == "$default_branch" || "$branch" == "main" || "$branch" == "master" ]] && echo "CD:$main_repo" && return | |
| branch="$(_cwm_resolve_branch "$branch")" | |
| local worktree_path="$(_cwm_get_worktree_path "$branch")" | |
| if [[ -z "$worktree_path" || ! -d "$worktree_path" ]]; then | |
| err "No worktree found for branch: $branch" | |
| err "Create it with: cwm create $branch" | |
| return 1 | |
| fi | |
| echo "CD:$worktree_path" | |
| } | |
| _cwm_status() { | |
| local branch | |
| branch="$(_cwm_get_branch_arg "$1" "status")" || return 1 | |
| branch="$(_cwm_resolve_branch "$branch")" | |
| local worktree_path="$(_cwm_worktree_path "$branch")" | |
| [[ ! -d "$worktree_path" ]] && err "Worktree not found: $worktree_path" && return 1 | |
| info "Status of worktree: $branch" | |
| info "Path: $worktree_path" | |
| echo >&2 | |
| git -C "$worktree_path" status --short --branch >&2 | |
| } | |
| _cwm_install() { | |
| local shell_rc="$HOME/.bashrc" | |
| [[ -n "${ZSH_VERSION:-}" || "$SHELL" == */zsh ]] && shell_rc="$HOME/.zshrc" | |
| if grep -q 'cwm().*source.*cwm.sh' "$shell_rc" 2>/dev/null; then | |
| info "Already installed in $shell_rc" | |
| return 0 | |
| fi | |
| echo -e "\n# Claude Worktree Manager\ncwm() { source ~/.local/bin/cwm.sh \"\$@\"; }" >> "$shell_rc" | |
| ok "Installed to $shell_rc" | |
| info "Run: source $shell_rc" | |
| } | |
| _cwm_main() { | |
| local cmd="${1:-}" | |
| shift 2>/dev/null || true | |
| case "$cmd" in | |
| install) _cwm_install; return ;; | |
| ""|help|-h|--help) _cwm_help; return ;; | |
| esac | |
| git rev-parse --git-dir >/dev/null 2>&1 || { err "Not in a git repository"; return 1; } | |
| case "$cmd" in | |
| list|ls) _cwm_list ;; | |
| create|new|add) _cwm_create "${1:-}" ;; | |
| remove|rm) _cwm_remove "${1:-}" ;; | |
| force-remove|rmf) _cwm_remove "${1:-}" "force" ;; | |
| cd|go) _cwm_cd "${1:-}" ;; | |
| status|st) _cwm_status "${1:-}" ;; | |
| *) err "Unknown command: $cmd"; _cwm_help; return 1 ;; | |
| esac | |
| } | |
| _cwm_main "$@" | |
| ) | |
| _cwm_exit_code=$? | |
| # Handle CD:path directive | |
| if [[ "$_cwm_result" == CD:* ]]; then | |
| _cwm_target="${_cwm_result#CD:}" | |
| cd "$_cwm_target" || true | |
| echo -e "\033[0;34mChanged to: $_cwm_target\033[0m" >&2 | |
| fi | |
| unset _cwm_result _cwm_target | |
| return $_cwm_exit_code 2>/dev/null || exit $_cwm_exit_code |
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 | |
| # cwm installer - Claude Worktree Manager | |
| # Usage: curl -fsSL "https://gist.githubusercontent.com/wojtha/bc41e6bdddbb78183c0d25bded21a291/raw/install.sh?$(date +%s)" | bash | |
| GIST_RAW_URL="https://gist.githubusercontent.com/wojtha/bc41e6bdddbb78183c0d25bded21a291/raw" | |
| INSTALL_DIR="${CWM_INSTALL_DIR:-$HOME/.local/bin}" | |
| SCRIPT_NAME="cwm.sh" | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| BLUE='\033[0;34m' | |
| NC='\033[0m' | |
| err() { echo -e "${RED}✗ $*${NC}" >&2; exit 1; } | |
| ok() { echo -e "${GREEN}✓ $*${NC}"; } | |
| info() { echo -e "${BLUE}$*${NC}"; } | |
| main() { | |
| info "Installing cwm - Claude Worktree Manager..." | |
| # Create install directory | |
| mkdir -p "$INSTALL_DIR" | |
| # Download the script (with cache-busting timestamp) | |
| info "Downloading cwm.sh..." | |
| if ! curl -sSL "${GIST_RAW_URL}/${SCRIPT_NAME}?$(date +%s)" -o "$INSTALL_DIR/$SCRIPT_NAME"; then | |
| err "Failed to download cwm.sh" | |
| fi | |
| chmod +x "$INSTALL_DIR/$SCRIPT_NAME" | |
| ok "Downloaded to $INSTALL_DIR/$SCRIPT_NAME" | |
| # Add shell function | |
| local shell_rc func_def | |
| if [[ -n "${ZSH_VERSION:-}" ]] || [[ "${SHELL:-}" == */zsh ]]; then | |
| shell_rc="$HOME/.zshrc" | |
| else | |
| shell_rc="$HOME/.bashrc" | |
| fi | |
| func_def='cwm() { source ~/.local/bin/cwm.sh "$@"; }' | |
| if grep -q 'cwm().*source.*cwm.sh' "$shell_rc" 2>/dev/null; then | |
| info "Shell function already in $shell_rc" | |
| else | |
| { | |
| echo "" | |
| echo "# Claude Worktree Manager" | |
| echo "$func_def" | |
| } >> "$shell_rc" | |
| ok "Added shell function to $shell_rc" | |
| fi | |
| echo "" | |
| ok "Installation complete!" | |
| echo "" | |
| info "To start using cwm, run:" | |
| echo " source $shell_rc" | |
| echo "" | |
| info "Or open a new terminal, then run:" | |
| echo " cwm help" | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment