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.
- checksums.yaml +5 -5
- data/CHANGELOG.md +75 -0
- data/LICENSE.txt +21 -0
- data/README.md +355 -0
- data/brunch.webp +0 -0
- data/exe/brunch +6 -0
- data/lib/brunch/cli.rb +145 -0
- data/lib/brunch/hooks.rb +89 -0
- data/lib/brunch/managers.rb +267 -0
- data/lib/brunch/version.rb +5 -0
- data/lib/brunch/worktree_mode.rb +310 -0
- data/lib/brunch.rb +7 -0
- metadata +96 -16
data/lib/brunch/hooks.rb
ADDED
|
@@ -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,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
|