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 --quiet fails
  • Staged changes — git diff --cached --quiet fails
  • Untracked files — git ls-files --others --exclude-standard returns 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

  • Repokanvas resolves the repo from your cwd via git 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>/.bare exists (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 tmux for best compatibility with modern terminals.
  • Claude flags. Each window launches with claude --dangerously-skip-permissions --chrome. Adjust in kanvas.sh if you want different defaults.
  • main is special. The script uses <root>/main/ as the “primary path” — that’s where it runs git -C from (so it always sees consistent ref state) and where it copies .env.local from. Keep the main/ worktree around even if you rarely work in it directly.
  • Worktree main window is not pre-created. When kanvas <name> starts the tmux session, the first window is the feature branch — not main. This is intentional: it avoids “orphan session with an idle main window” after all feature branches are cleaned. If you want a shell in main/, open a tmux window manually (Ctrl-Space c, then cd ~/Workspace/<repo>/main).

Further reading


This site uses Just the Docs, a documentation theme for Jekyll.