brunch 0.0.1 → 0.8.1

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.
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+
6
+ module Brunch
7
+ module Hooks
8
+ EVENTS = %w[branch-created remote-branch-created remote-branch-updated post-checkout worktree-created
9
+ worktree-removed worktree-moved worktree-pruned worktree-repaired].freeze
10
+
11
+ module_function
12
+
13
+ def install
14
+ abort "git-hooks-ext must be installed before running this command." unless system("ghe", "--version",
15
+ out: File::NULL, err: File::NULL)
16
+ abort "Could not install the git-hooks-ext bridge." unless system("ghe", "install")
17
+
18
+ hooks_dir = git_output("rev-parse", "--git-path", "hooks")
19
+ EVENTS.each { |event| install_hook(File.join(hooks_dir, event), event) }
20
+ puts "Brunch hooks are installed."
21
+ end
22
+
23
+ def dispatch(event, arguments)
24
+ case event
25
+ when "post-checkout"
26
+ CLI.start(["activate"]) if arguments.fetch(2, "0") == "1"
27
+ when "branch-created", "remote-branch-created", "remote-branch-updated"
28
+ CLI.start(["register", arguments.fetch(0)])
29
+ when "worktree-created", "worktree-removed", "worktree-moved", "worktree-pruned", "worktree-repaired"
30
+ CLI.start(["worktree-event", event, *arguments])
31
+ else
32
+ abort "Unknown hook event: #{event}"
33
+ end
34
+ end
35
+
36
+ def git_output(*arguments)
37
+ output, status = Open3.capture2("git", *arguments)
38
+ abort "Git command failed: git #{arguments.join(' ')}" unless status.success?
39
+
40
+ output.strip
41
+ end
42
+ private_class_method :git_output
43
+
44
+ def install_hook(path, event)
45
+ contents = hook_contents(event)
46
+ abort "Refusing to replace existing hook: #{path}" if File.exist?(path) && File.read(path) != contents && !brunch_hook?(path)
47
+
48
+ FileUtils.mkdir_p(File.dirname(path))
49
+ File.write(path, contents)
50
+ FileUtils.chmod(0o755, path)
51
+ end
52
+ private_class_method :install_hook
53
+
54
+ def brunch_hook?(path)
55
+ contents = File.read(path)
56
+ contents.include?("require \"brunch\"") && contents.include?("Brunch::Hooks.dispatch")
57
+ end
58
+ private_class_method :brunch_hook?
59
+
60
+ def hook_contents(event)
61
+ library_path = File.expand_path("../brunch.rb", __dir__)
62
+ <<~RUBY
63
+ #!#{Gem.ruby}
64
+ # frozen_string_literal: true
65
+
66
+ repo_root = `git rev-parse --show-toplevel`.strip
67
+ gemfile = File.join(repo_root, "Gemfile")
68
+ bundled = false
69
+ if File.file?(gemfile) && File.read(gemfile).match?(/gem[[:space:]]+["']brunch["']/)
70
+ ENV["BUNDLE_GEMFILE"] = gemfile
71
+ begin
72
+ require "bundler/setup"
73
+ bundled = true
74
+ rescue LoadError, StandardError
75
+ ENV.delete("BUNDLE_GEMFILE")
76
+ end
77
+ end
78
+
79
+ if !bundled && File.file?(gemfile)
80
+ require #{library_path.inspect}
81
+ else
82
+ require "brunch"
83
+ end
84
+ Brunch::Hooks.dispatch(#{event.inspect}, ARGV)
85
+ RUBY
86
+ end
87
+ private_class_method :hook_contents
88
+ end
89
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "socket"
6
+
7
+ module Brunch
8
+ module Managers
9
+ class Base
10
+ def initialize(configuration)
11
+ @configuration = configuration
12
+ end
13
+
14
+ def available? = true
15
+
16
+ def create(_entry) = true
17
+
18
+ def environment(entry)
19
+ {
20
+ "BRUNCH_REF" => entry.fetch("ref", ""),
21
+ "BRUNCH_PORT" => entry.fetch("port").to_s,
22
+ "BRUNCH_PROJECT" => entry.fetch("project"),
23
+ "BRUNCH_SNAPSHOT" => entry.fetch("snapshot")
24
+ }
25
+ end
26
+
27
+ def port_available?(port)
28
+ socket = TCPSocket.new("127.0.0.1", port)
29
+ socket.close
30
+ false
31
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH
32
+ true
33
+ end
34
+
35
+ def stop(_entry); end
36
+
37
+ def status(_entry) = "unknown"
38
+
39
+ def healthy?(_entry) = false
40
+
41
+ def logs(_entry, *_arguments)
42
+ warn "Logs are not supported by this manager."
43
+ false
44
+ end
45
+
46
+ def reset(entry)
47
+ stop(entry)
48
+ remove_managed_files(entry)
49
+ end
50
+
51
+ def remove(entry)
52
+ stop(entry)
53
+ remove_managed_files(entry)
54
+ true
55
+ end
56
+
57
+ def remove_managed_files(entry)
58
+ target = entry["source_type"] == "worktree" ? entry["control"] : entry.fetch("snapshot")
59
+ FileUtils.rm_rf(target) if target
60
+ end
61
+ end
62
+
63
+ class DockerCompose < Base
64
+ def available?
65
+ system("docker", "info", out: File::NULL, err: File::NULL)
66
+ end
67
+
68
+ def start(entry) = compose(entry, "up", "--detach", "--build", "--remove-orphans")
69
+
70
+ def status(entry)
71
+ return "unavailable" unless available?
72
+
73
+ healthy?(entry) ? "running" : "stopped"
74
+ end
75
+
76
+ def healthy?(entry)
77
+ return false unless available?
78
+
79
+ output, status = Open3.capture2(*compose_command(entry, "ps", "--status", "running", "--quiet"))
80
+ status.success? && !output.strip.empty?
81
+ end
82
+
83
+ def logs(entry, *arguments)
84
+ arguments = ["--tail", "100"] if arguments.empty?
85
+ compose(entry, "logs", *arguments)
86
+ end
87
+
88
+ def port_available?(port)
89
+ ports, = Open3.capture2(*ports_command)
90
+ return false if ports.match?(/:#{port}->/)
91
+
92
+ super
93
+ end
94
+
95
+ def stop(entry)
96
+ compose(entry, "stop") if available?
97
+ end
98
+
99
+ def reset(entry)
100
+ compose(entry, "down", "--remove-orphans") if available?
101
+ remove_managed_files(entry)
102
+ end
103
+
104
+ def remove(entry)
105
+ return false unless available? && compose(entry, "down", "--volumes", "--remove-orphans")
106
+
107
+ remove_managed_files(entry)
108
+ true
109
+ end
110
+
111
+ private
112
+
113
+ def compose_command(entry, *arguments)
114
+ directory = compose_directory(entry)
115
+ compose_file = File.join(directory, entry.fetch("compose_file"))
116
+ ["docker", "compose", "--project-name", entry.fetch("project"), "--project-directory", directory, "--file",
117
+ compose_file, *arguments]
118
+ end
119
+
120
+ def compose_directory(entry)
121
+ source = entry.fetch("snapshot")
122
+ File.file?(File.join(source, entry.fetch("compose_file"))) ? source : entry.fetch("control", source)
123
+ end
124
+
125
+ def ports_command = ["docker", "ps", "--format", "{{.Ports}}"]
126
+
127
+ def compose(entry, *arguments)
128
+ system(environment(entry), *compose_command(entry, *arguments))
129
+ end
130
+ end
131
+
132
+ class PodmanCompose < DockerCompose
133
+ def available?
134
+ system("podman", "info", out: File::NULL, err: File::NULL)
135
+ end
136
+
137
+ def healthy?(entry)
138
+ return false unless available?
139
+
140
+ output, status = Open3.capture2("podman", "ps", "--filter",
141
+ "label=com.docker.compose.project=#{entry.fetch('project')}", "--quiet")
142
+ status.success? && !output.strip.empty?
143
+ end
144
+
145
+ private
146
+
147
+ def compose_command(entry, *arguments)
148
+ directory = compose_directory(entry)
149
+ compose_file = File.join(directory, entry.fetch("compose_file"))
150
+ ["podman", "compose", "-p", entry.fetch("project"), "-f", compose_file, *arguments]
151
+ end
152
+
153
+ def ports_command = ["podman", "ps", "--format", "{{.Ports}}"]
154
+
155
+ def compose(entry, *arguments)
156
+ system(environment(entry), *compose_command(entry, *arguments), chdir: compose_directory(entry))
157
+ end
158
+ end
159
+
160
+ class LocalProcess < Base
161
+ def start(entry)
162
+ log_directory = entry["source_type"] == "worktree" ? entry.fetch("control") : entry.fetch("snapshot")
163
+ log_path = File.join(log_directory, ".brunch.log")
164
+ entry["pid"] =
165
+ Process.spawn(environment(entry), "sh", "-lc", @configuration.fetch("command"), chdir: entry.fetch("snapshot"),
166
+ out: [log_path, "a"], err: [log_path, "a"], pgroup: true)
167
+ entry["log_path"] = log_path
168
+ Process.detach(entry.fetch("pid"))
169
+ true
170
+ end
171
+
172
+ def stop(entry)
173
+ pid = entry["pid"]
174
+ return true unless pid
175
+
176
+ Process.kill("TERM", -pid)
177
+ true
178
+ rescue Errno::ESRCH
179
+ true
180
+ end
181
+
182
+ def status(entry) = running?(entry) ? "running" : "stopped"
183
+ def healthy?(entry) = running?(entry)
184
+
185
+ def logs(entry, *arguments)
186
+ return super unless arguments.empty?
187
+
188
+ path = entry["log_path"]
189
+ return super unless path && File.file?(path)
190
+
191
+ puts File.readlines(path).last(100)
192
+ true
193
+ end
194
+
195
+ private
196
+
197
+ def running?(entry)
198
+ Process.kill(0, entry.fetch("pid"))
199
+ true
200
+ rescue Errno::ESRCH
201
+ false
202
+ end
203
+ end
204
+
205
+ # Runs project-provided commands, making Brunch compatible with tools such
206
+ # as Podman, Kubernetes wrappers, Foreman, or custom process supervisors.
207
+ class Command < Base
208
+ def create(entry) = run("create", entry, optional: true)
209
+ def start(entry) = run("start", entry)
210
+ def stop(entry) = run("stop", entry)
211
+
212
+ def remove(entry)
213
+ run("remove", entry) && super
214
+ end
215
+
216
+ def status(entry)
217
+ if command?("status")
218
+ run("status", entry) ? "running" : "stopped"
219
+ else
220
+ "unknown"
221
+ end
222
+ end
223
+
224
+ def healthy?(entry)
225
+ return false unless command?("health")
226
+
227
+ run("health", entry)
228
+ end
229
+
230
+ def logs(entry, *arguments)
231
+ return super unless command?("logs")
232
+
233
+ run("logs", entry, arguments: arguments)
234
+ end
235
+
236
+ private
237
+
238
+ def run(action, entry, optional: false, arguments: [])
239
+ command = @configuration.fetch("commands")[action]
240
+ return true if optional && !command
241
+
242
+ abort "Missing commands.#{action} for the command manager." unless command
243
+ directory = if File.directory?(entry.fetch("snapshot"))
244
+ entry.fetch("snapshot")
245
+ else
246
+ entry.fetch("control",
247
+ entry.fetch("snapshot"))
248
+ end
249
+ system(environment(entry), "sh", "-lc", command, "brunch", *arguments, chdir: directory)
250
+ end
251
+
252
+ def command?(action) = @configuration.fetch("commands").key?(action)
253
+ end
254
+
255
+ module_function
256
+
257
+ def build(configuration)
258
+ case configuration.fetch("manager")
259
+ when "docker_compose" then DockerCompose.new(configuration)
260
+ when "podman_compose" then PodmanCompose.new(configuration)
261
+ when "local_process" then LocalProcess.new(configuration)
262
+ when "command" then Command.new(configuration)
263
+ else abort "Unknown Brunch manager: #{configuration.fetch('manager')}."
264
+ end
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brunch
4
+ VERSION = "0.8.1"
5
+ end
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Brunch
6
+ module WorktreeMode
7
+ def worktree_command(arguments)
8
+ case arguments
9
+ in ["worktree-event", "worktree-created", path, *_]
10
+ worktree_activate(path) if File.directory?(path)
11
+ in ["worktree-event", "worktree-removed" | "worktree-pruned", path, *_]
12
+ worktree_remove(path)
13
+ in ["worktree-event", "worktree-moved", old_path, new_path, *_]
14
+ worktree_move(old_path, new_path)
15
+ in ["worktree-event", "worktree-repaired", path, *_]
16
+ worktree_repair(path) if File.directory?(path)
17
+ in ["activate"] then worktree_activate(repo_root)
18
+ in ["restart"] then worktree_activate(repo_root, force: true)
19
+ in ["start", ref]
20
+ abort "Start the checked-out branch from its worktree." unless ref == worktree_ref(repo_root).first
21
+ worktree_activate(repo_root)
22
+ in ["stop"] then worktree_stop(repo_root)
23
+ in ["cleanup"] | ["register", _] then worktree_cleanup
24
+ in ["status"] then worktree_status
25
+ in ["ports"] then worktree_ports
26
+ in ["port"] then worktree_port
27
+ in ["logs", *arguments] then worktree_logs(arguments)
28
+ in ["run", "--", *command] if !command.empty? then worktree_run(command)
29
+ in ["doctor"] then worktree_doctor
30
+ else
31
+ warn "Usage: brunch {activate|start <current-ref>|stop|restart|status|ports|port|logs [args]|run -- <cmd>|doctor|cleanup}"
32
+ return 64
33
+ end
34
+ 0
35
+ end
36
+
37
+ def with_state_lock
38
+ FileUtils.mkdir_p(state_root)
39
+ File.open(File.join(state_root, "lock"), "w") do |lock|
40
+ lock.flock(File::LOCK_EX)
41
+ data = state
42
+ result = yield(data)
43
+ save_state(data)
44
+ result
45
+ end
46
+ end
47
+
48
+ def with_worktree_lock(id)
49
+ directory = File.join(state_root, "worktree-locks")
50
+ FileUtils.mkdir_p(directory)
51
+ File.open(File.join(directory, id), "w") do |lock|
52
+ lock.flock(File::LOCK_EX)
53
+ yield
54
+ end
55
+ end
56
+
57
+ def worktree_identity(path)
58
+ git_dir, status = Open3.capture2("git", "-C", path, "rev-parse", "--absolute-git-dir")
59
+ abort "Not a Git worktree: #{path}" unless status.success?
60
+ Digest::SHA256.hexdigest(File.realpath(git_dir.strip))[0, 16]
61
+ end
62
+
63
+ def canonical_worktree_path(path)
64
+ File.join(File.realpath(File.dirname(path)), File.basename(path))
65
+ rescue Errno::ENOENT
66
+ File.expand_path(path)
67
+ end
68
+
69
+ def worktree_ref(path)
70
+ branch, status = Open3.capture2("git", "-C", path, "symbolic-ref", "--quiet", "--short", "HEAD")
71
+ head, head_status = Open3.capture2("git", "-C", path, "rev-parse", "HEAD")
72
+ abort "Worktree has no commit: #{path}" unless head_status.success?
73
+ [status.success? ? branch.strip : "detached-#{head.strip[0, 12]}", head.strip]
74
+ end
75
+
76
+ def worktree_manager(entry) = Managers.build(entry.fetch("configuration"))
77
+
78
+ def current_worktree_entry(data, path = repo_root)
79
+ id = worktree_identity(path)
80
+ record = data.fetch("worktrees", {})[id]
81
+ return [nil, nil] unless record
82
+
83
+ [record, record.fetch("environments", {})[record["current"]]]
84
+ end
85
+
86
+ def worktree_port_for(id, data, manager, base: preferred_port)
87
+ reserved = data.fetch("worktrees", {}).values.map { |record| record["port"] }
88
+ (base..CLI::PORT_RANGE.end).chain(CLI::PORT_RANGE.begin...base).find do |port|
89
+ !reserved.include?(port) && manager.port_available?(port)
90
+ end || abort("No free port for worktree #{id}.")
91
+ end
92
+
93
+ def worktree_activate(path, force: false)
94
+ path = File.realpath(path)
95
+ id = worktree_identity(path)
96
+ with_worktree_lock(id) do
97
+ ref, head = worktree_ref(path)
98
+ config = configuration(root: path)
99
+ manager = Managers.build(config)
100
+ abort "#{config.fetch('manager')} is unavailable." unless manager.available?
101
+
102
+ existing = nil
103
+ with_state_lock do |data|
104
+ record = data.fetch("worktrees", {})[id]
105
+ existing = record&.fetch("environments", {})&.[](ref)
106
+ end
107
+ return if existing && !force && existing["snapshot"] == path && existing["head"] == head && existing["status"] == "running"
108
+
109
+ previous = nil
110
+ port = nil
111
+ with_state_lock do |data|
112
+ record = (data["worktrees"] ||= {})[id] ||= { "path" => path, "environments" => {} }
113
+ previous = record.fetch("environments")[record["current"]] if record["current"] && record["current"] != ref
114
+ port = record["port"] ||= worktree_port_for(id, data, manager, base: config.fetch("preferred_port"))
115
+ record["path"] = path
116
+ end
117
+ if previous
118
+ abort "Could not stop previous environment for #{path}." unless worktree_manager(previous).stop(previous)
119
+ with_state_lock do |data|
120
+ data.fetch("worktrees").fetch(id).fetch("environments").fetch(previous.fetch("ref"))["status"] = "stopped"
121
+ end
122
+ end
123
+ abort "Could not reset environment for #{path}." if existing && !worktree_manager(existing).reset(existing)
124
+
125
+ project = "brunch-#{id[0, 12]}-#{Digest::SHA256.hexdigest(ref)[0, 12]}"
126
+ control = File.join(state_root, "controls", project)
127
+ archive_ref(head, control)
128
+ compose_file = config.fetch("compose_file")
129
+ if %w[docker_compose podman_compose].include?(config.fetch("manager"))
130
+ source_file = File.join(path, compose_file)
131
+ abort "Missing #{compose_file} in #{path}." unless File.file?(source_file)
132
+ destination = File.join(control, compose_file)
133
+ FileUtils.mkdir_p(File.dirname(destination))
134
+ FileUtils.cp(source_file, destination)
135
+ end
136
+ entry = { "source_type" => "worktree", "snapshot" => path, "control" => control,
137
+ "ref" => ref, "head" => head, "port" => port, "project" => project,
138
+ "compose_file" => compose_file, "configuration" => config, "status" => "starting" }
139
+ with_state_lock do |data|
140
+ record = data.fetch("worktrees").fetch(id)
141
+ record.fetch("environments")[ref] = entry
142
+ record["current"] = ref
143
+ end
144
+ abort "Could not create environment for #{path}." unless manager.create(entry)
145
+ abort "Could not start environment for #{path}." unless manager.start(entry)
146
+ with_state_lock do |data|
147
+ data.fetch("worktrees").fetch(id).fetch("environments")[ref] = entry.merge("status" => "running")
148
+ end
149
+ puts "Started #{ref} in #{path} at http://127.0.0.1:#{port}"
150
+ end
151
+ end
152
+
153
+ def worktree_stop(path)
154
+ id = worktree_identity(path)
155
+ with_worktree_lock(id) do
156
+ with_state_lock do |data|
157
+ _record, entry = current_worktree_entry(data, path)
158
+ raise CLI::OperationError, "No Brunch environment in this worktree." unless entry
159
+
160
+ abort "Could not stop #{entry.fetch('project')}." unless worktree_manager(entry).stop(entry)
161
+ entry["status"] = "stopped"
162
+ puts "Stopped #{entry.fetch('project')}."
163
+ end
164
+ end
165
+ end
166
+
167
+ def worktree_remove(path)
168
+ path = canonical_worktree_path(path)
169
+ data = state
170
+ id, = data.fetch("worktrees", {}).find { |_key, record| record["path"] == path }
171
+ return unless id
172
+
173
+ with_worktree_lock(id) do
174
+ record = state.fetch("worktrees").fetch(id)
175
+ record.fetch("environments").each do |ref, entry|
176
+ abort "Could not remove environment #{ref}." unless worktree_manager(entry).remove(entry)
177
+ end
178
+ with_state_lock { |locked| locked.fetch("worktrees").delete(id) }
179
+ puts "Removed Brunch environments for #{path}."
180
+ end
181
+ end
182
+
183
+ def worktree_move(old_path, new_path)
184
+ old_path = canonical_worktree_path(old_path)
185
+ new_path = File.realpath(new_path)
186
+ data = state
187
+ id, = data.fetch("worktrees", {}).find { |_key, record| record["path"] == old_path }
188
+ return worktree_activate(new_path) unless id
189
+
190
+ with_worktree_lock(id) do
191
+ record = state.fetch("worktrees").fetch(id)
192
+ record.fetch("environments").each_value do |entry|
193
+ next unless entry["status"] == "running"
194
+
195
+ abort "Could not stop environment during worktree move." unless worktree_manager(entry).stop(entry)
196
+ end
197
+ with_state_lock do |locked|
198
+ updated = locked.fetch("worktrees").fetch(id)
199
+ updated["path"] = new_path
200
+ updated.fetch("environments").each_value do |entry|
201
+ entry["snapshot"] = new_path
202
+ entry["status"] = "stopped"
203
+ end
204
+ end
205
+ end
206
+ worktree_activate(new_path, force: true)
207
+ end
208
+
209
+ def worktree_repair(path)
210
+ path = File.realpath(path)
211
+ record = state.fetch("worktrees", {})[worktree_identity(path)]
212
+ if record && record.fetch("path") != path
213
+ worktree_move(record.fetch("path"), path)
214
+ else
215
+ worktree_activate(path)
216
+ end
217
+ end
218
+
219
+ def worktree_paths
220
+ output, status = Open3.capture2("git", "worktree", "list", "--porcelain", "-z")
221
+ abort "Could not list Git worktrees." unless status.success?
222
+ output.split("\0").filter_map do |field|
223
+ canonical_worktree_path(field.delete_prefix("worktree ")) if field.start_with?("worktree ")
224
+ end
225
+ end
226
+
227
+ def worktree_cleanup
228
+ paths = worktree_paths
229
+ identities = paths.each_with_object({}) do |path, result|
230
+ result[worktree_identity(path)] = path if File.directory?(path)
231
+ end
232
+ data = state
233
+ data.fetch("worktrees", {}).each do |id, record|
234
+ path = record.fetch("path")
235
+ if identities[id] && identities[id] != path
236
+ worktree_move(path, identities[id])
237
+ elsif !paths.include?(path)
238
+ worktree_remove(path)
239
+ end
240
+ end
241
+ end
242
+
243
+ def worktree_status
244
+ data = state
245
+ record, current = current_worktree_entry(data)
246
+ return puts "No Brunch environment in this worktree." unless current
247
+
248
+ puts "#{current.fetch('ref')} (#{record.fetch('path')}): #{worktree_manager(current).status(current)}, http://127.0.0.1:#{record.fetch('port')}"
249
+ end
250
+
251
+ def worktree_ports
252
+ data = state
253
+ current_id = worktree_identity(repo_root)
254
+ data.fetch("worktrees", {}).sort_by { |_id, record| record.fetch("path") }.each do |id, record|
255
+ entry = record.fetch("environments", {})[record["current"]]
256
+ next unless entry
257
+
258
+ marker = id == current_id ? color("●", 32) : "○"
259
+ name = id == current_id ? color(entry.fetch("ref"), 32) : entry.fetch("ref")
260
+ puts "#{marker} #{name} #{record.fetch('port')} #{record.fetch('path')}"
261
+ end
262
+ end
263
+
264
+ def worktree_port
265
+ record, = current_worktree_entry(state)
266
+ raise CLI::OperationError, "No Brunch environment in this worktree." unless record
267
+
268
+ puts record.fetch("port")
269
+ end
270
+
271
+ def worktree_logs(arguments)
272
+ _record, entry = current_worktree_entry(state)
273
+ raise CLI::OperationError, "No Brunch environment in this worktree." unless entry
274
+
275
+ worktree_manager(entry).logs(entry, *arguments)
276
+ end
277
+
278
+ def worktree_run(command)
279
+ _record, entry = current_worktree_entry(state)
280
+ raise CLI::OperationError, "No Brunch environment in this worktree." unless entry
281
+
282
+ abort "Command failed: #{command.join(' ')}" unless system(worktree_manager(entry).environment(entry), *command,
283
+ chdir: entry.fetch("snapshot"))
284
+ end
285
+
286
+ def worktree_doctor
287
+ data = state
288
+ ports = data.fetch("worktrees", {}).values.map { |record| record["port"] }
289
+ paths = worktree_paths
290
+ hooks_dir = git_output("rev-parse", "--git-path", "hooks")
291
+ checks = {
292
+ "Git worktree" => !worktree_identity(repo_root).empty?,
293
+ "git-hooks-ext" => system("ghe", "--version", out: File::NULL, err: File::NULL),
294
+ "Brunch hooks" => Hooks::EVENTS.all? do |event|
295
+ path = File.join(hooks_dir, event)
296
+ File.file?(path) && File.read(path).include?("Brunch::Hooks.dispatch")
297
+ end,
298
+ "Configuration" => !configuration.nil?,
299
+ "Manager" => Managers.build(configuration).available?,
300
+ "Unique ports" => ports.uniq.size == ports.size,
301
+ "Port range" => ports.all? { |port| CLI::PORT_RANGE.cover?(port) },
302
+ "Git worktree paths" => data.fetch("worktrees", {}).values.all? do |record|
303
+ paths.include?(record.fetch("path"))
304
+ end
305
+ }
306
+ checks.each { |name, passed| puts "#{passed ? 'OK' : 'FAIL'} #{name}" }
307
+ abort "Brunch doctor found problems." unless checks.values.all?
308
+ end
309
+ end
310
+ end
data/lib/brunch.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "brunch/version"
4
+ require_relative "brunch/managers"
5
+ require_relative "brunch/worktree_mode"
6
+ require_relative "brunch/cli"
7
+ require_relative "brunch/hooks"