bonchi 0.1.0.dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4401b948510406e541ccd17bc7aa34faefcf8d9f420ea9d4126da8776a6b3fc4
4
+ data.tar.gz: 1eca2f4dad53e1cf516fabef0def984cec178a0a9a604171a0053aa59d8e13c9
5
+ SHA512:
6
+ metadata.gz: a1798321638aa5711f9fa042c5f1a667fb4e8469ee887830fcbd5efff5676ee1ff110c9df38c65fdb5af2521e28ff737ac032709045d8cf3feca3dec758a6890
7
+ data.tar.gz: 81e208dcb8c0fb44445669bf34976b7a5fbfaff7e77fc4a6ce6ddd6c998165f3a5ddeb147bf93d57ecc128bf813a79b62fa0d98be0c0b9f765937b1a7c3323e0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Gert Goet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/exe/bonchi ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require "bonchi"
3
+
4
+ Bonchi::CLI.start(ARGV)
data/lib/bonchi/cli.rb ADDED
@@ -0,0 +1,199 @@
1
+ require "thor"
2
+
3
+ module Bonchi
4
+ class CLI < Thor
5
+ def self.exit_on_failure?
6
+ true
7
+ end
8
+
9
+ desc "version", "Print version"
10
+ def version
11
+ puts "bonchi #{VERSION}"
12
+ end
13
+ map "--version" => :version
14
+ map "-v" => :version
15
+
16
+ desc "create BRANCH [BASE]", "Create new branch + worktree"
17
+ option :setup, type: :boolean, default: true, desc: "Run setup after creating worktree"
18
+ def create(branch, base = nil)
19
+ base ||= Git.default_base_branch
20
+ path = Git.worktree_dir(branch)
21
+
22
+ existing = Git.worktree_path_for(branch)
23
+ if existing
24
+ puts "Worktree already exists: #{existing}"
25
+ puts "BONCHI_CD:#{existing}"
26
+ return
27
+ end
28
+
29
+ Git.worktree_add_new_branch(path, branch, base)
30
+ puts "Worktree created at: #{path}"
31
+
32
+ if options[:setup] && Config.from_main_worktree
33
+ puts ""
34
+ Setup.new(worktree: path).run
35
+ else
36
+ puts "BONCHI_CD:#{path}"
37
+ end
38
+ end
39
+
40
+ desc "switch BRANCH", "Switch to existing branch in worktree"
41
+ def switch(branch)
42
+ existing = Git.worktree_path_for(branch)
43
+ if existing
44
+ puts "Worktree already exists: #{existing}"
45
+ puts "BONCHI_CD:#{existing}"
46
+ return
47
+ end
48
+
49
+ unless Git.branch_exists?(branch)
50
+ abort "Error: Branch '#{branch}' does not exist\nUse 'bonchi create #{branch}' to create a new branch"
51
+ end
52
+
53
+ path = Git.worktree_dir(branch)
54
+ Git.worktree_add(path, branch)
55
+ puts "Worktree created at: #{path}"
56
+
57
+ puts "BONCHI_CD:#{path}"
58
+ end
59
+
60
+ desc "pr NUMBER_OR_URL", "Checkout GitHub PR in worktree"
61
+ def pr(input)
62
+ pr_number = extract_pr_number(input)
63
+ branch = "pr-#{pr_number}"
64
+ path = Git.worktree_dir(branch)
65
+
66
+ existing = Git.worktree_path_for(branch)
67
+ if existing
68
+ puts "Worktree already exists: #{existing}"
69
+ puts "BONCHI_CD:#{existing}"
70
+ return
71
+ end
72
+
73
+ Git.fetch_pr(pr_number)
74
+ Git.worktree_add(path, branch)
75
+ puts "PR ##{pr_number} checked out at: #{path}"
76
+
77
+ puts "BONCHI_CD:#{path}"
78
+ end
79
+
80
+ desc "setup", "Run setup in current worktree (ports, copy, pre_setup, setup cmd)"
81
+ def setup
82
+ Setup.new.run
83
+ end
84
+
85
+ desc "list", "List all worktrees"
86
+ def list
87
+ Git.worktree_list.each { |line| puts line }
88
+ end
89
+
90
+ desc "remove BRANCH", "Remove a worktree"
91
+ def remove(branch)
92
+ path = Git.worktree_path_for(branch)
93
+ abort "Error: No worktree found for branch: #{branch}" unless path
94
+
95
+ Git.worktree_remove(path)
96
+ puts "Removed worktree: #{path}"
97
+ end
98
+
99
+ desc "prune", "Prune stale worktree admin files"
100
+ def prune
101
+ Git.worktree_prune
102
+ puts "Pruned stale worktree administrative files"
103
+ end
104
+
105
+ desc "shellenv", "Output shell function for auto-cd + completions"
106
+ def shellenv
107
+ puts SHELL_ENV
108
+ end
109
+
110
+ map "sw" => :switch
111
+ map "ls" => :list
112
+ map "rm" => :remove
113
+
114
+ private
115
+
116
+ def extract_pr_number(input)
117
+ case input
118
+ when %r{^https://github.com/.*/pull/(\d+)}
119
+ $1
120
+ when /^\d+$/
121
+ input
122
+ else
123
+ abort "Error: Invalid PR number or URL: #{input}"
124
+ end
125
+ end
126
+
127
+ SHELL_ENV = <<~'SHELL'
128
+ bonchi() {
129
+ local output
130
+ output=$(command bonchi "$@")
131
+ local exit_code=$?
132
+ echo "$output"
133
+ if [ $exit_code -eq 0 ]; then
134
+ local cd_path=$(echo "$output" | grep "^BONCHI_CD:" | cut -d: -f2-)
135
+ [ -n "$cd_path" ] && cd "$cd_path"
136
+ fi
137
+ return $exit_code
138
+ }
139
+
140
+ # Bash completion
141
+ if [ -n "$BASH_VERSION" ]; then
142
+ _bonchi_complete() {
143
+ local cur prev commands
144
+ COMPREPLY=()
145
+ cur="${COMP_WORDS[COMP_CWORD]}"
146
+ prev="${COMP_WORDS[COMP_CWORD-1]}"
147
+ commands="create switch sw pr setup list ls remove rm prune shellenv help"
148
+
149
+ if [ $COMP_CWORD -eq 1 ]; then
150
+ COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
151
+ return 0
152
+ fi
153
+
154
+ case "$prev" in
155
+ switch|sw|remove|rm)
156
+ local branches
157
+ branches=$(git worktree list 2>/dev/null | sed -n 's/.*\[\([^]]*\)\].*/\1/p' | tail -n +2)
158
+ COMPREPLY=( $(compgen -W "$branches" -- "$cur") )
159
+ return 0
160
+ ;;
161
+ esac
162
+ }
163
+ complete -F _bonchi_complete bonchi
164
+ fi
165
+
166
+ # Zsh completion
167
+ if [ -n "$ZSH_VERSION" ]; then
168
+ _bonchi_complete_zsh() {
169
+ local -a commands branches
170
+ commands=(
171
+ 'create:Create new branch + worktree'
172
+ 'switch:Switch to existing branch in worktree'
173
+ 'sw:Switch to existing branch in worktree'
174
+ 'pr:Checkout GitHub PR in worktree'
175
+ 'setup:Run setup in current worktree'
176
+ 'list:List all worktrees'
177
+ 'ls:List all worktrees'
178
+ 'remove:Remove a worktree'
179
+ 'rm:Remove a worktree'
180
+ 'prune:Prune stale worktree admin files'
181
+ 'shellenv:Output shell function for auto-cd'
182
+ )
183
+
184
+ if (( CURRENT == 2 )); then
185
+ _describe 'command' commands
186
+ elif (( CURRENT == 3 )); then
187
+ case "$words[2]" in
188
+ switch|sw|remove|rm)
189
+ branches=(${(f)"$(git worktree list 2>/dev/null | sed -n 's/.*\[\([^]]*\)\].*/\1/p' | tail -n +1)"})
190
+ _describe 'branch' branches
191
+ ;;
192
+ esac
193
+ fi
194
+ }
195
+ compdef _bonchi_complete_zsh bonchi
196
+ fi
197
+ SHELL
198
+ end
199
+ end
@@ -0,0 +1,23 @@
1
+ require "yaml"
2
+
3
+ module Bonchi
4
+ class Config
5
+ attr_reader :copy, :ports, :pre_setup, :setup
6
+
7
+ def initialize(path)
8
+ data = YAML.load_file(path)
9
+ @copy = Array(data["copy"])
10
+ @ports = Array(data["ports"])
11
+ @pre_setup = Array(data["pre_setup"])
12
+ @setup = data["setup"] || "bin/setup"
13
+ end
14
+
15
+ def self.from_main_worktree
16
+ main = Git.main_worktree
17
+ path = File.join(main, ".worktree.yml")
18
+ return nil unless File.exist?(path)
19
+
20
+ new(path)
21
+ end
22
+ end
23
+ end
data/lib/bonchi/git.rb ADDED
@@ -0,0 +1,64 @@
1
+ require "shellwords"
2
+
3
+ module Bonchi
4
+ module Git
5
+ module_function
6
+
7
+ def repo_name
8
+ url = `git remote get-url origin 2>/dev/null`.strip
9
+ base = url.empty? ? `git rev-parse --show-toplevel`.strip : url
10
+ File.basename(base, ".git")
11
+ end
12
+
13
+ def default_base_branch
14
+ ref = `git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null`.strip
15
+ ref.empty? ? "main" : ref.sub(%r{^refs/remotes/origin/}, "")
16
+ end
17
+
18
+ def main_worktree
19
+ `git worktree list --porcelain`.lines.first.sub("worktree ", "").strip
20
+ end
21
+
22
+ def worktree_list
23
+ `git worktree list`.lines.map(&:strip).reject(&:empty?)
24
+ end
25
+
26
+ def worktree_branches
27
+ worktree_list.filter_map { |line| line[/\[([^\]]+)\]/, 1] }
28
+ end
29
+
30
+ def worktree_path_for(branch)
31
+ line = `git worktree list`.lines.find { |l| l.include?("[#{branch}]") }
32
+ line&.split(/\s+/)&.first
33
+ end
34
+
35
+ def branch_exists?(branch)
36
+ system("git show-ref --verify --quiet refs/heads/#{branch.shellescape}") ||
37
+ system("git show-ref --verify --quiet refs/remotes/origin/#{branch.shellescape}")
38
+ end
39
+
40
+ def worktree_add(path, branch)
41
+ system("git", "worktree", "add", path, branch) || abort("Failed to add worktree")
42
+ end
43
+
44
+ def worktree_add_new_branch(path, branch, base)
45
+ system("git", "worktree", "add", path, "-b", branch, base) || abort("Failed to add worktree")
46
+ end
47
+
48
+ def worktree_remove(path)
49
+ system("git", "worktree", "remove", path) || abort("Failed to remove worktree")
50
+ end
51
+
52
+ def worktree_prune
53
+ system("git", "worktree", "prune")
54
+ end
55
+
56
+ def fetch_pr(pr_number)
57
+ system("git", "fetch", "origin", "pull/#{pr_number}/head:pr-#{pr_number}")
58
+ end
59
+
60
+ def worktree_dir(branch)
61
+ File.join(GlobalConfig.new.worktree_root, repo_name, branch)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,34 @@
1
+ require "yaml"
2
+ require "fileutils"
3
+
4
+ module Bonchi
5
+ class GlobalConfig
6
+ def initialize
7
+ @path = self.class.config_path
8
+ @data = if File.exist?(@path)
9
+ YAML.safe_load_file(@path) || {}
10
+ else
11
+ {}
12
+ end
13
+ end
14
+
15
+ attr_reader :path, :data
16
+
17
+ def worktree_root
18
+ ENV.fetch("WORKTREE_ROOT") {
19
+ @data["worktree_root"] || File.join(Dir.home, "dev", "worktrees")
20
+ }
21
+ end
22
+
23
+ def self.config_path
24
+ xdg = ENV["XDG_CONFIG_HOME"]
25
+ if xdg && !xdg.empty?
26
+ dir = File.join(xdg, "bonchi")
27
+ FileUtils.mkdir_p(dir)
28
+ File.join(dir, "config.yml")
29
+ else
30
+ File.expand_path("~/.bonchi.yml")
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,80 @@
1
+ require "yaml"
2
+ require "socket"
3
+ require "set"
4
+
5
+ module Bonchi
6
+ class PortPool
7
+ DEFAULT_MIN = 4000
8
+ DEFAULT_MAX = 5000
9
+
10
+ def initialize
11
+ @path = GlobalConfig.config_path
12
+ load_config
13
+ end
14
+
15
+ def allocate(worktree_path, port_names)
16
+ prune_stale
17
+
18
+ existing = @allocated[worktree_path] || {}
19
+ if port_names.all? { |name| existing[name] }
20
+ port_names.each_with_object({}) do |name, result|
21
+ result[name] = existing[name]
22
+ puts "Reusing port #{existing[name]} for #{name}"
23
+ end
24
+ else
25
+ used = @allocated.reject { |k, _| k == worktree_path }
26
+ .values.flat_map { |ports| ports.values }.to_set
27
+
28
+ new_ports = {}
29
+ port_names.each do |name|
30
+ port = (@min..@max).find { |p| !used.include?(p) && port_available?(p) }
31
+ abort "Error: no available port for #{name}" unless port
32
+ used << port
33
+ new_ports[name] = port
34
+ puts "Allocated port #{port} for #{name}"
35
+ end
36
+
37
+ @allocated[worktree_path] = new_ports
38
+ save_config
39
+ new_ports
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def load_config
46
+ if File.exist?(@path)
47
+ data = YAML.safe_load_file(@path) || {}
48
+ else
49
+ data = {}
50
+ end
51
+ pool = data["port_pool"] || {}
52
+ @min = pool["min"] || DEFAULT_MIN
53
+ @max = pool["max"] || DEFAULT_MAX
54
+ @allocated = pool["allocated"] || {}
55
+ end
56
+
57
+ def save_config
58
+ data = {
59
+ "port_pool" => {
60
+ "min" => @min,
61
+ "max" => @max,
62
+ "allocated" => @allocated
63
+ }
64
+ }
65
+ File.write(@path, YAML.dump(data))
66
+ end
67
+
68
+ def prune_stale
69
+ @allocated.delete_if { |path, _| !File.directory?(path) }
70
+ end
71
+
72
+ def port_available?(port)
73
+ server = TCPServer.new("127.0.0.1", port)
74
+ server.close
75
+ true
76
+ rescue Errno::EADDRINUSE
77
+ false
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,66 @@
1
+ require "fileutils"
2
+ require "shellwords"
3
+
4
+ module Bonchi
5
+ class Setup
6
+ def initialize(worktree: nil)
7
+ @worktree = worktree || Dir.pwd
8
+ @main_worktree = Git.main_worktree
9
+ end
10
+
11
+ def run(args = [])
12
+ if @worktree == @main_worktree
13
+ abort "Error: already in the main worktree"
14
+ end
15
+
16
+ config = Config.from_main_worktree
17
+ abort "Error: .worktree.yml not found in main worktree" unless config
18
+
19
+ ENV["MAIN_WORKTREE"] = @main_worktree
20
+ ENV["WORKTREE"] = @worktree
21
+
22
+ puts "Setting up worktree from: #{@main_worktree}"
23
+
24
+ allocate_ports(config.ports) if config.ports.any?
25
+ copy_files(config.copy)
26
+ run_pre_setup(config.pre_setup)
27
+ exec_setup(config.setup, args)
28
+ end
29
+
30
+ private
31
+
32
+ def allocate_ports(port_names)
33
+ pool = PortPool.new
34
+ ports = pool.allocate(@worktree, port_names)
35
+ ports.each { |name, port| ENV[name] = port.to_s }
36
+ end
37
+
38
+ def copy_files(files)
39
+ files.each do |file|
40
+ src = File.join(@main_worktree, file)
41
+ if File.exist?(src)
42
+ FileUtils.cp(src, File.join(@worktree, file))
43
+ puts "Copied #{file}"
44
+ else
45
+ puts "Warning: #{file} not found in main worktree, skipping"
46
+ end
47
+ end
48
+ end
49
+
50
+ def run_pre_setup(commands)
51
+ commands.each do |cmd|
52
+ puts "Running: #{cmd}"
53
+ Dir.chdir(@worktree) do
54
+ system(cmd) || abort("Command failed: #{cmd}")
55
+ end
56
+ end
57
+ end
58
+
59
+ def exec_setup(setup_cmd, args)
60
+ puts "\n== Running #{setup_cmd} =="
61
+ Dir.chdir(@worktree) do
62
+ exec(*setup_cmd.shellsplit, *args)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,3 @@
1
+ module Bonchi
2
+ VERSION = "0.1.0.dev"
3
+ end
data/lib/bonchi.rb ADDED
@@ -0,0 +1,10 @@
1
+ require_relative "bonchi/version"
2
+ require_relative "bonchi/global_config"
3
+ require_relative "bonchi/git"
4
+ require_relative "bonchi/config"
5
+ require_relative "bonchi/port_pool"
6
+ require_relative "bonchi/setup"
7
+ require_relative "bonchi/cli"
8
+
9
+ module Bonchi
10
+ end
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bonchi
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.dev
5
+ platform: ruby
6
+ authors:
7
+ - Gert Goet
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: thor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ description: Manage git worktrees with automatic port allocation, file copying, and
27
+ setup commands
28
+ executables:
29
+ - bonchi
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - LICENSE.txt
34
+ - exe/bonchi
35
+ - lib/bonchi.rb
36
+ - lib/bonchi/cli.rb
37
+ - lib/bonchi/config.rb
38
+ - lib/bonchi/git.rb
39
+ - lib/bonchi/global_config.rb
40
+ - lib/bonchi/port_pool.rb
41
+ - lib/bonchi/setup.rb
42
+ - lib/bonchi/version.rb
43
+ homepage: https://github.com/gertgoet/bonchi
44
+ licenses:
45
+ - MIT
46
+ metadata: {}
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '3.3'
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 4.0.3
62
+ specification_version: 4
63
+ summary: Git worktree manager
64
+ test_files: []