brunch 0.0.1 → 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
- SHA1:
3
- metadata.gz: 0446f4c84c8e70a7178c9de33468d90d7747c12b
4
- data.tar.gz: 4f08030c58640bbbeea3a2078083bca77744461e
2
+ SHA256:
3
+ metadata.gz: f77dd24e00604eea2485dff005164094eb6f6e8633d81e96616df5bf25420ef2
4
+ data.tar.gz: 578505da516fbd81dfdd6c54e397ac0d4861e25e7af5b482a6e4f8fcf2aa4043
5
5
  SHA512:
6
- metadata.gz: 0dc5753a95e229da0bf221648a291b8c745194e4798674fa3ab377f8cce5d7969843780791db8dd12d0767b4f6001ed5ac0f3a58c6b9c12abdeb7e9ea5217ff2
7
- data.tar.gz: 2e2a24eb7ce8c920cef40c55bc744c0399c81653872eca813aa25aa395a6ca86ef42abf0f022123a7bd9e4a876a6528409121ff39954ebe742af04210d185202
6
+ metadata.gz: c80c84a7078a91a71c69517d68d9b5093d13c6e97b8662e5ec98aecf2b2d2b07daaa49713da6046c51ca16c41c63cc58b50c2e6c286f1c5ede0ed0c3f2ce5ab2
7
+ data.tar.gz: 759c3e32ad7957cdec4b3babe27ad211262ce4f177b0ce6a2f4a0f9d3db26ed81ed332c930e2690e7cc3f9195f7528a5fa80b5ddf8cd4aa7063c32f9e0c18648
data/CHANGELOG.md ADDED
@@ -0,0 +1,70 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 0.8.0 - 2026-09-29
6
+
7
+ - Use one worktree-based lifecycle for both branch switches and parallel
8
+ worktrees. Branches within a worktree reuse its port; separate worktrees
9
+ receive distinct host ports.
10
+ - Run applications from the live worktree and replace mode-specific port
11
+ settings with an optional `preferred_port`.
12
+ - Document installation and everyday use, and add a compact illustration to
13
+ the README.
14
+ - Make CI lint only project files, install the `ghe` alias, and initialize the
15
+ test repository on `main`.
16
+ - Detect running Podman Compose containers by their project label.
17
+
18
+ ## 0.7.1 - 2026-09-29
19
+
20
+ - Use a compatible Podman Compose provider and CLI arguments in integration
21
+ tests and at runtime.
22
+
23
+ ## 0.7.0 - 2026-09-29
24
+
25
+ - Add parallel worktree mode with per-worktree ports, live source directories,
26
+ isolated projects and volumes, and branch-specific environments.
27
+ - Handle git-hooks-ext worktree create, remove, move, prune, and repair events;
28
+ reconcile plain Git worktree operations during cleanup.
29
+ - Preserve control files for teardown after a worktree disappears, and use
30
+ short repository locks plus per-worktree lifecycle locks.
31
+
32
+ ## 0.6.0 - 2026-09-29
33
+
34
+ - Add shared and unique port modes; shared port `3000` is now the default.
35
+
36
+ ## 0.5.0 - 2026-09-28
37
+
38
+ - Add `ports` and script-friendly `port` commands.
39
+
40
+ ## 0.4.1 - 2026-09-28
41
+
42
+ - Strengthen `doctor` hook and port validation.
43
+
44
+ ## 0.4.0 - 2026-09-28
45
+
46
+ - Add `doctor`, `exec`, and argument forwarding for `logs`.
47
+ - Serialize commands with a repository-local lock, save state atomically, and
48
+ recover interrupted environment setup on the next activation.
49
+ - Add conditional Docker Compose and Podman Compose integration tests to CI.
50
+
51
+ ## 0.3.0 - 2026-09-28
52
+
53
+ - Add a native `local_process` manager for `bin/dev`-style commands, with PID
54
+ tracking, process-group shutdown, and snapshot-local logs.
55
+
56
+ ## 0.2.0 - 2026-09-28
57
+
58
+ - Add pluggable environment managers. Docker Compose remains the default and a
59
+ command-based manager supports other project-specific runtimes.
60
+ - Add an optional `create` lifecycle command for provisioning custom-manager
61
+ environments before they start.
62
+ - Add the Podman Compose manager, configurable `switch_only` and `active_only`
63
+ lifecycle modes, and `status`, `stop`, `restart`, and `logs` commands.
64
+ - Validate `brunch.yml` and expose manager status and health checks.
65
+
66
+ ## 0.1.0 - 2026-09-28
67
+
68
+ - Initial public release.
69
+ - Isolated Docker Compose environments activated by Git branch checkout.
70
+ - `git-hooks-ext` integration and stale-environment cleanup.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maciej Ciemborowicz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,243 @@
1
+ # Brunch
2
+
3
+ ![Brunch — container per branch and worktree](brunch.webp)
4
+
5
+ Brunch runs isolated development environments for Git worktrees. The main
6
+ checkout is a worktree too: switching branches there reuses its port, while
7
+ additional worktrees run in parallel on different host ports. Docker Compose
8
+ is the default manager; Podman
9
+ Compose, local processes, and project commands are also supported.
10
+
11
+ Each branch environment has a distinct Compose project name, network, and
12
+ named volumes. The active environment runs from its live worktree directory,
13
+ including uncommitted changes when the manager rebuilds or reloads the app.
14
+
15
+ ## Requirements
16
+
17
+ - Ruby 3.1 or newer
18
+ - Git 2.28 or newer
19
+ - A supported environment manager: Docker Compose, Podman Compose, or project commands
20
+ - [`git-hooks-ext`](https://github.com/ciembor/git-hooks-ext)
21
+
22
+ ## Installation
23
+
24
+ Install [`git-hooks-ext`](https://github.com/ciembor/git-hooks-ext) so that
25
+ `ghe` is on your `PATH`, then install Brunch:
26
+
27
+ ```sh
28
+ gem install brunch
29
+ ```
30
+
31
+ Run `brunch install` inside each Git repository you want Brunch to manage.
32
+ It installs the `git-hooks-ext` bridge and project-local hooks, and refuses
33
+ to replace an existing hook owned by another tool.
34
+
35
+ ## Project configuration
36
+
37
+ Add `brunch.yml` to the project root. Docker Compose is the default, so the
38
+ following remains sufficient for Compose projects:
39
+
40
+ ```yaml
41
+ compose_file: compose.yaml
42
+ ```
43
+
44
+ The referenced Compose file belongs to the application. Brunch does not impose
45
+ a database or service stack: applications may define PostgreSQL, MySQL, Redis,
46
+ Sidekiq, Elasticsearch, or any other services they need.
47
+ Unknown `brunch.yml` fields are errors, so configuration typos are not ignored.
48
+
49
+ Expose the application port with `BRUNCH_PORT`:
50
+
51
+ ```yaml
52
+ services:
53
+ web:
54
+ ports:
55
+ - "127.0.0.1:${BRUNCH_PORT}:3000"
56
+ ```
57
+
58
+ The first activated worktree prefers `127.0.0.1:3000`. Every worktree keeps
59
+ its assigned host port while its branches take turns using it. Other worktrees
60
+ receive a different free port; running containers can never bind the same host
61
+ address and port. To prefer another starting port, set `preferred_port: 4000`.
62
+ Brunch persists assignments privately in `.git/brunch/state.json`.
63
+
64
+ ## Using Brunch
65
+
66
+ First, commit `brunch.yml` and your Compose file (or the configuration for
67
+ another manager). The repository needs at least one commit before Brunch can
68
+ start an environment. Install the hooks once per repository, then activate
69
+ the checked-out branch:
70
+
71
+ ```sh
72
+ brunch install
73
+ brunch doctor
74
+ brunch activate
75
+ brunch status
76
+ brunch port
77
+ ```
78
+
79
+ Open `http://127.0.0.1:3000` if port 3000 is free, or use the port printed by
80
+ `brunch activate` / `brunch port`. Brunch starts the application in the
81
+ background. With Compose, the app must listen on the container port mapped in
82
+ `compose.yaml`.
83
+
84
+ ### Branches in one checkout
85
+
86
+ Once the hooks are installed, ordinary Git branch switches stop the old
87
+ environment and start the new one automatically:
88
+
89
+ ```sh
90
+ git switch -c feature/login
91
+ brunch status
92
+ git switch main
93
+ ```
94
+
95
+ Both branches use this checkout's assigned host port. With Compose, they keep
96
+ separate projects and volumes. To start the checked-out branch manually (for
97
+ example, after `brunch stop`), run `brunch activate`. Run `brunch restart` to
98
+ rebuild it after changing files that are copied into the image.
99
+
100
+ ### Parallel worktrees
101
+
102
+ There is no mode switch. Commit the same `brunch.yml` in your repository, then
103
+ create additional worktrees. Run `brunch install` again after upgrading Brunch
104
+ to install its worktree hooks.
105
+
106
+ ```sh
107
+ ghe worktree add -b feature-a ../feature-a
108
+ ghe worktree add -b feature-b ../feature-b
109
+ cd ../feature-a
110
+ brunch port # port for this worktree
111
+ brunch ports # ports for all worktrees
112
+ ```
113
+
114
+ Each worktree runs independently on a different host port, so `feature-a` and
115
+ `feature-b` can be used at the same time. Run `brunch status` or `brunch port`
116
+ inside each worktree to find its address. Switching branches inside one
117
+ worktree does not stop the others.
118
+
119
+ `ghe worktree add`, `move`, and `remove` emit lifecycle events. Git's
120
+ `post-checkout` hook handles branch changes inside any worktree, including the
121
+ main checkout. If you use plain `git worktree add`, run `brunch activate` in the
122
+ new worktree; after a plain move or removal, run `brunch cleanup` from a
123
+ remaining worktree.
124
+
125
+ The source directory is never deleted by Brunch. It keeps a separate control
126
+ copy under `.git/brunch/controls` so it can shut down Compose or run a custom
127
+ `remove` command after a worktree has been removed. Switching branches within
128
+ one worktree keeps the old branch's environment stopped, with its own named
129
+ volumes. Removing the worktree removes all of its Brunch environments. The
130
+ `local_process` manager also writes its log to the control directory, keeping
131
+ the worktree clean. Custom `remove` commands that depend on project files must
132
+ be committed, because the control copy is based on the worktree's last commit.
133
+
134
+ Docker builds read uncommitted files from the live worktree. To see edits in
135
+ an already running container without rebuilding, configure a source bind
136
+ mount or another reload mechanism in the project's Compose file.
137
+ `brunch restart` rebuilds the current environment.
138
+
139
+ Before upgrading from an older branch-only configuration, stop its running
140
+ environment and clean up its legacy state. Brunch refuses to reinterpret an
141
+ old non-empty branch state as worktree state, preventing orphaned containers.
142
+
143
+ ### Custom manager commands
144
+
145
+ Use the `command` manager when the project is started by another tool. Brunch
146
+ runs commands from the live worktree with these variables: `BRUNCH_REF`,
147
+ `BRUNCH_PORT`, `BRUNCH_PROJECT`, and `BRUNCH_SNAPSHOT`. The last variable points
148
+ to the live worktree for compatibility with manager adapters.
149
+
150
+ ```yaml
151
+ manager: command
152
+ commands:
153
+ create: bin/environment create
154
+ start: bin/environment start
155
+ stop: bin/environment stop
156
+ remove: bin/environment remove
157
+ ```
158
+
159
+ `create` provisions manager-owned resources; it is optional for the `command`
160
+ manager. `start` must return after
161
+ it has launched the environment (for example, by delegating to a daemon or
162
+ supervisor). `stop` is used when switching branches; `remove` is used when
163
+ a worktree is deleted and should remove any manager-owned persistent
164
+ resources. This makes the adapter suitable for Podman, Kubernetes wrappers,
165
+ Foreman/Overmind wrappers, or a project-specific script.
166
+
167
+ Optional `status`, `health`, and `logs` commands power the corresponding Brunch
168
+ commands. A successful `health` command reports a healthy environment.
169
+
170
+ ### Podman Compose
171
+
172
+ Podman Compose uses the same Compose file contract as Docker Compose:
173
+
174
+ ```yaml
175
+ manager: podman_compose
176
+ compose_file: compose.yaml
177
+ ```
178
+
179
+ ### Local process
180
+
181
+ Use `local_process` for a development command such as `bin/dev`.
182
+ Brunch starts it in a dedicated process group, records its PID, stops that
183
+ group on a branch switch, and saves its output under `.git/brunch/controls`.
184
+
185
+ ```yaml
186
+ manager: local_process
187
+ command: bin/dev
188
+ ```
189
+
190
+ ## Lifecycle
191
+
192
+ - A branch checkout stops the previous environment in that worktree, then
193
+ creates and starts the new one on the same port.
194
+ - Each worktree keeps its own current environment and port. Other worktrees
195
+ continue running during branch switches.
196
+ - Checking out a remote branch into a new local tracking branch works through
197
+ the same `post-checkout` hook.
198
+ - `brunch cleanup` removes environments belonging to deleted worktrees.
199
+
200
+ Git 2.39 cannot reliably report a normal `git branch -d` operation to a hook.
201
+ Stopped branch environments remain until their worktree is removed.
202
+
203
+ ## Operations
204
+
205
+ ```sh
206
+ brunch status # current worktree environment, manager status and port
207
+ brunch ports # worktree-to-port list; current worktree is marked in green
208
+ brunch port # only the current worktree's port, suitable for scripts
209
+ brunch stop # stop the active environment without deleting it
210
+ brunch restart # recreate and start the active environment
211
+ brunch logs # show the last 100 logs (or run commands.logs)
212
+ brunch logs --follow
213
+ brunch run -- bin/rails console # runs on the host in the live worktree, not in a container
214
+ brunch doctor # verify Git, installed hooks, configuration, manager and ports
215
+ brunch cleanup # delete environments for removed worktrees
216
+ ```
217
+
218
+ Brunch coordinates commands with locks under `.git/brunch`, writes `state.json`
219
+ atomically, and retries interrupted environment setup on the next activation.
220
+
221
+ ## Development
222
+
223
+ ```sh
224
+ bundle install
225
+ bin/install-pre-commit
226
+ bundle exec rake quality
227
+ gem build brunch.gemspec
228
+ ```
229
+
230
+ Container E2E tests use real temporary Git repositories, Compose containers,
231
+ and HTTP requests. Run them with Docker or Podman:
232
+
233
+ ```sh
234
+ BRUNCH_INTEGRATION_MANAGER=docker_compose bundle exec rake test
235
+ BRUNCH_INTEGRATION_MANAGER=podman_compose bundle exec rake test
236
+ ```
237
+
238
+ CI runs both managers on pushes and pull requests. Without the environment
239
+ variable, container tests are skipped by the local quality check.
240
+
241
+ The pre-commit hook runs RuboCop with automatic corrections, Reek, and the full
242
+ test suite. SimpleCov requires 100% line and branch coverage of `lib/**/*.rb`. When
243
+ RuboCop changes a file, review and stage the correction before committing.
data/brunch.webp ADDED
Binary file
data/exe/brunch ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/brunch"
5
+
6
+ exit Brunch::CLI.start(ARGV)
data/lib/brunch/cli.rb ADDED
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "open3"
6
+ require "yaml"
7
+
8
+ module Brunch
9
+ class CLI
10
+ include WorktreeMode
11
+
12
+ PORT_RANGE = (1024..49_151)
13
+ CONFIGURATION_KEYS = %w[manager compose_file command commands preferred_port].freeze
14
+ COMMAND_KEYS = %w[create start stop remove status health logs].freeze
15
+
16
+ class ConfigurationError < StandardError; end
17
+ class OperationError < StandardError; end
18
+
19
+ def self.start(arguments) = new.start(arguments)
20
+
21
+ def start(arguments)
22
+ Dir.chdir(repo_root) unless Dir.pwd == repo_root
23
+ case arguments
24
+ in ["install"] then Hooks.install
25
+ in ["version"] | ["--version"] | ["-v"] then puts Brunch::VERSION
26
+ else return worktree_command(arguments)
27
+ end
28
+ 0
29
+ rescue ConfigurationError => e
30
+ warn e.message
31
+ 64
32
+ rescue OperationError => e
33
+ warn e.message
34
+ 1
35
+ end
36
+
37
+ private
38
+
39
+ def git_output(*arguments)
40
+ output, status = Open3.capture2("git", *arguments)
41
+ abort "Git command failed: git #{arguments.join(' ')}" unless status.success?
42
+
43
+ output.strip
44
+ end
45
+
46
+ def repo_root
47
+ @repo_root ||= git_output("rev-parse", "--show-toplevel")
48
+ end
49
+
50
+ def state_root
51
+ common_dir = git_output("rev-parse", "--git-common-dir")
52
+ @state_root ||= File.join(File.expand_path(common_dir, repo_root), "brunch")
53
+ end
54
+
55
+ def state_path = File.join(state_root, "state.json")
56
+
57
+ def state
58
+ return { "worktrees" => {} } unless File.file?(state_path)
59
+
60
+ data = JSON.parse(File.read(state_path))
61
+ raise ConfigurationError, "Invalid Brunch state: expected a JSON object." unless data.is_a?(Hash)
62
+ if data.fetch("environments", {}).any? || data.key?("active_ref")
63
+ raise ConfigurationError, "Legacy branch state found in .git/brunch/state.json; stop old environments before upgrading."
64
+ end
65
+
66
+ data
67
+ rescue JSON::ParserError => e
68
+ raise ConfigurationError, "Invalid Brunch state: #{e.message}"
69
+ end
70
+
71
+ def save_state(value)
72
+ FileUtils.mkdir_p(state_root)
73
+ temporary_path = "#{state_path}.#{Process.pid}.tmp"
74
+ File.open(temporary_path, "w", 0o600) do |file|
75
+ file.write(JSON.pretty_generate(value))
76
+ file.flush
77
+ file.fsync
78
+ end
79
+ File.rename(temporary_path, state_path)
80
+ ensure
81
+ FileUtils.rm_f(temporary_path) if temporary_path
82
+ end
83
+
84
+ def configuration(root: repo_root)
85
+ path = File.join(root, "brunch.yml")
86
+ configured = File.file?(path) ? YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {} : {}
87
+ validate_configuration!(configured)
88
+ { "manager" => "docker_compose", "compose_file" => "compose.yaml", "preferred_port" => 3000 }.merge(configured)
89
+ rescue Psych::Exception => e
90
+ raise ConfigurationError, "Invalid brunch.yml: #{e.message}"
91
+ end
92
+
93
+ def validate_configuration!(configured)
94
+ raise ConfigurationError, "brunch.yml must contain a mapping." unless configured.is_a?(Hash)
95
+
96
+ unknown = configured.keys - CONFIGURATION_KEYS
97
+ raise ConfigurationError, "Unknown brunch.yml key(s): #{unknown.join(', ')}." unless unknown.empty?
98
+
99
+ value = { "manager" => "docker_compose", "compose_file" => "compose.yaml", "preferred_port" => 3000 }.merge(configured)
100
+ unless %w[docker_compose podman_compose local_process command].include?(value["manager"])
101
+ raise ConfigurationError, "Unknown Brunch manager: #{value['manager']}."
102
+ end
103
+ unless value["preferred_port"].is_a?(Integer) && PORT_RANGE.cover?(value["preferred_port"])
104
+ raise ConfigurationError, "preferred_port must be a number from #{PORT_RANGE.begin} to #{PORT_RANGE.end}."
105
+ end
106
+ if %w[docker_compose podman_compose].include?(value["manager"]) && (!value["compose_file"].is_a?(String) || value["compose_file"].empty?)
107
+ raise ConfigurationError, "compose_file must be a non-empty string."
108
+ end
109
+ if value["manager"] == "local_process" && (!value["command"].is_a?(String) || value["command"].empty?)
110
+ raise ConfigurationError, "local_process manager requires a non-empty command."
111
+ end
112
+ return unless value["manager"] == "command"
113
+
114
+ commands = value["commands"]
115
+ raise ConfigurationError, "command manager requires a commands mapping." unless commands.is_a?(Hash)
116
+
117
+ unknown = commands.keys - COMMAND_KEYS
118
+ raise ConfigurationError, "Unknown commands key(s): #{unknown.join(', ')}." unless unknown.empty?
119
+
120
+ %w[start stop remove].each do |action|
121
+ raise ConfigurationError, "command manager requires commands.#{action}." unless commands[action].is_a?(String) && !commands[action].empty?
122
+ end
123
+ %w[create status health logs].each do |action|
124
+ raise ConfigurationError, "commands.#{action} must be a string." if commands.key?(action) && !commands[action].is_a?(String)
125
+ end
126
+ end
127
+
128
+ def preferred_port = configuration.fetch("preferred_port")
129
+
130
+ def archive_ref(ref, destination)
131
+ FileUtils.rm_rf(destination)
132
+ FileUtils.mkdir_p(destination)
133
+ environment = { "LC_ALL" => "C", "LANG" => "C" }
134
+ statuses = Open3.pipeline([environment, "git", "archive", "--format=tar", ref],
135
+ [environment, "tar", "-x", "-C", destination])
136
+ abort "Could not create a control copy for #{ref}." unless statuses.all?(&:success?)
137
+ end
138
+
139
+ def color(text, code)
140
+ return text unless $stdout.tty?
141
+
142
+ "\e[#{code}m#{text}\e[0m"
143
+ end
144
+ end
145
+ end
@@ -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.0"
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"
metadata CHANGED
@@ -1,27 +1,109 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brunch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maciej Ciemborowicz
8
- autorequire:
9
- bindir: bin
8
+ bindir: exe
10
9
  cert_chain: []
11
- date: 2018-07-25 00:00:00.000000000 Z
12
- dependencies: []
13
- description:
14
- email: no@email.com
15
- executables: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: reek
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '6.5'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '6.5'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rubocop
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.75'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.75'
68
+ - !ruby/object:Gem::Dependency
69
+ name: simplecov
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.22'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.22'
82
+ description: Brunch uses Git hooks and git-hooks-ext to run isolated branch environments
83
+ in parallel worktrees through Docker Compose or custom managers.
84
+ executables:
85
+ - brunch
16
86
  extensions: []
17
87
  extra_rdoc_files: []
18
- files: []
88
+ files:
89
+ - CHANGELOG.md
90
+ - LICENSE.txt
91
+ - README.md
92
+ - brunch.webp
93
+ - exe/brunch
94
+ - lib/brunch.rb
95
+ - lib/brunch/cli.rb
96
+ - lib/brunch/hooks.rb
97
+ - lib/brunch/managers.rb
98
+ - lib/brunch/version.rb
99
+ - lib/brunch/worktree_mode.rb
19
100
  homepage: https://github.com/ciembor/brunch
20
101
  licenses:
21
102
  - MIT
22
103
  metadata:
23
- source_code_uri: https://github.com/ciembor/brunch.git
24
- post_install_message:
104
+ homepage_uri: https://github.com/ciembor/brunch
105
+ changelog_uri: https://github.com/ciembor/brunch/blob/main/CHANGELOG.md
106
+ rubygems_mfa_required: 'true'
25
107
  rdoc_options: []
26
108
  require_paths:
27
109
  - lib
@@ -29,16 +111,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
29
111
  requirements:
30
112
  - - ">="
31
113
  - !ruby/object:Gem::Version
32
- version: '0'
114
+ version: 3.1.0
33
115
  required_rubygems_version: !ruby/object:Gem::Requirement
34
116
  requirements:
35
117
  - - ">="
36
118
  - !ruby/object:Gem::Version
37
119
  version: '0'
38
120
  requirements: []
39
- rubyforge_project:
40
- rubygems_version: 2.5.2
41
- signing_key:
121
+ rubygems_version: 3.6.9
42
122
  specification_version: 4
43
- summary: Separate database for each git branch.
123
+ summary: Isolated development environments for Git branches and worktrees
44
124
  test_files: []