Kanvas CLI
kanvas is the shell function that wraps every worktree action — create a new one, list, clean up, bulk-clean merged branches. It auto-detects the current repo from your cwd and only works on the bare-repo layout (see Worktrees & tmux for the layout, migration recipe, and tmux setup).
Install
Save the source below as ~/.config/shell/kanvas.sh, then add to ~/.zshrc:
[ -f ~/.config/shell/kanvas.sh ] && source ~/.config/shell/kanvas.sh
Reload your shell (source ~/.zshrc or open a new terminal).
Commands
| Invocation | What it does |
|---|---|
kanvas <name> | Create worktree <root>/<name> on a new branch named <name> cut from main. Copy .env.local from main/. Open a tmux window in the repo’s session running Claude Code. |
kanvas --list | List all worktrees in the current repo (git worktree list). |
kanvas --clean <name> | Safe cleanup: refuses if the worktree has uncommitted, unpushed, or never-pushed work. Otherwise removes the worktree dir, deletes the local branch, kills the tmux window. |
kanvas --clean --force <name> | Same as above but skips the safety check. Discards local work without asking. |
kanvas --clean --all-merged | Fetches origin, finds every local branch fully merged into origin/main, removes those worktrees. Dirty ones are skipped with a printed reason. |
kanvas --clean --all-merged --force | Bulk-cleans every merged branch, dirty or not. |
-l / -c / -f are accepted as short forms of --list / --clean / --force.
Cleanup safety model
kanvas --clean <name> (without --force) refuses if any of these are true:
- Unstaged changes —
git diff --quietfails - Staged changes —
git diff --cached --quietfails - Untracked files —
git ls-files --others --exclude-standardreturns non-empty - Unpushed commits —
git rev-list --count <upstream>..HEAD > 0 - Branch has no upstream and no
origin/<name>(= never pushed)
Each tripped check is printed as a separate - reason line, so you see exactly why it refused. Re-run with --force to discard.
The intent: kanvas --clean is for “I’m done; remove cleanly.” Anything else needs --force.
Auto-detection rules
- Repo —
kanvasresolves the repo from your cwd viagit rev-parse --git-common-dir. The directory containing.bare/is the repo root; its basename becomes the repo identifier and tmux session name. - Layout — the function refuses to run unless
<repo>/.bareexists (bare-repo layout). For sibling-style layouts you’d need a different launcher.
You can invoke kanvas from inside any worktree, the bare-repo parent, or even the .bare/ directory — it always operates on the same repo.
Examples
# Daily start
cd ~/Workspace/kanvas
kanvas KANV-1234 # creates worktree + tmux window + Claude
# Safe cleanup after PR merges
kanvas --clean KANV-1234 # refuses if dirty
kanvas --clean --force KANV-1234 # discard local changes
# Post-sprint sweep
kanvas --clean --all-merged # remove all fully-merged worktrees
kanvas --clean --all-merged --force # plus any merged-but-dirty ones
Source
# kanvas — git worktree + tmux launcher for bare-repo layout.
# Repo is auto-detected from the cwd; must be run from inside a bare-layout repo.
#
# Expected layout:
# <root>/.bare/ bare clone (object DB, refs)
# <root>/.git file: "gitdir: ./.bare"
# <root>/main/ worktree on main
# <root>/<name>/ additional worktrees, one per branch
#
# Usage:
# kanvas <name> create worktree + open tmux window running Claude
# kanvas --list list worktrees in the current repo
# kanvas --clean [--force] <name> remove worktree + branch + tmux window
# kanvas --clean --all-merged [--force]
# remove every worktree whose branch is fully merged
# into origin/main; dirty worktrees are skipped unless --force
kanvas() {
local common_dir
common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || {
echo "kanvas: not inside a git repository"
return 1
}
common_dir=$(cd "$common_dir" && pwd)
case "$common_dir" in
*/.bare) ;;
*)
echo "kanvas: repo is not in bare-repo layout (expected <root>/.bare)"
return 1
;;
esac
local repo_root=$(dirname "$common_dir")
local repo_name=$(basename "$repo_root")
local primary_path="$repo_root/main"
case "$1" in
--list|-l)
git -C "$primary_path" worktree list
return 0
;;
--clean|-c)
shift
local force=0 all_merged=0 name=""
while [ $# -gt 0 ]; do
case "$1" in
--force|-f) force=1; shift ;;
--all-merged) all_merged=1; shift ;;
--*) echo "kanvas: unknown flag '$1'"; return 1 ;;
*) name=$1; shift ;;
esac
done
if [ "$all_merged" -eq 1 ]; then
_kanvas_clean_all_merged "$repo_root" "$repo_name" "$primary_path" "$force"
return
fi
if [ -z "$name" ]; then
echo "Usage: kanvas --clean [--force] <name>"
echo " kanvas --clean --all-merged [--force]"
return 1
fi
_kanvas_clean_one "$repo_root" "$repo_name" "$primary_path" "$name" "$force"
return
;;
esac
local name=$1
if [ -z "$name" ]; then
echo "Usage: kanvas <name>"
echo " kanvas --list"
echo " kanvas --clean [--force] <name>"
echo " kanvas --clean --all-merged [--force]"
return 1
fi
local wt_path="$repo_root/$name"
if [ ! -d "$wt_path" ]; then
git -C "$primary_path" worktree add "$wt_path" -b "$name"
[ -f "$primary_path/.env.local" ] && cp "$primary_path/.env.local" "$wt_path/"
fi
local session=$repo_name
if ! tmux has-session -t "$session" 2>/dev/null; then
tmux new-session -d -s "$session" -c "$wt_path" -n "$name" \
"claude --dangerously-skip-permissions --chrome"
elif ! tmux list-windows -t "$session" -F '#W' 2>/dev/null | grep -qx "$name"; then
tmux new-window -t "$session" -c "$wt_path" -n "$name" \
"claude --dangerously-skip-permissions --chrome"
fi
if [ -z "$TMUX" ]; then
tmux attach -t "$session:$name"
else
tmux switch-client -t "$session:$name"
fi
}
_kanvas_worktree_clean() {
local wt_path=$1
local -a reasons=()
if ! git -C "$wt_path" diff --quiet 2>/dev/null; then reasons+=("unstaged changes"); fi
if ! git -C "$wt_path" diff --cached --quiet 2>/dev/null; then reasons+=("staged changes"); fi
if [ -n "$(git -C "$wt_path" ls-files --others --exclude-standard 2>/dev/null)" ]; then
reasons+=("untracked files")
fi
local branch upstream
branch=$(git -C "$wt_path" rev-parse --abbrev-ref HEAD 2>/dev/null)
upstream=$(git -C "$wt_path" rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null)
if [ -n "$upstream" ]; then
local ahead=$(git -C "$wt_path" rev-list --count "$upstream..HEAD" 2>/dev/null)
[ -n "$ahead" ] && [ "$ahead" -gt 0 ] && reasons+=("$ahead unpushed commit(s) ahead of $upstream")
else
if ! git -C "$wt_path" rev-parse --verify "origin/$branch" >/dev/null 2>&1; then
reasons+=("branch '$branch' has no upstream and isn't on origin")
fi
fi
if [ ${#reasons[@]} -gt 0 ]; then
printf ' - %s\n' "${reasons[@]}"
return 1
fi
return 0
}
_kanvas_clean_one() {
local repo_root=$1 repo_name=$2 primary_path=$3 name=$4 force=$5
local wt_path="$repo_root/$name"
if [ ! -d "$wt_path" ]; then
echo "kanvas: worktree '$name' not found at $wt_path"
return 1
fi
if [ "$force" -eq 0 ]; then
if ! _kanvas_worktree_clean "$wt_path"; then
echo "kanvas: worktree '$name' has local work. Re-run with --force to discard."
return 1
fi
fi
git -C "$primary_path" worktree remove "$wt_path" --force
git -C "$primary_path" branch -D "$name" 2>/dev/null
tmux kill-window -t "$repo_name:$name" 2>/dev/null
echo "Removed worktree and branch: $name"
}
_kanvas_clean_all_merged() {
local repo_root=$1 repo_name=$2 primary_path=$3 force=$4
echo "Fetching origin..."
git -C "$primary_path" fetch origin --quiet || echo "kanvas: fetch failed; merge state may be stale"
local merge_target
if git -C "$primary_path" rev-parse --verify origin/main >/dev/null 2>&1; then
merge_target=origin/main
elif git -C "$primary_path" rev-parse --verify main >/dev/null 2>&1; then
merge_target=main
else
echo "kanvas: cannot determine merge target (no origin/main or main)"
return 1
fi
local merged_branches=$(git -C "$primary_path" branch --merged "$merge_target" --format '%(refname:short)')
local -a candidates=()
local wt_path branch
while read -r wt_path branch; do
[ -z "$branch" ] && continue
[ "$branch" = main ] && continue
if printf '%s\n' "$merged_branches" | grep -qx "$branch"; then
candidates+=("$branch|$wt_path")
fi
done < <(git -C "$primary_path" worktree list --porcelain | awk '
/^worktree / { wt=$2 }
/^branch / { sub("refs/heads/", "", $2); print wt, $2; wt="" }
')
if [ ${#candidates[@]} -eq 0 ]; then
echo "No merged worktrees to clean."
return 0
fi
echo "Merged worktrees (vs $merge_target):"
for entry in "${candidates[@]}"; do echo " ${entry%%|*}"; done
local cleaned=0 skipped=0 dirty_msg
for entry in "${candidates[@]}"; do
branch=${entry%%|*}
wt_path=${entry#*|}
if [ "$force" -eq 0 ]; then
dirty_msg=$(_kanvas_worktree_clean "$wt_path")
if [ -n "$dirty_msg" ]; then
echo "Skipping $branch (--force to override):"
echo "$dirty_msg"
skipped=$((skipped + 1))
continue
fi
fi
git -C "$primary_path" worktree remove "$wt_path" --force >/dev/null
git -C "$primary_path" branch -D "$branch" >/dev/null 2>&1
tmux kill-window -t "$repo_name:$branch" 2>/dev/null
echo "Cleaned: $branch"
cleaned=$((cleaned + 1))
done
echo "Done. Cleaned: $cleaned, skipped: $skipped"
}
Notes
- Bare-layout only. Won’t run on a normal git checkout. See Worktrees & tmux for the layout and migration recipe.
- macOS / tmux. The launcher uses tmux for window management. Install a recent tmux (3.x) via
brew install tmuxfor best compatibility with modern terminals. - Claude flags. Each window launches with
claude --dangerously-skip-permissions --chrome. Adjust inkanvas.shif you want different defaults. mainis special. The script uses<root>/main/as the “primary path” — that’s where it runsgit -Cfrom (so it always sees consistent ref state) and where it copies.env.localfrom. Keep themain/worktree around even if you rarely work in it directly.- Worktree
mainwindow is not pre-created. Whenkanvas <name>starts the tmux session, the first window is the feature branch — notmain. This is intentional: it avoids “orphan session with an idle main window” after all feature branches are cleaned. If you want a shell inmain/, open a tmux window manually (Ctrl-Space c, thencd ~/Workspace/<repo>/main).
Further reading
git worktree— official docs for the underlying git command.- tmux wiki — terminal multiplexer reference.
- Worktrees & tmux — the workflow guide this CLI exists to support.