Skip to content
32 changes: 18 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,19 @@ This gives you auto-cd (jumps into the worktree after create/switch/pr) and tab
## Usage

```sh
bonchi init # Generate a .worktree.yml in current project
bonchi create my-feature # New branch + worktree off default base
bonchi create my-feature develop # New branch off develop
bonchi switch existing-branch # Existing branch → new worktree
bonchi pr 123 # Checkout PR #123
bonchi pr https://github.com/org/repo/pull/123
bonchi list # List all worktrees
bonchi remove my-feature # Remove a worktree
bonchi prune # Clean up stale admin files
bonchi setup # Run setup in current worktree
bonchi init # Generate a .worktree.yml in the current project
bonchi create BRANCH [BASE] # Create new branch + worktree (alias for switch -c)
bonchi pr NUMBER_OR_URL # Checkout GitHub PR in worktree
bonchi switch BRANCH # Switch to branch in worktree
bonchi remove BRANCH # Remove a worktree (and merged branch)
bonchi rmf BRANCH # Force-remove a worktree (and merged branch)
bonchi rmrf BRANCH # Force-remove a worktree and branch
bonchi list # List all worktrees
bonchi setup [-- ARGS...] # Run setup in current worktree (ports, copy, pre_setup, setup cmd)
bonchi shellenv # Output shell function for auto-cd + completions
bonchi prune # Prune stale worktree admin files
bonchi version # Print version
bonchi help [COMMAND] # Describe available commands or one specific command
```

Run `bonchi help <command>` for detailed info on any command.
Expand Down Expand Up @@ -166,15 +169,16 @@ Using [mise](https://mise.jdx.dev/) for env-vars is recommended.
1. Update `lib/bonchi/version.rb`
```
bin/rake 'gem:write_version[0.5.0]'
# commit & push
# commit&push
# check CI
```
1. Release workflow from GitHub Actions...
- ...publishes to RubyGems (with Sigstore attestation)
- ...creates git GitHub release after successful publish
1. Tag
```
gem_push=no bin/rake release
```
1. Release workflow from GitHub Actions...
- ...publishes to RubyGems (with Sigstore attestation)
- ...creates git GitHub release after successful publish
1. Update `version.rb` for next dev-cycle
```
bin/rake 'gem:write_version[0.6.0.dev]'
Expand Down
180 changes: 128 additions & 52 deletions lib/bonchi/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

module Bonchi
class CLI < Thor
include Colors

def self.exit_on_failure?
true
end
Expand All @@ -13,20 +15,25 @@ def version
map "--version" => :version
map "-v" => :version

desc "create BRANCH [BASE]", "Create new branch + worktree"
desc "switch BRANCH", "Switch to branch in worktree"
long_desc <<~DESC
Create a new branch and worktree. BASE defaults to the repository's default branch
(e.g. main). If a worktree for BRANCH already exists, switches to it instead.
Create a worktree for a branch and cd into it.
If a worktree for BRANCH already exists, switches to it instead.

Use -c to create a new branch (like git switch -c). Use --base to specify
the base branch (defaults to the repository's default branch, e.g. main).

When a .worktree.yml exists in the main worktree, setup runs automatically
(copy files, allocate ports, run pre_setup and setup commands).
When a .worktree.yml exists in the main worktree, setup runs automatically.
Skip with --no-setup, or use --upto STEP to run only up to a specific step.

Aliases: sw, create (implies -c)
DESC
option :c, type: :boolean, default: false, desc: "Create a new branch"
option :base, type: :string, desc: "Base branch for -c (default: repository default branch)"
option :setup, type: :boolean, default: true, desc: "Run setup after creating worktree"
option :upto, type: :string, desc: "Run setup steps up to and including STEP (copy, link, ports, replace, pre_setup, setup)"
def create(branch, base = nil)
base ||= Git.default_base_branch
path = Git.worktree_dir(branch)
def switch(branch)
abort "Error: --base requires -c flag" if options[:base] && !options[:c]

existing = Git.worktree_path_for(branch)
if existing
Expand All @@ -35,9 +42,18 @@ def create(branch, base = nil)
return
end

Git.worktree_add_new_branch(path, branch, base)
puts "Worktree created at: #{path}"
path = Git.worktree_dir(branch)

if options[:c] && !Git.branch_exists?(branch)
base = options[:base] || Git.default_base_branch
Git.worktree_add_new_branch(path, branch, base)
elsif options[:c] || Git.branch_exists?(branch)
Git.worktree_add(path, branch)
else
abort "Error: Branch '#{branch}' does not exist\nUse 'bonchi switch -c #{branch}' to create a new branch"
end

puts "Worktree created at: #{path}"
signal_cd(path)

if options[:setup] && Config.from_main_worktree
Expand All @@ -46,41 +62,27 @@ def create(branch, base = nil)
end
end

desc "switch BRANCH", "Switch to existing branch in worktree"
long_desc <<~DESC
Create a worktree for an existing branch and cd into it.
If a worktree for BRANCH already exists, switches to it instead.

The branch must already exist locally or on the remote.
To create a new branch, use `bonchi create` instead.
DESC
def switch(branch)
existing = Git.worktree_path_for(branch)
if existing
puts "Worktree already exists: #{existing}"
signal_cd(existing)
return
end

unless Git.branch_exists?(branch)
abort "Error: Branch '#{branch}' does not exist\nUse 'bonchi create #{branch}' to create a new branch"
end

path = Git.worktree_dir(branch)
Git.worktree_add(path, branch)
puts "Worktree created at: #{path}"

signal_cd(path)
desc "create BRANCH [BASE]", "Create new branch + worktree (alias for switch -c)"
option :setup, type: :boolean, default: true, desc: "Run setup after creating worktree"
option :upto, type: :string, desc: "Run setup steps up to and including STEP (copy, link, ports, replace, pre_setup, setup)"
def create(branch, base = nil)
invoke :switch, [branch], c: true, base: base, setup: options[:setup], upto: options[:upto]
end

desc "pr NUMBER_OR_URL", "Checkout GitHub PR in worktree"
long_desc <<~DESC
Fetch a GitHub pull request and check it out in a new worktree.
Fetch a GitHub pull request and switch to it in a new worktree.
Accepts a PR number (e.g. 123) or a full GitHub PR URL.
Like `bonchi switch`, but fetches the PR first.

The worktree branch will be named pr-<number>.
If the worktree already exists, switches to it instead.

When a .worktree.yml exists in the main worktree, setup runs automatically.
Skip with --no-setup, or use --upto STEP to run only up to a specific step.
DESC
option :setup, type: :boolean, default: true, desc: "Run setup after creating worktree"
option :upto, type: :string, desc: "Run setup steps up to and including STEP (copy, link, ports, replace, pre_setup, setup)"
def pr(input)
pr_number = extract_pr_number(input)
branch = "pr-#{pr_number}"
Expand All @@ -98,6 +100,11 @@ def pr(input)
puts "PR ##{pr_number} checked out at: #{path}"

signal_cd(path)

if options[:setup] && Config.from_main_worktree
puts ""
Setup.new(worktree: path).run(upto: options[:upto])
end
end

desc "init", "Generate a .worktree.yml in the current project"
Expand All @@ -123,25 +130,69 @@ def setup(*args)
end

desc "list", "List all worktrees"
long_desc <<~DESC
List all worktrees. Non-main branches are annotated with:

\x5 dirty — has uncommitted changes or untracked files
\x5 merged — branch has been merged into the default branch
DESC
def list
Git.worktree_list.each { |line| puts line }
lines = Git.worktree_list
base = Git.default_base_branch
home = Dir.home

lines.each do |line|
branch = line[/\[([^\]]+)\]/, 1]
path = line.split(/\s+/).first
line = line.sub(home, "~")

unless branch
puts line
next
end

if branch == base
puts line
next
end

merged = Git.merged?(branch, into: base)
clean = Git.clean?(path)
tags = []
tags << "#{color(:yellow)}dirty#{reset}" unless clean
tags << "#{color(:green)}merged#{reset}" if merged

if tags.any?
puts "#{line} #{tags.join(" ")}"
else
puts line
end
end
end

desc "remove BRANCH", "Remove a worktree"
desc "remove BRANCH", "Remove a worktree (and merged branch)"
long_desc <<~DESC
Remove a worktree and its directory. Refuses to remove worktrees
with uncommitted changes or untracked files unless --force is used.

Aliases: rm
If the branch has been merged into the default branch, it is
automatically deleted. Unmerged branches are kept.

Aliases: rm, rmf (force), rmrf (force + delete unmerged branch)
DESC
option :force, type: :boolean, default: false, desc: "Force removal even with uncommitted changes"
def remove(branch)
path = Git.worktree_path_for(branch)
abort "Error: No worktree found for branch: #{branch}" unless path
remove_worktree(branch, force: options[:force], delete_branch: :merged)
end

Git.worktree_remove(path, force: options[:force])
puts "Removed worktree: #{path}"
signal_cd(Git.main_worktree)
desc "rmf BRANCH", "Force-remove a worktree (and merged branch)"
def rmf(branch)
remove_worktree(branch, force: true, delete_branch: :merged)
end

desc "rmrf BRANCH", "Force-remove a worktree and branch"
def rmrf(branch)
remove_worktree(branch, force: true, delete_branch: :always)
end

desc "prune", "Prune stale worktree admin files"
Expand All @@ -166,8 +217,31 @@ def shellenv
map "ls" => :list
map "rm" => :remove

remove_command :tree

private

def remove_worktree(branch, force:, delete_branch:)
path = Git.worktree_path_for(branch)
abort "Error: No worktree found for branch: #{branch}" unless path

Git.worktree_remove(path, force: force)
puts "Removed worktree: #{path}"

case delete_branch
when :always
Git.delete_branch(branch, force: true)
puts "Deleted branch: #{branch}"
when :merged
if Git.merged?(branch)
Git.delete_branch(branch)
puts "Deleted merged branch: #{branch}"
end
end

signal_cd(Git.main_worktree)
end

def signal_cd(path)
cd_file = ENV["BONCHI_CD_FILE"]
if cd_file
Expand Down Expand Up @@ -246,15 +320,15 @@ def extract_pr_number(input)
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
commands="create switch sw pr setup list ls remove rm prune shellenv help"
commands="create switch sw pr setup list ls remove rm rmf rmrf prune shellenv help"

if [ $COMP_CWORD -eq 1 ]; then
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
return 0
fi

case "$prev" in
switch|sw|remove|rm)
switch|sw|remove|rm|rmf|rmrf)
local branches
branches=$(git worktree list 2>/dev/null | sed -n 's/.*\[\([^]]*\)\].*/\1/p' | tail -n +2)
COMPREPLY=( $(compgen -W "$branches" -- "$cur") )
Expand All @@ -270,15 +344,17 @@ def extract_pr_number(input)
_bonchi_complete_zsh() {
local -a commands branches
commands=(
'create:Create new branch + worktree'
'switch:Switch to existing branch in worktree'
'sw:Switch to existing branch in worktree'
'create:Create new branch + worktree (alias for switch -c)'
'switch:Switch to branch in worktree (-c to create)'
'sw:Switch to branch in worktree (-c to create)'
'pr:Checkout GitHub PR in worktree'
'setup:Run setup in current worktree'
'list:List all worktrees'
'ls:List all worktrees'
'remove:Remove a worktree'
'rm:Remove a worktree'
'remove:Remove a worktree (and merged branch)'
'rm:Remove a worktree (and merged branch)'
'rmf:Force-remove a worktree (and merged branch)'
'rmrf:Force-remove a worktree and branch'
'prune:Prune stale worktree admin files'
'shellenv:Output shell function for auto-cd'
)
Expand All @@ -287,7 +363,7 @@ def extract_pr_number(input)
_describe 'command' commands
elif (( CURRENT == 3 )); then
case "$words[2]" in
switch|sw|remove|rm)
switch|sw|remove|rm|rmf|rmrf)
branches=(${(f)"$(git worktree list 2>/dev/null | sed -n 's/.*\[\([^]]*\)\].*/\1/p' | tail -n +1)"})
_describe 'branch' branches
;;
Expand Down
6 changes: 4 additions & 2 deletions lib/bonchi/colors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@ module Colors
private

def color(name)
return "" if ENV.key?("NO_COLOR")
return "" if ENV.key?("NO_COLOR") || !$stdout.tty?

case name
when :red then "\e[31m"
when :green then "\e[32m"
when :yellow then "\e[33m"
when :dim then "\e[2m"
end
end

def reset
return "" if ENV.key?("NO_COLOR")
return "" if ENV.key?("NO_COLOR") || !$stdout.tty?

"\e[0m"
end
Expand Down
13 changes: 13 additions & 0 deletions lib/bonchi/git.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ def worktree_prune
system("git", "worktree", "prune")
end

def clean?(worktree)
`git -C #{worktree.shellescape} status --porcelain`.strip.empty?
end

def merged?(branch, into: default_base_branch)
system("git", "merge-base", "--is-ancestor", branch, into)
end

def delete_branch(branch, force: false)
flag = force ? "-D" : "-d"
system("git", "branch", flag, branch) || abort("Failed to delete branch: #{branch}")
end

def fetch_pr(pr_number)
system("git", "fetch", "origin", "pull/#{pr_number}/head:pr-#{pr_number}")
end
Expand Down
Loading