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 +5 -5
- data/CHANGELOG.md +70 -0
- data/LICENSE.txt +21 -0
- data/README.md +243 -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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f77dd24e00604eea2485dff005164094eb6f6e8633d81e96616df5bf25420ef2
|
|
4
|
+
data.tar.gz: 578505da516fbd81dfdd6c54e397ac0d4861e25e7af5b482a6e4f8fcf2aa4043
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+

|
|
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
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
|
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
|
data/lib/brunch.rb
ADDED
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
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Maciej Ciemborowicz
|
|
8
|
-
|
|
9
|
-
bindir: bin
|
|
8
|
+
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
12
|
-
dependencies:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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:
|
|
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
|
-
|
|
40
|
-
rubygems_version: 2.5.2
|
|
41
|
-
signing_key:
|
|
121
|
+
rubygems_version: 3.6.9
|
|
42
122
|
specification_version: 4
|
|
43
|
-
summary:
|
|
123
|
+
summary: Isolated development environments for Git branches and worktrees
|
|
44
124
|
test_files: []
|