Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.6.1] - 2026-06-29

### Changed

- The picker and `wt -l` now compute each worktree's status (`!`, `↑N`/`↓N`) in
parallel instead of one at a time, and stream rows into fzf as they finish, so
the picker appears instantly on repos with many worktrees instead of blocking
on a serial run of `git status` per worktree.

### Fixed

- Slow git operations now show a spinner instead of appearing frozen: deleting a
worktree (`git worktree remove`, e.g. on a large `node_modules`), creating one
(`git worktree add`), and `wt pr` (the network fetch and checkout). Falls back
to a plain run with a one-line breadcrumb when output isn't a terminal.

## [0.6.0] - 2026-06-28

### Added
Expand Down
102 changes: 76 additions & 26 deletions bin/wt
Original file line number Diff line number Diff line change
@@ -1,13 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail

VERSION="0.6.0"
VERSION="0.6.1"
PROG="wt"

# --- helpers ----------------------------------------------------------------

die() { printf '%s: %s\n' "$PROG" "$1" >&2; exit 1; }

# Run "$@" while showing a spinner on stderr, so slow git operations don't look
# like a freeze. Falls back to a plain run when stderr isn't a terminal (scripts,
# CI, tests). Returns the command's exit status; its output is captured and
# replayed afterwards so it never collides with the spinner line.
spin() {
local msg="$1"; shift
if [ ! -t 2 ]; then
# No terminal (scripts, CI, tests): leave a breadcrumb and run plainly. The
# command's own output goes to stderr so stdout stays reserved for results.
printf '%s: %s...\n' "$PROG" "$msg" >&2
"$@" >&2
return $?
fi

local out
out="$(mktemp)"
"$@" >"$out" 2>&1 &
local pid=$! i=0 rc=0
local frames=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏)
trap 'kill "$pid" 2>/dev/null; printf "\r\033[K\033[?25h" >&2' INT TERM
printf '\033[?25l' >&2
while kill -0 "$pid" 2>/dev/null; do
printf '\r%s %s' "${frames[i++ % ${#frames[@]}]}" "$msg" >&2
sleep 0.08
done
wait "$pid" || rc=$?
printf '\r\033[K\033[?25h' >&2
trap - INT TERM
[ -s "$out" ] && cat "$out" >&2
rm -f "$out"
return $rc
}

usage() {
cat <<EOF
Usage: $PROG [<branch> [<start-point>]]
Expand Down Expand Up @@ -203,26 +236,44 @@ list_worktrees() {
# has uncommitted changes (tracked or untracked).
# a/b "↑N" commits ahead / "↓N" behind its upstream, when it has one.
# The branch and path stay in their own clean fields so callers can parse them.
annotate_worktrees() {
local cur_top
cur_top="$(git rev-parse --show-toplevel 2>/dev/null || true)"
# Decorate a single worktree (<branch> <path>) and print its four-field row.
# Each call shells out to git status + rev-list, so annotate_worktrees runs many
# of these in parallel. WT_CUR_TOP names the worktree we're currently in.
annotate_one() {
local branch="$1" path="$2"
local cur=" " dirty=" " ab="" counts behind ahead
[ "$path" = "${WT_CUR_TOP:-}" ] && cur="*"
[ -n "$(git -C "$path" status --porcelain 2>/dev/null || true)" ] && dirty="!"

counts="$(git -C "$path" rev-list --left-right --count '@{upstream}...HEAD' 2>/dev/null || true)"
if [ -n "$counts" ]; then
behind="${counts%% *}"
ahead="${counts##* }"
[ "${ahead:-0}" != 0 ] && ab="$ab ↑$ahead"
[ "${behind:-0}" != 0 ] && ab="$ab ↓$behind"
fi

local branch path
list_worktrees | while IFS=$'\t' read -r branch path; do
local cur=" " dirty=" " ab="" counts behind ahead
[ "$path" = "$cur_top" ] && cur="*"
[ -n "$(git -C "$path" status --porcelain 2>/dev/null || true)" ] && dirty="!"

counts="$(git -C "$path" rev-list --left-right --count '@{upstream}...HEAD' 2>/dev/null || true)"
if [ -n "$counts" ]; then
behind="${counts%% *}"
ahead="${counts##* }"
[ "${ahead:-0}" != 0 ] && ab="$ab ↑$ahead"
[ "${behind:-0}" != 0 ] && ab="$ab ↓$behind"
fi
printf '%s%s\t%s\t%s\t%s\n' "$cur" "$dirty" "$branch" "$path" "$ab"
}

printf '%s%s\t%s\t%s\t%s\n' "$cur" "$dirty" "$branch" "$path" "$ab"
done
# Decorate every worktree for display, computing the per-worktree git lookups in
# parallel and streaming rows to stdout as they finish (fzf renders them live).
# Output order is non-deterministic — fzf sorts on match anyway. See annotate_one
# for the field layout.
annotate_worktrees() {
local list jobs
list="$(list_worktrees)"
[ -z "$list" ] && return 0

jobs="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)"
export WT_CUR_TOP
WT_CUR_TOP="$(git rev-parse --show-toplevel 2>/dev/null || true)"
export -f annotate_one

# Feed branch and path as separate NUL-delimited tokens (-0 -n2) so values with
# spaces survive and xargs never reinterprets the tab/field separators.
printf '%s\n' "$list" | tr '\t\n' '\0\0' \
| xargs -0 -n2 -P "$jobs" bash -c 'annotate_one "$@"' _
}

pick_worktree() {
Expand Down Expand Up @@ -300,11 +351,11 @@ create_worktree() {
fi

if git show-ref --verify --quiet "refs/heads/$branch"; then
git worktree add "$wt_path" "$branch" >&2
spin "Creating worktree $branch" git worktree add "$wt_path" "$branch"
elif [ -n "$start_point" ]; then
git worktree add -b "$branch" "$wt_path" "$start_point" >&2
spin "Creating worktree $branch" git worktree add -b "$branch" "$wt_path" "$start_point"
else
git worktree add -b "$branch" "$wt_path" >&2
spin "Creating worktree $branch" git worktree add -b "$branch" "$wt_path"
fi

run_hooks "$branch" "$wt_path"
Expand Down Expand Up @@ -351,7 +402,7 @@ remove_worktree() {
die "worktree has uncommitted changes: $wt_path (stash/commit them, or run: git worktree remove --force \"$wt_path\")"
fi

git worktree remove --force "$wt_path"
spin "Removing worktree $branch" git worktree remove --force "$wt_path"
printf 'Removed worktree: %s (%s)\n' "$branch" "$wt_path" >&2

if [ "$branch" != "(detached)" ]; then
Expand Down Expand Up @@ -399,14 +450,13 @@ pr_worktree() {
return 0
fi

printf '%s: fetching PR #%s from %s\n' "$PROG" "$n" "$remote" >&2
git fetch -q "$remote" "refs/pull/$n/head:$branch" >&2 \
spin "Fetching PR #$n from $remote" git fetch -q "$remote" "refs/pull/$n/head:$branch" \
|| die "could not fetch PR #$n from $remote"

local base wt_path
base="$(worktree_base)"
wt_path="$base/$(safe_dirname "$branch")"
git worktree add "$wt_path" "$branch" >&2
spin "Creating worktree $branch" git worktree add "$wt_path" "$branch"
run_hooks "$branch" "$wt_path"
echo "$wt_path"
}
Expand Down
Loading