set main_checkout ~/dev set worktree_root ~/worktrees/dev set base_branch main set remote origin function __devtree_usage --no-scope-shadowing echo "usage: devtree [args]" >&2 echo "commands:" >&2 echo " new create a branch worktree from main and cd into it" >&2 echo " cd cd into an existing worktree" >&2 echo " list list worktrees" >&2 echo " sync [] fast-forward main, then rebase branch onto main" >&2 echo " sync --all fast-forward main, then rebase clean topic worktrees onto main" >&2 echo " remove remove a clean worktree, keeping the branch" >&2 echo " prune [--dry-run] prune worktree metadata and stale merged local branches" >&2 end function __devtree_path_for_branch --no-scope-shadowing --argument-names branch set current_path "" for line in (git -C $main_checkout worktree list --porcelain) if string match -q 'worktree *' -- $line set current_path (string replace 'worktree ' '' -- $line) else if test "$line" = "branch refs/heads/$branch" echo $current_path return 0 end end return 1 end function __devtree_checked_out_branches --no-scope-shadowing for line in (git -C $main_checkout worktree list --porcelain) if string match -q 'branch refs/heads/*' -- $line string replace 'branch refs/heads/' '' -- $line end end end function __devtree_update_main --no-scope-shadowing if not test -d $main_checkout/.git echo "devtree: main checkout not found at $main_checkout" >&2 return 1 end set dirty_lines (git -C $main_checkout status --porcelain) if test (count $dirty_lines) -gt 0 echo "devtree: refusing to update dirty main checkout: $main_checkout" >&2 return 1 end git -C $main_checkout fetch --prune $remote or return $status git -C $main_checkout switch $base_branch or return $status git -C $main_checkout merge --ff-only $remote/$base_branch end function __devtree_sync_branch --no-scope-shadowing --argument-names branch if test "$branch" = "$base_branch" echo "$branch: already updated" return 0 end set path (__devtree_path_for_branch $branch) if test -z "$path" echo "devtree: no worktree found for branch '$branch'" >&2 return 1 end set dirty_lines (git -C $path status --porcelain) if test (count $dirty_lines) -gt 0 echo "$branch: skipped: dirty ($path)" return 0 end echo "$branch: rebasing onto $base_branch" git -C $path rebase $base_branch end if test (count $argv) -eq 0 __devtree_usage return 2 end set command $argv[1] set args $argv[2..-1] switch $command case new if test (count $args) -ne 1 echo "usage: devtree new " >&2 return 2 end set branch $args[1] set path $worktree_root/(string replace -a / __ $branch) if git -C $main_checkout show-ref --verify --quiet refs/heads/$branch echo "devtree: branch already exists: $branch" >&2 return 1 end if test -e $path echo "devtree: worktree path already exists: $path" >&2 return 1 end __devtree_update_main or return $status mkdir -p $worktree_root or return $status git -C $main_checkout worktree add -b $branch $path $base_branch or return $status cd $path case cd if test (count $args) -ne 1 echo "usage: devtree cd " >&2 return 2 end set path (__devtree_path_for_branch $args[1]) if test -z "$path" echo "devtree: no worktree found for branch '$args[1]'" >&2 return 1 end cd $path case list if test (count $args) -ne 0 echo "usage: devtree list" >&2 return 2 end git -C $main_checkout worktree list case sync if test (count $args) -gt 1 echo "usage: devtree sync []" >&2 echo " devtree sync --all" >&2 return 2 end __devtree_update_main or return $status if test (count $args) -eq 1; and test "$args[1]" = --all set current_path "" set branches for line in (git -C $main_checkout worktree list --porcelain) if string match -q 'worktree *' -- $line set current_path (string replace 'worktree ' '' -- $line) else if string match -q 'branch refs/heads/*' -- $line set branch (string replace 'branch refs/heads/' '' -- $line) if test "$branch" != "$base_branch" set --append branches $branch end end end for branch in $branches __devtree_sync_branch $branch or return $status end else if test (count $args) -eq 1 set branch $args[1] else git rev-parse --show-toplevel >/dev/null 2>&1 or begin echo "devtree: run 'devtree sync' from a git checkout, or pass a branch" >&2 return 2 end set branch (git branch --show-current) if test -z "$branch" echo "devtree: could not determine the current branch" >&2 return 2 end end __devtree_sync_branch $branch end case remove if test (count $args) -ne 1 echo "usage: devtree remove " >&2 return 2 end set branch $args[1] if test "$branch" = "$base_branch" echo "devtree: refusing to remove $base_branch" >&2 return 1 end set path (__devtree_path_for_branch $branch) if test -z "$path" echo "devtree: no worktree found for branch '$branch'" >&2 return 1 end set dirty_lines (git -C $path status --porcelain) if test (count $dirty_lines) -gt 0 echo "devtree: refusing to remove dirty worktree: $path" >&2 return 1 end git -C $main_checkout worktree remove $path case prune set dry_run false if test (count $args) -gt 1 echo "usage: devtree prune [--dry-run]" >&2 return 2 else if test (count $args) -eq 1 if test "$args[1]" = --dry-run set dry_run true else echo "usage: devtree prune [--dry-run]" >&2 return 2 end end git -C $main_checkout worktree prune or return $status git -C $main_checkout fetch --prune $remote or return $status set checked_out (__devtree_checked_out_branches) for line in (git -C $main_checkout for-each-ref --format='%(refname:short)|%(upstream:track)' refs/heads) set parts (string split '|' -- $line) set branch $parts[1] set upstream_track $parts[2] if test "$branch" = "$base_branch" continue end if contains -- $branch $checked_out continue end if not string match -q '*gone*' -- $upstream_track continue end if not git -C $main_checkout merge-base --is-ancestor $branch $base_branch echo "$branch: skipped: not merged into $base_branch" continue end if test "$dry_run" = true echo "$branch: would delete" else git -C $main_checkout branch -d $branch or return $status end end case '*' echo "devtree: unknown command: $command" >&2 __devtree_usage return 2 end