bonchi 0.2.0 → 0.4.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: f726dfa32881aaae2a225b30e26b3a31fe3bfa2714709a33738910c4a42d2daf
4
- data.tar.gz: 4ef9df6de06aecfc41d5bf69fe651114123bccb48b0fb9cc67ed8c26f1ab6bcb
3
+ metadata.gz: d0506e896860d23e52035729f193224f9a1d2a54799b2f7deb73691ddde7ba61
4
+ data.tar.gz: 831b6a75a71e40d6ccb7d09c8db83aafff0a56ea301d6083649b440245cfb3c6
5
5
  SHA512:
6
- metadata.gz: 5388c61deec0daf249af8c2033772bf9c2b22d51a894b6cfb75f887d65d14c1bdf298d7e96d6c7513717b0d0676fcb0f5f5a7bf518227be3b380487afd8774f3
7
- data.tar.gz: 919fe02691d9f5b4c7ca3c2ac31c8cb365b6bb9533ac56470d2803d972881f3375ee3b72a64ecbb4e983e29898629d331f451808ee20b56e418df7d4ab91a671
6
+ metadata.gz: 7e483c14fee4002b3cbcb0816a4cd351b190ff50a7e01e0db48606e749df21a954d71ea79c8ad7ae982807f90618f44f6461fbd26ad7814196c1c22c53c51bd0
7
+ data.tar.gz: 178967a54213235cd13552fb36283a12ccb4ea669533df6abad0a0049fb3063cc7d6332e9e076e7c6cb407650628d597eb884bd3e5827a11cecc355eef3bda80
data/lib/bonchi/cli.rb CHANGED
@@ -127,16 +127,18 @@ module Bonchi
127
127
  desc "remove BRANCH", "Remove a worktree"
128
128
  long_desc <<~DESC
129
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).
130
+ with uncommitted changes or untracked files unless --force is used.
131
131
 
132
132
  Aliases: rm
133
133
  DESC
134
+ option :force, type: :boolean, default: false, desc: "Force removal even with uncommitted changes"
134
135
  def remove(branch)
135
136
  path = Git.worktree_path_for(branch)
136
137
  abort "Error: No worktree found for branch: #{branch}" unless path
137
138
 
138
- Git.worktree_remove(path)
139
+ Git.worktree_remove(path, force: options[:force])
139
140
  puts "Removed worktree: #{path}"
141
+ signal_cd(Git.main_worktree)
140
142
  end
141
143
 
142
144
  desc "prune", "Prune stale worktree admin files"
@@ -183,18 +185,37 @@ module Bonchi
183
185
  end
184
186
  end
185
187
 
186
- WORKTREE_YML_TEMPLATE = <<~'YAML'
188
+ WORKTREE_YML_TEMPLATE = <<~YAML
189
+ # Worktree configuration for bonchi.
190
+ # See https://github.com/eval/bonchi
191
+
187
192
  # Files to copy from the main worktree before setup.
188
193
  # copy:
189
194
  # - .env.local
190
195
 
196
+ # Files to symlink from the main worktree (useful for large directories).
197
+ # link:
198
+ # - node_modules
199
+
191
200
  # Env var names to allocate unique ports for (from global pool).
192
201
  # ports:
193
202
  # - PORT
194
203
 
204
+ # Regex replacements in copied files. Env vars ($VAR) are expanded.
205
+ # Short form:
206
+ # replace:
207
+ # .env.local:
208
+ # - "^PORT=.*": "PORT=$PORT"
209
+ # Full form (with optional missing: warn, default: halt):
210
+ # replace:
211
+ # .env.local:
212
+ # - match: "^PORT=.*"
213
+ # with: "PORT=$PORT"
214
+ # missing: warn
215
+
195
216
  # Commands to run before the setup command (port env vars are available).
196
217
  # pre_setup:
197
- # - sed -i '' "s|^PORT=.*|PORT=$PORT|" .env.local
218
+ # - echo "preparing..."
198
219
 
199
220
  # The setup command to run (default: bin/setup).
200
221
  setup: bin/setup
@@ -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,22 +2,61 @@ 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 link ports replace pre_setup setup].freeze
8
+
9
+ attr_reader :copy, :link, :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"])
18
+ @link = Array(data["link"])
10
19
  @ports = Array(data["ports"])
20
+ @replace = data["replace"] || {}
11
21
  @pre_setup = Array(data["pre_setup"])
12
22
  @setup = data["setup"] || "bin/setup"
23
+
24
+ validate!
13
25
  end
14
26
 
15
27
  def self.from_main_worktree
16
- main = Git.main_worktree
17
- path = File.join(main, ".worktree.yml")
28
+ from_worktree(Git.main_worktree)
29
+ end
30
+
31
+ def self.from_worktree(dir)
32
+ path = File.join(dir, ".worktree.yml")
18
33
  return nil unless File.exist?(path)
19
34
 
20
35
  new(path)
21
36
  end
37
+
38
+ private
39
+
40
+ def validate!
41
+ unless @replace.is_a?(Hash)
42
+ abort "#{color(:red)}Error:#{reset} 'replace' must be a mapping of filename to list of replacements"
43
+ end
44
+
45
+ @replace.each do |file, entries|
46
+ unless entries.is_a?(Array)
47
+ abort "#{color(:red)}Error:#{reset} 'replace.#{file}' must be a list of replacements"
48
+ end
49
+
50
+ entries.each do |entry|
51
+ unless entry.is_a?(Hash)
52
+ abort "#{color(:red)}Error:#{reset} each replacement in 'replace.#{file}' must be a mapping"
53
+ end
54
+ end
55
+ end
56
+
57
+ unless @setup.is_a?(String)
58
+ abort "#{color(:red)}Error:#{reset} 'setup' must be a string"
59
+ end
60
+ end
22
61
  end
23
62
  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
@@ -45,8 +50,11 @@ module Bonchi
45
50
  system("git", "worktree", "add", path, "-b", branch, base) || abort("Failed to add worktree")
46
51
  end
47
52
 
48
- def worktree_remove(path)
49
- system("git", "worktree", "remove", path) || abort("Failed to remove worktree")
53
+ def worktree_remove(path, force: false)
54
+ cmd = ["git", "worktree", "remove"]
55
+ cmd << "--force" if force
56
+ cmd << path
57
+ system(*cmd) || abort("Failed to remove worktree")
50
58
  end
51
59
 
52
60
  def worktree_prune
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,32 @@ 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
- allocate_ports(config.ports) if config.ports.any?
25
29
  copy_files(config.copy)
30
+ link_files(config.link)
31
+
32
+ # Prefer linked worktree's .worktree.yml if it was copied or already exists
33
+ linked_config = Config.from_worktree(@worktree)
34
+ if linked_config
35
+ puts "Using .worktree.yml from linked worktree"
36
+ config = linked_config
37
+ end
38
+
39
+ allocate_ports(config.ports) if config.ports.any?
40
+ replace_in_files(config.replace) if config.replace.any?
26
41
  run_pre_setup(config.pre_setup)
27
42
  exec_setup(config.setup, args)
28
43
  end
@@ -35,15 +50,71 @@ module Bonchi
35
50
  ports.each { |name, port| ENV[name] = port.to_s }
36
51
  end
37
52
 
53
+ def link_files(files)
54
+ files.each do |file|
55
+ src = File.join(@main_worktree, file)
56
+ dest = File.join(@worktree, file)
57
+
58
+ unless File.exist?(src)
59
+ puts "#{color(:yellow)}Warning:#{reset} #{file} not found in main worktree, skipping"
60
+ next
61
+ end
62
+
63
+ FileUtils.mkdir_p(File.dirname(dest))
64
+ FileUtils.rm_rf(dest) if File.exist?(dest) || File.symlink?(dest)
65
+ FileUtils.ln_s(src, dest)
66
+ puts "Linked #{file} -> #{src}"
67
+ end
68
+ end
69
+
38
70
  def copy_files(files)
39
71
  files.each do |file|
40
72
  src = File.join(@main_worktree, file)
73
+ dest = File.join(@worktree, file)
41
74
  if File.exist?(src)
42
- FileUtils.cp(src, File.join(@worktree, file))
75
+ FileUtils.mkdir_p(File.dirname(dest))
76
+ FileUtils.cp(src, dest)
43
77
  puts "Copied #{file}"
44
78
  else
45
- puts "Warning: #{file} not found in main worktree, skipping"
79
+ puts "#{color(:yellow)}Warning:#{reset} #{file} not found in main worktree, skipping"
80
+ end
81
+ end
82
+ end
83
+
84
+ def replace_in_files(replacements)
85
+ replacements.each do |file, entries|
86
+ path = File.join(@worktree, file)
87
+ abort "#{color(:red)}Error:#{reset} #{file} not found" unless File.exist?(path)
88
+
89
+ content = File.read(path)
90
+ entries.each do |entry|
91
+ if entry.is_a?(Hash) && entry.key?("match")
92
+ pattern = entry["match"]
93
+ replacement = entry["with"]
94
+ missing = entry["missing"] || "halt"
95
+ elsif entry.is_a?(Hash)
96
+ pattern, replacement = entry.first
97
+ missing = "halt"
98
+ else
99
+ abort "#{color(:red)}Error:#{reset} invalid replace entry in #{file}: #{entry.inspect}"
100
+ end
101
+
102
+ expanded = replacement.gsub(/\$(\w+)/) { ENV[$1] || abort("#{color(:red)}Error:#{reset} $#{$1} not set") }
103
+ regex = Regexp.new(pattern)
104
+
105
+ unless content.match?(regex)
106
+ if missing == "warn"
107
+ puts "#{color(:yellow)}Warning:#{reset} pattern #{pattern} not found in #{file}, skipping"
108
+ next
109
+ else
110
+ abort "#{color(:red)}Error:#{reset} pattern #{pattern} not found in #{file}"
111
+ end
112
+ end
113
+
114
+ content = content.gsub(regex, expanded)
115
+ puts "Replaced #{pattern} in #{file}"
46
116
  end
117
+ File.write(path, content)
47
118
  end
48
119
  end
49
120
 
@@ -1,3 +1,3 @@
1
1
  module Bonchi
2
- VERSION = "0.2.0"
2
+ VERSION = "0.4.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
4
+ version: 0.4.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