bonchi 0.6.0 → 0.8.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: 45384f6de454e34a40006fd28bcdad586dffcbb672d5b0e94c887614b35fee6e
4
- data.tar.gz: 8d360abbfdbf1166b6eb7d87d0e18799d19d3b2246704c6e25ea3b7d6b0e0dcf
3
+ metadata.gz: '053922f8049bc9788caaf72af772fcd9058945e2964183228c339922109029f4'
4
+ data.tar.gz: b2058d5ed48143f1eb54c42ecb614087bd4e3a23a461a31d0c749146366454cb
5
5
  SHA512:
6
- metadata.gz: 98a219b540749095fed54cfa5370081827cc79b50cbe2c959c8f1fd4a09db4ceafcb38256f098f2b32119896ef911499f69c9205440c120fad57b0712ac88afe
7
- data.tar.gz: 9edb8fb1e158affae3429a229c23e5b1fb1caa0b07d13cce0ec37dddd115e3e99e492d3b5d81194a3afd8af8a03f6a34a9e3d1deee7a54f7f385b2c66e27cb84
6
+ metadata.gz: 24cdf46ea9f71a1bfec30f23d2765b51a1dc036e06059693f14379b192822fd850a28ab667e8439a65ed20cb2cd5b4fd1c818213c5d5c34e46ff9c03d3cbdaa5
7
+ data.tar.gz: a512b18c8ba6e77f5cbbeaa8584738c1f41a3754fc08ffaa9f6a1a04d76a753194ec55837ce413545b351f59936b1640c1c53d91ac68bc9d40f75a604b04c096
data/lib/bonchi/cli.rb CHANGED
@@ -97,6 +97,7 @@ module Bonchi
97
97
 
98
98
  Git.fetch_pr(pr_number)
99
99
  Git.worktree_add(path, branch)
100
+ Git.set_pr_upstream(pr_number)
100
101
  puts "PR ##{pr_number} checked out at: #{path}"
101
102
 
102
103
  signal_cd(path)
@@ -114,7 +115,9 @@ module Bonchi
114
115
  which ports to allocate, and what setup command to run.
115
116
  DESC
116
117
  def init
117
- path = File.join(Dir.pwd, ".worktree.yml")
118
+ root = Git.toplevel
119
+ abort "Error: not inside a git repository" unless root
120
+ path = File.join(root, ".worktree.yml")
118
121
  if File.exist?(path)
119
122
  abort "Error: .worktree.yml already exists"
120
123
  end
@@ -281,17 +284,21 @@ module Bonchi
281
284
  # ports:
282
285
  # - PORT
283
286
 
284
- # Regex replacements in copied files. Env vars ($VAR) are expanded.
285
- # Short form:
286
- # replace:
287
+ # Edits applied to files. Env vars ($VAR) are expanded.
288
+ # Three actions: regex replace, append, upsert (replace-or-append).
289
+ # edit:
287
290
  # .env.local:
291
+ # # Replace — short form
288
292
  # - "^PORT=.*": "PORT=$PORT"
289
- # Full form (with optional missing: warn, default: halt):
290
- # replace:
291
- # .env.local:
292
- # - match: "^PORT=.*"
293
- # with: "PORT=$PORT"
293
+ # # Replace — full form (with optional missing: warn, default: halt)
294
+ # - match: "^DATABASE_URL=.*"
295
+ # with: "DATABASE_URL=postgres:///myapp_$WORKTREE_BRANCH_SLUG"
294
296
  # missing: warn
297
+ # # Append a line unconditionally
298
+ # - append: "FOO=bar"
299
+ # # Replace if pattern matches, otherwise append
300
+ # - upsert: "^DEBUG="
301
+ # with: "DEBUG=1"
295
302
 
296
303
  # Commands to run before the setup command (port env vars are available).
297
304
  # pre_setup:
data/lib/bonchi/config.rb CHANGED
@@ -4,7 +4,7 @@ module Bonchi
4
4
  class Config
5
5
  include Colors
6
6
 
7
- KNOWN_KEYS = %w[min_version copy link ports replace pre_setup setup].freeze
7
+ KNOWN_KEYS = %w[min_version copy link ports replace edit pre_setup setup].freeze
8
8
 
9
9
  attr_reader :copy, :link, :ports, :replace, :pre_setup, :setup
10
10
 
@@ -16,10 +16,14 @@ module Bonchi
16
16
 
17
17
  check_min_version!(data["min_version"]) if data["min_version"]
18
18
 
19
+ if data.key?("replace") && data.key?("edit")
20
+ abort "#{color(:red)}Error:#{reset} both 'edit' and 'replace' set in .worktree.yml — use 'edit' (preferred)"
21
+ end
22
+
19
23
  @copy = Array(data["copy"])
20
24
  @link = Array(data["link"])
21
25
  @ports = Array(data["ports"])
22
- @replace = data["replace"] || {}
26
+ @replace = data["edit"] || data["replace"] || {}
23
27
  @pre_setup = Array(data["pre_setup"])
24
28
  @setup = data["setup"] || "bin/setup"
25
29
 
@@ -49,17 +53,21 @@ module Bonchi
49
53
 
50
54
  def validate!
51
55
  unless @replace.is_a?(Hash)
52
- abort "#{color(:red)}Error:#{reset} 'replace' must be a mapping of filename to list of replacements"
56
+ abort "#{color(:red)}Error:#{reset} 'edit' must be a mapping of filename to list of edits"
53
57
  end
54
58
 
55
59
  @replace.each do |file, entries|
56
60
  unless entries.is_a?(Array)
57
- abort "#{color(:red)}Error:#{reset} 'replace.#{file}' must be a list of replacements"
61
+ abort "#{color(:red)}Error:#{reset} 'edit.#{file}' must be a list of edits"
58
62
  end
59
63
 
60
64
  entries.each do |entry|
61
65
  unless entry.is_a?(Hash)
62
- abort "#{color(:red)}Error:#{reset} each replacement in 'replace.#{file}' must be a mapping"
66
+ abort "#{color(:red)}Error:#{reset} each edit in 'edit.#{file}' must be a mapping"
67
+ end
68
+
69
+ if entry.key?("upsert") && !entry.key?("with")
70
+ abort "#{color(:red)}Error:#{reset} 'upsert' in 'edit.#{file}' requires a 'with' value"
63
71
  end
64
72
  end
65
73
  end
data/lib/bonchi/git.rb CHANGED
@@ -24,6 +24,11 @@ module Bonchi
24
24
  `git worktree list --porcelain`.lines.first.sub("worktree ", "").strip
25
25
  end
26
26
 
27
+ def toplevel
28
+ root = `git rev-parse --show-toplevel 2>/dev/null`.strip
29
+ root.empty? ? nil : root
30
+ end
31
+
27
32
  def worktree_list
28
33
  `git worktree list`.lines.map(&:strip).reject(&:empty?)
29
34
  end
@@ -87,6 +92,15 @@ module Bonchi
87
92
  system("git", "fetch", "origin", "pull/#{pr_number}/head:pr-#{pr_number}")
88
93
  end
89
94
 
95
+ def set_pr_upstream(pr_number)
96
+ head_ref = `gh pr view #{pr_number} --json headRefName -q .headRefName`.strip
97
+ return if head_ref.empty?
98
+
99
+ if system("git", "branch", "--set-upstream-to", "origin/#{head_ref}", "pr-#{pr_number}")
100
+ puts "Upstream set to origin/#{head_ref}"
101
+ end
102
+ end
103
+
90
104
  def worktree_dir(branch)
91
105
  File.join(GlobalConfig.new.worktree_root, repo_name, branch)
92
106
  end
@@ -1,3 +1,4 @@
1
+ require "set"
1
2
  require "yaml"
2
3
  require "socket"
3
4
 
@@ -6,6 +7,21 @@ module Bonchi
6
7
  DEFAULT_MIN = 4000
7
8
  DEFAULT_MAX = 5000
8
9
 
10
+ # Ports browsers refuse to connect to (Chrome: ERR_UNSAFE_PORT, Firefox: a
11
+ # similar block). Allocating one of these for a dev web server makes it
12
+ # unreachable in the browser even though the server binds fine. This is
13
+ # Chromium's kRestrictedPorts list (net/base/port_util.cc); ports outside
14
+ # the pool range are harmless to keep listed. 4045 (lockd) and 4190 (sieve)
15
+ # fall inside the default 4000..5000 range.
16
+ UNSAFE_PORTS = [
17
+ 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77,
18
+ 79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123,
19
+ 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526,
20
+ 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993,
21
+ 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566,
22
+ 6665, 6666, 6667, 6668, 6669, 6697, 10080
23
+ ].to_set.freeze
24
+
9
25
  def initialize
10
26
  @path = GlobalConfig.config_path
11
27
  load_config
@@ -26,7 +42,7 @@ module Bonchi
26
42
 
27
43
  new_ports = {}
28
44
  port_names.each do |name|
29
- port = (@min..@max).find { |p| !used.include?(p) && port_available?(p) }
45
+ port = (@min..@max).find { |p| !used.include?(p) && !UNSAFE_PORTS.include?(p) && port_available?(p) }
30
46
  abort "Error: no available port for #{name}" unless port
31
47
  used << port
32
48
  new_ports[name] = port
data/lib/bonchi/setup.rb CHANGED
@@ -6,7 +6,7 @@ module Bonchi
6
6
  include Colors
7
7
 
8
8
  def initialize(worktree: nil)
9
- @worktree = worktree || Dir.pwd
9
+ @worktree = worktree || Git.toplevel || Dir.pwd
10
10
  @main_worktree = Git.main_worktree
11
11
  end
12
12
 
@@ -21,8 +21,13 @@ module Bonchi
21
21
  abort "#{color(:red)}Error:#{reset} already in the main worktree"
22
22
  end
23
23
 
24
- config = Config.from_main_worktree
25
- abort "#{color(:red)}Error:#{reset} .worktree.yml not found in main worktree" unless config
24
+ config = Config.from_worktree(@worktree)
25
+ if config
26
+ puts "Using .worktree.yml from linked worktree"
27
+ else
28
+ config = Config.from_main_worktree
29
+ abort "#{color(:red)}Error:#{reset} .worktree.yml not found in main worktree" unless config
30
+ end
26
31
 
27
32
  last_step = upto || STEPS.last
28
33
  run_steps = STEPS[0..STEPS.index(last_step)]
@@ -37,14 +42,6 @@ module Bonchi
37
42
 
38
43
  copy_files(config.copy) if run_steps.include?("copy")
39
44
  link_files(config.link) if run_steps.include?("link")
40
-
41
- # Prefer linked worktree's .worktree.yml if it was copied or already exists
42
- linked_config = Config.from_worktree(@worktree)
43
- if linked_config
44
- puts "Using .worktree.yml from linked worktree"
45
- config = linked_config
46
- end
47
-
48
45
  allocate_ports(config.ports) if run_steps.include?("ports") && config.ports.any?
49
46
  replace_in_files(config.replace) if run_steps.include?("replace") && config.replace.any?
50
47
  run_pre_setup(config.pre_setup) if run_steps.include?("pre_setup")
@@ -96,37 +93,64 @@ module Bonchi
96
93
  abort "#{color(:red)}Error:#{reset} #{file} not found" unless File.exist?(path)
97
94
 
98
95
  content = File.read(path)
99
- entries.each do |entry|
100
- if entry.is_a?(Hash) && entry.key?("match")
101
- pattern = entry["match"]
102
- replacement = entry["with"]
103
- missing = entry["missing"] || "halt"
104
- elsif entry.is_a?(Hash)
105
- pattern, replacement = entry.first
106
- missing = "halt"
107
- else
108
- abort "#{color(:red)}Error:#{reset} invalid replace entry in #{file}: #{entry.inspect}"
109
- end
110
-
111
- expanded = replacement.gsub(/\$(\w+)/) { ENV[$1] || abort("#{color(:red)}Error:#{reset} $#{$1} not set") }
112
- regex = Regexp.new(pattern)
113
-
114
- unless content.match?(regex)
115
- if missing == "warn"
116
- puts "#{color(:yellow)}Warning:#{reset} pattern #{pattern} not found in #{file}, skipping"
117
- next
118
- else
119
- abort "#{color(:red)}Error:#{reset} pattern #{pattern} not found in #{file}"
120
- end
121
- end
122
-
123
- content = content.gsub(regex, expanded)
124
- puts "Replaced #{pattern} in #{file}"
125
- end
96
+ entries.each { |entry| content = apply_edit(entry, content, file) }
126
97
  File.write(path, content)
127
98
  end
128
99
  end
129
100
 
101
+ def apply_edit(entry, content, file)
102
+ unless entry.is_a?(Hash)
103
+ abort "#{color(:red)}Error:#{reset} invalid edit entry in #{file}: #{entry.inspect}"
104
+ end
105
+
106
+ if entry.key?("append")
107
+ line = expand(entry["append"])
108
+ puts "Appended to #{file}"
109
+ ensure_trailing_newline(content) + line + "\n"
110
+ elsif entry.key?("upsert")
111
+ unless entry.key?("with")
112
+ abort "#{color(:red)}Error:#{reset} 'upsert' requires 'with' in #{file}"
113
+ end
114
+ pattern = entry["upsert"]
115
+ replacement = expand(entry["with"])
116
+ regex = Regexp.new(pattern)
117
+ if content.match?(regex)
118
+ puts "Upserted #{pattern} in #{file} (matched)"
119
+ content.gsub(regex, replacement)
120
+ else
121
+ puts "Upserted #{pattern} in #{file} (appended)"
122
+ ensure_trailing_newline(content) + replacement + "\n"
123
+ end
124
+ elsif entry.key?("match")
125
+ replace(content, entry["match"], expand(entry["with"]), entry["missing"] || "halt", file)
126
+ else
127
+ pattern, replacement = entry.first
128
+ replace(content, pattern, expand(replacement), "halt", file)
129
+ end
130
+ end
131
+
132
+ def replace(content, pattern, replacement, missing, file)
133
+ regex = Regexp.new(pattern)
134
+ unless content.match?(regex)
135
+ if missing == "warn"
136
+ puts "#{color(:yellow)}Warning:#{reset} pattern #{pattern} not found in #{file}, skipping"
137
+ return content
138
+ else
139
+ abort "#{color(:red)}Error:#{reset} pattern #{pattern} not found in #{file}"
140
+ end
141
+ end
142
+ puts "Replaced #{pattern} in #{file}"
143
+ content.gsub(regex, replacement)
144
+ end
145
+
146
+ def expand(value)
147
+ value.to_s.gsub(/\$(\w+)/) { ENV[$1] || abort("#{color(:red)}Error:#{reset} $#{$1} not set") }
148
+ end
149
+
150
+ def ensure_trailing_newline(content)
151
+ (content.empty? || content.end_with?("\n")) ? content : content + "\n"
152
+ end
153
+
130
154
  def run_pre_setup(commands)
131
155
  commands.each do |cmd|
132
156
  puts "Running: #{cmd}"
@@ -1,3 +1,3 @@
1
1
  module Bonchi
2
- VERSION = "0.6.0"
2
+ VERSION = "0.8.0"
3
3
  end
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.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gert Goet
@@ -41,10 +41,14 @@ files:
41
41
  - lib/bonchi/port_pool.rb
42
42
  - lib/bonchi/setup.rb
43
43
  - lib/bonchi/version.rb
44
- homepage: https://github.com/gertgoet/bonchi
44
+ homepage: https://github.com/eval/bonchi
45
45
  licenses:
46
46
  - MIT
47
- metadata: {}
47
+ metadata:
48
+ homepage_uri: https://github.com/eval/bonchi
49
+ source_code_uri: https://github.com/eval/bonchi
50
+ changelog_uri: https://github.com/eval/bonchi/blob/main/CHANGELOG.md
51
+ rubygems_mfa_required: 'true'
48
52
  rdoc_options: []
49
53
  require_paths:
50
54
  - lib
@@ -59,7 +63,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
59
63
  - !ruby/object:Gem::Version
60
64
  version: '0'
61
65
  requirements: []
62
- rubygems_version: 3.6.7
66
+ rubygems_version: 4.0.10
63
67
  specification_version: 4
64
68
  summary: Git worktree manager
65
69
  test_files: []