bonchi 0.2.0.rc1 → 0.3.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c8bcf10affa988d4bff6b9d78fd53ea1be600a9c03e93bf95f083fadd210e3e6
4
- data.tar.gz: 40a744831b24891e328d4dbc400a4da030a98680279e06cd55ab77b0306d5375
3
+ metadata.gz: 137e1eebb563dfaf05a9e6928abeb3832ff8e6231f7e1b4ea176037b605b2489
4
+ data.tar.gz: 8f22938f47fbc5068cb0a40a02fd31c875b5cdc5b7e699b6049752060ec3accb
5
5
  SHA512:
6
- metadata.gz: 511dfe169d347f644e9f5c24db3a03ba3faba965ceda23848d06295b58bca16c7de3020164febd7cc2611f26878d421663a709d599edede7a71dc164e8ae891a
7
- data.tar.gz: 26f08d5c1e6725088ba9d2268a7ef0fe0daa604351c63afd4a44dd45208f21c68fce9270b8dbbabd104313b950f776de700e8b1a07eececa14d0007676260704
6
+ metadata.gz: 47d60879e7c32a8e518c3719d78c6c952dcc850c9897f49e4da0b4370d3a3e07e2d1233cf4b15a8eaf257671eae02aad4fd7d89d40d5a86525b8bf6a3ab6157b
7
+ data.tar.gz: f6a8c72b294fd4c6e29dff35d20c085463e0411bab413971c934ca43b80b331caa095b726d1cace6305a9db20865a6ec3877de1cdaf89d9d98f3aa9bbef2b284
data/lib/bonchi/cli.rb CHANGED
@@ -14,6 +14,13 @@ module Bonchi
14
14
  map "-v" => :version
15
15
 
16
16
  desc "create BRANCH [BASE]", "Create new branch + worktree"
17
+ long_desc <<~DESC
18
+ Create a new branch and worktree. BASE defaults to the repository's default branch
19
+ (e.g. main). If a worktree for BRANCH already exists, switches to it instead.
20
+
21
+ When a .worktree.yml exists in the main worktree, setup runs automatically
22
+ (copy files, allocate ports, run pre_setup and setup commands). Skip with --no-setup.
23
+ DESC
17
24
  option :setup, type: :boolean, default: true, desc: "Run setup after creating worktree"
18
25
  def create(branch, base = nil)
19
26
  base ||= Git.default_base_branch
@@ -38,6 +45,13 @@ module Bonchi
38
45
  end
39
46
 
40
47
  desc "switch BRANCH", "Switch to existing branch in worktree"
48
+ long_desc <<~DESC
49
+ Create a worktree for an existing branch and cd into it.
50
+ If a worktree for BRANCH already exists, switches to it instead.
51
+
52
+ The branch must already exist locally or on the remote.
53
+ To create a new branch, use `bonchi create` instead.
54
+ DESC
41
55
  def switch(branch)
42
56
  existing = Git.worktree_path_for(branch)
43
57
  if existing
@@ -58,6 +72,13 @@ module Bonchi
58
72
  end
59
73
 
60
74
  desc "pr NUMBER_OR_URL", "Checkout GitHub PR in worktree"
75
+ long_desc <<~DESC
76
+ Fetch a GitHub pull request and check it out in a new worktree.
77
+ Accepts a PR number (e.g. 123) or a full GitHub PR URL.
78
+
79
+ The worktree branch will be named pr-<number>.
80
+ If the worktree already exists, switches to it instead.
81
+ DESC
61
82
  def pr(input)
62
83
  pr_number = extract_pr_number(input)
63
84
  branch = "pr-#{pr_number}"
@@ -77,6 +98,22 @@ module Bonchi
77
98
  signal_cd(path)
78
99
  end
79
100
 
101
+ desc "init", "Generate a .worktree.yml in the current project"
102
+ long_desc <<~DESC
103
+ Generate a .worktree.yml config file in the current directory with
104
+ sensible defaults. Edit the file to customize which files to copy,
105
+ which ports to allocate, and what setup command to run.
106
+ DESC
107
+ def init
108
+ path = File.join(Dir.pwd, ".worktree.yml")
109
+ if File.exist?(path)
110
+ abort "Error: .worktree.yml already exists"
111
+ end
112
+
113
+ File.write(path, WORKTREE_YML_TEMPLATE)
114
+ puts "Created #{path}"
115
+ end
116
+
80
117
  desc "setup [-- ARGS...]", "Run setup in current worktree (ports, copy, pre_setup, setup cmd)"
81
118
  def setup(*args)
82
119
  Setup.new.run(args)
@@ -88,6 +125,12 @@ module Bonchi
88
125
  end
89
126
 
90
127
  desc "remove BRANCH", "Remove a worktree"
128
+ long_desc <<~DESC
129
+ Remove a worktree and its directory. Refuses to remove worktrees
130
+ with uncommitted changes or untracked files (use `git worktree remove --force` to override).
131
+
132
+ Aliases: rm
133
+ DESC
91
134
  def remove(branch)
92
135
  path = Git.worktree_path_for(branch)
93
136
  abort "Error: No worktree found for branch: #{branch}" unless path
@@ -97,6 +140,13 @@ module Bonchi
97
140
  end
98
141
 
99
142
  desc "prune", "Prune stale worktree admin files"
143
+ long_desc <<~DESC
144
+ Clean up stale worktree tracking data. Git internally tracks worktrees in
145
+ .git/worktrees/. When a worktree directory is deleted manually (e.g. rm -rf)
146
+ instead of via `bonchi remove`, the tracking data becomes stale.
147
+
148
+ This runs `git worktree prune` to remove those orphaned entries.
149
+ DESC
100
150
  def prune
101
151
  Git.worktree_prune
102
152
  puts "Pruned stale worktree administrative files"
@@ -133,6 +183,38 @@ module Bonchi
133
183
  end
134
184
  end
135
185
 
186
+ WORKTREE_YML_TEMPLATE = <<~YAML
187
+ # Worktree configuration for bonchi.
188
+ # See https://github.com/eval/bonchi
189
+
190
+ # Files to copy from the main worktree before setup.
191
+ # copy:
192
+ # - .env.local
193
+
194
+ # Env var names to allocate unique ports for (from global pool).
195
+ # ports:
196
+ # - PORT
197
+
198
+ # Regex replacements in copied files. Env vars ($VAR) are expanded.
199
+ # Short form:
200
+ # replace:
201
+ # .env.local:
202
+ # - "^PORT=.*": "PORT=$PORT"
203
+ # Full form (with optional missing: warn, default: halt):
204
+ # replace:
205
+ # .env.local:
206
+ # - match: "^PORT=.*"
207
+ # with: "PORT=$PORT"
208
+ # missing: warn
209
+
210
+ # Commands to run before the setup command (port env vars are available).
211
+ # pre_setup:
212
+ # - echo "preparing..."
213
+
214
+ # The setup command to run (default: bin/setup).
215
+ setup: bin/setup
216
+ YAML
217
+
136
218
  SHELL_ENV = <<~'SHELL'
137
219
  bonchi() {
138
220
  local bonchi_cd_file="${TMPDIR:-/tmp}/bonchi_cd.$$"
@@ -0,0 +1,20 @@
1
+ module Bonchi
2
+ module Colors
3
+ private
4
+
5
+ def color(name)
6
+ return "" if ENV.key?("NO_COLOR")
7
+
8
+ case name
9
+ when :red then "\e[31m"
10
+ when :yellow then "\e[33m"
11
+ end
12
+ end
13
+
14
+ def reset
15
+ return "" if ENV.key?("NO_COLOR")
16
+
17
+ "\e[0m"
18
+ end
19
+ end
20
+ end
data/lib/bonchi/config.rb CHANGED
@@ -2,14 +2,25 @@ require "yaml"
2
2
 
3
3
  module Bonchi
4
4
  class Config
5
- attr_reader :copy, :ports, :pre_setup, :setup
5
+ include Colors
6
+
7
+ KNOWN_KEYS = %w[copy ports replace pre_setup setup].freeze
8
+
9
+ attr_reader :copy, :ports, :replace, :pre_setup, :setup
6
10
 
7
11
  def initialize(path)
8
12
  data = YAML.load_file(path)
13
+
14
+ unknown = data.keys - KNOWN_KEYS
15
+ unknown.each { |k| warn "#{color(:yellow)}Warning:#{reset} unknown key '#{k}' in .worktree.yml, ignoring" }
16
+
9
17
  @copy = Array(data["copy"])
10
18
  @ports = Array(data["ports"])
19
+ @replace = data["replace"] || {}
11
20
  @pre_setup = Array(data["pre_setup"])
12
21
  @setup = data["setup"] || "bin/setup"
22
+
23
+ validate!
13
24
  end
14
25
 
15
26
  def self.from_main_worktree
@@ -19,5 +30,29 @@ module Bonchi
19
30
 
20
31
  new(path)
21
32
  end
33
+
34
+ private
35
+
36
+ def validate!
37
+ unless @replace.is_a?(Hash)
38
+ abort "#{color(:red)}Error:#{reset} 'replace' must be a mapping of filename to list of replacements"
39
+ end
40
+
41
+ @replace.each do |file, entries|
42
+ unless entries.is_a?(Array)
43
+ abort "#{color(:red)}Error:#{reset} 'replace.#{file}' must be a list of replacements"
44
+ end
45
+
46
+ entries.each do |entry|
47
+ unless entry.is_a?(Hash)
48
+ abort "#{color(:red)}Error:#{reset} each replacement in 'replace.#{file}' must be a mapping"
49
+ end
50
+ end
51
+ end
52
+
53
+ unless @setup.is_a?(String)
54
+ abort "#{color(:red)}Error:#{reset} 'setup' must be a string"
55
+ end
56
+ end
22
57
  end
23
58
  end
data/lib/bonchi/git.rb CHANGED
@@ -4,6 +4,11 @@ module Bonchi
4
4
  module Git
5
5
  module_function
6
6
 
7
+ def current_branch(worktree = nil)
8
+ dir = worktree || Dir.pwd
9
+ `git -C #{dir.shellescape} rev-parse --abbrev-ref HEAD`.strip
10
+ end
11
+
7
12
  def repo_name
8
13
  url = `git remote get-url origin 2>/dev/null`.strip
9
14
  base = url.empty? ? `git rev-parse --show-toplevel`.strip : url
data/lib/bonchi/setup.rb CHANGED
@@ -3,6 +3,8 @@ require "shellwords"
3
3
 
4
4
  module Bonchi
5
5
  class Setup
6
+ include Colors
7
+
6
8
  def initialize(worktree: nil)
7
9
  @worktree = worktree || Dir.pwd
8
10
  @main_worktree = Git.main_worktree
@@ -10,19 +12,23 @@ module Bonchi
10
12
 
11
13
  def run(args = [])
12
14
  if @worktree == @main_worktree
13
- abort "Error: already in the main worktree"
15
+ abort "#{color(:red)}Error:#{reset} already in the main worktree"
14
16
  end
15
17
 
16
18
  config = Config.from_main_worktree
17
- abort "Error: .worktree.yml not found in main worktree" unless config
19
+ abort "#{color(:red)}Error:#{reset} .worktree.yml not found in main worktree" unless config
18
20
 
19
- ENV["MAIN_WORKTREE"] = @main_worktree
20
- ENV["WORKTREE"] = @worktree
21
+ ENV["WORKTREE_MAIN"] = @main_worktree
22
+ ENV["WORKTREE_LINKED"] = @worktree
23
+ ENV["WORKTREE_BRANCH"] = Git.current_branch(@worktree)
24
+ ENV["WORKTREE_BRANCH_SLUG"] = ENV["WORKTREE_BRANCH"].gsub(/[^a-zA-Z0-9_]/, "_")
25
+ ENV["WORKTREE_ROOT"] ||= GlobalConfig.new.worktree_root
21
26
 
22
27
  puts "Setting up worktree from: #{@main_worktree}"
23
28
 
24
29
  allocate_ports(config.ports) if config.ports.any?
25
30
  copy_files(config.copy)
31
+ replace_in_files(config.replace) if config.replace.any?
26
32
  run_pre_setup(config.pre_setup)
27
33
  exec_setup(config.setup, args)
28
34
  end
@@ -42,8 +48,45 @@ module Bonchi
42
48
  FileUtils.cp(src, File.join(@worktree, file))
43
49
  puts "Copied #{file}"
44
50
  else
45
- puts "Warning: #{file} not found in main worktree, skipping"
51
+ puts "#{color(:yellow)}Warning:#{reset} #{file} not found in main worktree, skipping"
52
+ end
53
+ end
54
+ end
55
+
56
+ def replace_in_files(replacements)
57
+ replacements.each do |file, entries|
58
+ path = File.join(@worktree, file)
59
+ abort "#{color(:red)}Error:#{reset} #{file} not found" unless File.exist?(path)
60
+
61
+ content = File.read(path)
62
+ entries.each do |entry|
63
+ if entry.is_a?(Hash) && entry.key?("match")
64
+ pattern = entry["match"]
65
+ replacement = entry["with"]
66
+ missing = entry["missing"] || "halt"
67
+ elsif entry.is_a?(Hash)
68
+ pattern, replacement = entry.first
69
+ missing = "halt"
70
+ else
71
+ abort "#{color(:red)}Error:#{reset} invalid replace entry in #{file}: #{entry.inspect}"
72
+ end
73
+
74
+ expanded = replacement.gsub(/\$(\w+)/) { ENV[$1] || abort("#{color(:red)}Error:#{reset} $#{$1} not set") }
75
+ regex = Regexp.new(pattern)
76
+
77
+ unless content.match?(regex)
78
+ if missing == "warn"
79
+ puts "#{color(:yellow)}Warning:#{reset} pattern #{pattern} not found in #{file}, skipping"
80
+ next
81
+ else
82
+ abort "#{color(:red)}Error:#{reset} pattern #{pattern} not found in #{file}"
83
+ end
84
+ end
85
+
86
+ content = content.gsub(regex, expanded)
87
+ puts "Replaced #{pattern} in #{file}"
46
88
  end
89
+ File.write(path, content)
47
90
  end
48
91
  end
49
92
 
@@ -1,3 +1,3 @@
1
1
  module Bonchi
2
- VERSION = "0.2.0.rc1"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/bonchi.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require_relative "bonchi/version"
2
+ require_relative "bonchi/colors"
2
3
  require_relative "bonchi/global_config"
3
4
  require_relative "bonchi/git"
4
5
  require_relative "bonchi/config"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bonchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0.rc1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gert Goet
@@ -34,6 +34,7 @@ files:
34
34
  - exe/bonchi
35
35
  - lib/bonchi.rb
36
36
  - lib/bonchi/cli.rb
37
+ - lib/bonchi/colors.rb
37
38
  - lib/bonchi/config.rb
38
39
  - lib/bonchi/git.rb
39
40
  - lib/bonchi/global_config.rb