brunch 0.8.3 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +68 -10
- data/brunch.webp +0 -0
- data/lib/brunch/cli.rb +6 -1
- data/lib/brunch/hooks.rb +3 -1
- data/lib/brunch/version.rb +1 -1
- data/lib/brunch/worktree_mode.rb +124 -4
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 668bcb5a607af2adcad5006541dc927be6e1c5277d4c79f57b46de725e96076b
|
|
4
|
+
data.tar.gz: c9a02342f703ce5546860760165f4e6b3226cbc6d686f1b5b12a3891e27ad85d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d47e2a2fa7a68de063d05696eadaac3e4e41939d4e9faac87c875752c0a4e83542a13dfe9a0171b17590f7208825088de95875c87e156c10b91be2fed7a43393
|
|
7
|
+
data.tar.gz: 94449f91dbaa4bbcb70ab6aca393068f4e25e0e92797487195de86ffd05f5d2c50ab7f3f50b6fe2837938f1da6d5e6cbca03f34ffe3309e4789e411854c0fa5f
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.9.1 - 2026-10-03
|
|
6
|
+
|
|
7
|
+
- Replace `brunch ports` with `brunch list` and add `brunch watch` for a
|
|
8
|
+
continuously refreshed interactive view of branch environments.
|
|
9
|
+
|
|
10
|
+
## 0.9.0 - 2026-10-02
|
|
11
|
+
|
|
12
|
+
- Remove environments for deleted local branches after the next Brunch command,
|
|
13
|
+
while preserving them across branch renames, including reftable renames that
|
|
14
|
+
do not emit a deletion event. Run `brunch install` again to install the new
|
|
15
|
+
`branch-deleted` hook.
|
|
16
|
+
- Keep the same Compose project and named volumes when restarting after a branch rename.
|
|
17
|
+
- Exercise the config-based git-hooks-ext bridge with Git 2.55 in CI.
|
|
18
|
+
|
|
19
|
+
## 0.8.4 - 2026-10-01
|
|
20
|
+
|
|
21
|
+
- Test against git-hooks-ext 0.6.0 in CI and clarify bridge upgrades and
|
|
22
|
+
worktree Git requirements.
|
|
23
|
+
- Document a minimal Rails setup and update the Brunch artwork.
|
|
24
|
+
|
|
5
25
|
## 0.8.3 - 2026-09-29
|
|
6
26
|
|
|
7
27
|
- Detect containers that exit immediately after a Compose resume and report the
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Brunch
|
|
2
2
|
|
|
3
|
-

|
|
4
4
|
|
|
5
5
|
Brunch runs isolated development environments for Git branches and worktrees.
|
|
6
6
|
|
|
@@ -11,9 +11,9 @@ Docker Compose is the default manager. Podman Compose, local processes, and cust
|
|
|
11
11
|
## Requirements
|
|
12
12
|
|
|
13
13
|
- Ruby 3.1+
|
|
14
|
-
- Git 2.28+
|
|
14
|
+
- Git 2.28+ (2.39.3+ for `ghe worktree` lifecycle events)
|
|
15
15
|
- Docker Compose, Podman Compose, or another supported manager
|
|
16
|
-
- [git-hooks-ext](https://github.com/ciembor/git-hooks-ext)
|
|
16
|
+
- [git-hooks-ext](https://github.com/ciembor/git-hooks-ext) 0.6.0+ for reliable branch deletion events
|
|
17
17
|
|
|
18
18
|
## Installation
|
|
19
19
|
|
|
@@ -29,7 +29,8 @@ Inside each repository you want Brunch to manage:
|
|
|
29
29
|
brunch install
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
Brunch installs
|
|
32
|
+
Brunch installs the `git-hooks-ext` bridge and its own event hooks without overwriting hooks owned by another tool.
|
|
33
|
+
After upgrading Brunch or `git-hooks-ext`, run `brunch install` again in each managed repository to refresh its hooks and bridge.
|
|
33
34
|
|
|
34
35
|
## Configuration
|
|
35
36
|
|
|
@@ -41,15 +42,50 @@ For Docker Compose:
|
|
|
41
42
|
compose_file: compose.yaml
|
|
42
43
|
```
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
For a Rails app with SQLite databases in `storage/`, add `Dockerfile.dev`:
|
|
46
|
+
|
|
47
|
+
```dockerfile
|
|
48
|
+
FROM ruby:3.4-slim
|
|
49
|
+
|
|
50
|
+
WORKDIR /rails
|
|
51
|
+
|
|
52
|
+
RUN apt-get update -qq && \
|
|
53
|
+
apt-get install --no-install-recommends -y build-essential git libsqlite3-dev libvips libyaml-dev pkg-config && \
|
|
54
|
+
rm -rf /var/lib/apt/lists/*
|
|
55
|
+
|
|
56
|
+
COPY Gemfile Gemfile.lock ./
|
|
57
|
+
RUN bundle install
|
|
58
|
+
COPY . .
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Use the Ruby version required by your application. Then add `compose.yaml`:
|
|
45
62
|
|
|
46
63
|
```yaml
|
|
47
64
|
services:
|
|
48
65
|
web:
|
|
66
|
+
build:
|
|
67
|
+
context: .
|
|
68
|
+
dockerfile: Dockerfile.dev
|
|
69
|
+
command: sh -c 'bin/rails db:prepare && exec bin/rails server -b 0.0.0.0 -p 3000'
|
|
70
|
+
environment:
|
|
71
|
+
RAILS_ENV: development
|
|
72
|
+
volumes:
|
|
73
|
+
- .:/rails
|
|
74
|
+
- storage:/rails/storage
|
|
49
75
|
ports:
|
|
50
76
|
- "127.0.0.1:${BRUNCH_PORT}:3000"
|
|
77
|
+
|
|
78
|
+
volumes:
|
|
79
|
+
storage:
|
|
51
80
|
```
|
|
52
81
|
|
|
82
|
+
The bind mount makes source edits visible to Rails. The named volume keeps the
|
|
83
|
+
SQLite database separate for each branch and preserves it across container
|
|
84
|
+
rebuilds. If your app stores its database elsewhere, mount that location instead.
|
|
85
|
+
Brunch runs on the host, so it does not need to be in the Rails `Gemfile`.
|
|
86
|
+
After changing `Gemfile` or `Gemfile.lock`, run `brunch restart` to rebuild the
|
|
87
|
+
image with the new gems; the named volume remains intact.
|
|
88
|
+
|
|
53
89
|
The first activated worktree prefers port `3000`. Additional worktrees receive another free port.
|
|
54
90
|
|
|
55
91
|
To prefer a different starting port:
|
|
@@ -140,15 +176,22 @@ Then:
|
|
|
140
176
|
cd ../feature-a
|
|
141
177
|
|
|
142
178
|
brunch port
|
|
143
|
-
brunch
|
|
179
|
+
brunch list
|
|
144
180
|
```
|
|
145
181
|
|
|
146
182
|
`brunch port` prints the current worktree's port.
|
|
147
183
|
|
|
148
|
-
`brunch
|
|
184
|
+
`brunch list` groups the current and previously activated branches under each
|
|
149
185
|
worktree. Stopped branches appear in gray and retain their worktree's port for
|
|
150
186
|
the next activation.
|
|
151
187
|
|
|
188
|
+
```bash
|
|
189
|
+
brunch watch
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`brunch watch` shows the same view and refreshes it every second. Press
|
|
193
|
+
`Ctrl-C` to stop watching.
|
|
194
|
+
|
|
152
195
|
Switching branches inside one worktree does not affect environments running in other worktrees.
|
|
153
196
|
|
|
154
197
|
If you create a worktree with plain Git:
|
|
@@ -257,7 +300,16 @@ If a custom `remove` command depends on project files, those files must be commi
|
|
|
257
300
|
|
|
258
301
|
`brunch cleanup` removes environments belonging to worktrees that no longer exist.
|
|
259
302
|
|
|
260
|
-
|
|
303
|
+
With `git-hooks-ext` 0.6.0+, deleting a local branch queues its Brunch
|
|
304
|
+
environments for removal. On the next regular Brunch command, such as
|
|
305
|
+
`brunch status` or `brunch cleanup`, Brunch checks the current refs and removes
|
|
306
|
+
the branch's managed resources, including Compose containers and volumes.
|
|
307
|
+
It also checks for missing branches when Git emits no deletion event. This
|
|
308
|
+
delay protects environments when `git branch -m` reports a deletion before
|
|
309
|
+
creating the renamed branch. A renamed branch keeps its environment under the
|
|
310
|
+
new name. If Git reflogs are unavailable and Brunch cannot distinguish a
|
|
311
|
+
rename from a deletion, it keeps the environment. A later `brunch restart`
|
|
312
|
+
recreates the container in the same Compose project, preserving named volumes.
|
|
261
313
|
|
|
262
314
|
## Commands
|
|
263
315
|
|
|
@@ -286,11 +338,17 @@ brunch port
|
|
|
286
338
|
Print the current worktree's port.
|
|
287
339
|
|
|
288
340
|
```bash
|
|
289
|
-
brunch
|
|
341
|
+
brunch list
|
|
290
342
|
```
|
|
291
343
|
|
|
292
344
|
List current and stopped branch environments grouped by worktree.
|
|
293
345
|
|
|
346
|
+
```bash
|
|
347
|
+
brunch watch
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Continuously refresh the branch-environment list in an interactive terminal.
|
|
351
|
+
|
|
294
352
|
```bash
|
|
295
353
|
brunch stop
|
|
296
354
|
```
|
|
@@ -326,7 +384,7 @@ Check Git, installed hooks, configuration, manager availability, and ports.
|
|
|
326
384
|
brunch cleanup
|
|
327
385
|
```
|
|
328
386
|
|
|
329
|
-
Remove environments belonging to deleted worktrees.
|
|
387
|
+
Remove environments belonging to deleted worktrees and process pending branch deletions.
|
|
330
388
|
|
|
331
389
|
## Upgrading from older configurations
|
|
332
390
|
|
data/brunch.webp
CHANGED
|
Binary file
|
data/lib/brunch/cli.rb
CHANGED
|
@@ -23,7 +23,12 @@ module Brunch
|
|
|
23
23
|
case arguments
|
|
24
24
|
in ["install"] then Hooks.install
|
|
25
25
|
in ["version"] | ["--version"] | ["-v"] then puts Brunch::VERSION
|
|
26
|
-
else
|
|
26
|
+
else
|
|
27
|
+
unless arguments.first == "branch-deleted"
|
|
28
|
+
reconcile_deleted_branches
|
|
29
|
+
reconcile_missing_branches
|
|
30
|
+
end
|
|
31
|
+
return worktree_command(arguments)
|
|
27
32
|
end
|
|
28
33
|
0
|
|
29
34
|
rescue ConfigurationError => e
|
data/lib/brunch/hooks.rb
CHANGED
|
@@ -5,7 +5,7 @@ require "open3"
|
|
|
5
5
|
|
|
6
6
|
module Brunch
|
|
7
7
|
module Hooks
|
|
8
|
-
EVENTS = %w[branch-created remote-branch-created remote-branch-updated post-checkout worktree-created
|
|
8
|
+
EVENTS = %w[branch-created branch-deleted remote-branch-created remote-branch-updated post-checkout worktree-created
|
|
9
9
|
worktree-removed worktree-moved worktree-pruned worktree-repaired].freeze
|
|
10
10
|
|
|
11
11
|
module_function
|
|
@@ -26,6 +26,8 @@ module Brunch
|
|
|
26
26
|
CLI.start(["activate"]) if arguments.fetch(2, "0") == "1"
|
|
27
27
|
when "branch-created", "remote-branch-created", "remote-branch-updated"
|
|
28
28
|
CLI.start(["register", arguments.fetch(0)])
|
|
29
|
+
when "branch-deleted"
|
|
30
|
+
CLI.start(["branch-deleted", arguments.fetch(0), arguments.fetch(2)])
|
|
29
31
|
when "worktree-created", "worktree-removed", "worktree-moved", "worktree-pruned", "worktree-repaired"
|
|
30
32
|
CLI.start(["worktree-event", event, *arguments])
|
|
31
33
|
else
|
data/lib/brunch/version.rb
CHANGED
data/lib/brunch/worktree_mode.rb
CHANGED
|
@@ -20,15 +20,17 @@ module Brunch
|
|
|
20
20
|
abort "Start the checked-out branch from its worktree." unless ref == worktree_ref(repo_root).first
|
|
21
21
|
worktree_activate(repo_root)
|
|
22
22
|
in ["stop"] then worktree_stop(repo_root)
|
|
23
|
+
in ["branch-deleted", ref, oid] then record_deleted_branch(ref, oid)
|
|
23
24
|
in ["cleanup"] | ["register", _] then worktree_cleanup
|
|
24
25
|
in ["status"] then worktree_status
|
|
25
|
-
in ["
|
|
26
|
+
in ["list"] then worktree_ports
|
|
27
|
+
in ["watch"] then worktree_watch
|
|
26
28
|
in ["port"] then worktree_port
|
|
27
29
|
in ["logs", *arguments] then worktree_logs(arguments)
|
|
28
30
|
in ["run", "--", *command] if !command.empty? then worktree_run(command)
|
|
29
31
|
in ["doctor"] then worktree_doctor
|
|
30
32
|
else
|
|
31
|
-
warn "Usage: brunch {activate|start <current-ref>|stop|restart|status|
|
|
33
|
+
warn "Usage: brunch {activate|start <current-ref>|stop|restart|status|list|watch|port|logs [args]|run -- <cmd>|doctor|cleanup}"
|
|
32
34
|
return 64
|
|
33
35
|
end
|
|
34
36
|
0
|
|
@@ -140,8 +142,8 @@ module Brunch
|
|
|
140
142
|
end
|
|
141
143
|
abort "Could not reset environment for #{path}." if existing && !worktree_manager(existing).reset(existing)
|
|
142
144
|
|
|
143
|
-
project = "brunch-#{id[0, 12]}-#{Digest::SHA256.hexdigest(ref)[0, 12]}"
|
|
144
|
-
control = File.join(state_root, "controls", project)
|
|
145
|
+
project = existing ? existing.fetch("project") : "brunch-#{id[0, 12]}-#{Digest::SHA256.hexdigest(ref)[0, 12]}"
|
|
146
|
+
control = existing ? existing.fetch("control") : File.join(state_root, "controls", project)
|
|
145
147
|
archive_ref(head, control)
|
|
146
148
|
compose_file = config.fetch("compose_file")
|
|
147
149
|
if %w[docker_compose podman_compose].include?(config.fetch("manager"))
|
|
@@ -258,6 +260,110 @@ module Brunch
|
|
|
258
260
|
end
|
|
259
261
|
end
|
|
260
262
|
|
|
263
|
+
def record_deleted_branch(ref, oid)
|
|
264
|
+
with_state_lock do |data|
|
|
265
|
+
records = data.fetch("worktrees", {}).values
|
|
266
|
+
next unless records.any? { |record| record.fetch("environments", {}).key?(ref) }
|
|
267
|
+
|
|
268
|
+
(data["deleted_branches"] ||= {})[ref] = oid
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def reconcile_deleted_branches
|
|
273
|
+
pending = state.fetch("deleted_branches", {})
|
|
274
|
+
return if pending.empty?
|
|
275
|
+
|
|
276
|
+
refs = local_branch_refs
|
|
277
|
+
pending.each do |ref, oid|
|
|
278
|
+
unless refs.include?("refs/heads/#{ref}")
|
|
279
|
+
renamed = renamed_branch(ref, oid, refs)
|
|
280
|
+
next if renamed == :unknown
|
|
281
|
+
|
|
282
|
+
renamed ? rename_branch_environments(ref, renamed) : remove_branch_environments(ref)
|
|
283
|
+
end
|
|
284
|
+
with_state_lock { |data| data.fetch("deleted_branches").delete(ref) }
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def reconcile_missing_branches
|
|
289
|
+
data = state
|
|
290
|
+
names = data.fetch("worktrees", {}).values.flat_map { |record| record.fetch("environments", {}).keys }.uniq
|
|
291
|
+
names -= data.fetch("deleted_branches", {}).keys
|
|
292
|
+
names.reject! { |ref| ref.start_with?("detached-") }
|
|
293
|
+
return if names.empty?
|
|
294
|
+
|
|
295
|
+
refs = local_branch_refs
|
|
296
|
+
names.each do |ref|
|
|
297
|
+
next if refs.include?("refs/heads/#{ref}")
|
|
298
|
+
|
|
299
|
+
renamed = renamed_branch(ref, nil, refs)
|
|
300
|
+
next if renamed == :unknown
|
|
301
|
+
|
|
302
|
+
renamed ? rename_branch_environments(ref, renamed) : remove_branch_environments(ref)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def local_branch_refs
|
|
307
|
+
output, status = Open3.capture2("git", "for-each-ref", "--format=%(refname)", "refs/heads")
|
|
308
|
+
abort "Could not inspect local branches." unless status.success?
|
|
309
|
+
|
|
310
|
+
output.lines.map(&:strip)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def renamed_branch(old_ref, old_oid, refs)
|
|
314
|
+
missing_reflog = false
|
|
315
|
+
full_ref = refs.find do |new_ref|
|
|
316
|
+
output, status = Open3.capture2("git", "reflog", "show", "--format=%H %gs", new_ref)
|
|
317
|
+
missing_reflog ||= !status.success?
|
|
318
|
+
status.success? && output.lines.any? do |line|
|
|
319
|
+
oid, message = line.split(" ", 2)
|
|
320
|
+
(old_oid.nil? || oid == old_oid) && message.to_s.strip == "Branch: renamed refs/heads/#{old_ref} to #{new_ref}"
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
return full_ref.delete_prefix("refs/heads/") if full_ref
|
|
324
|
+
|
|
325
|
+
:unknown if missing_reflog
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def rename_branch_environments(old_ref, new_ref)
|
|
329
|
+
state.fetch("worktrees", {}).each_key do |id|
|
|
330
|
+
with_worktree_lock(id) do
|
|
331
|
+
with_state_lock do |data|
|
|
332
|
+
record = data.fetch("worktrees", {})[id]
|
|
333
|
+
next unless record
|
|
334
|
+
|
|
335
|
+
environments = record.fetch("environments", {})
|
|
336
|
+
next unless environments.key?(old_ref) && !environments.key?(new_ref)
|
|
337
|
+
|
|
338
|
+
entry = environments.delete(old_ref)
|
|
339
|
+
entry["ref"] = new_ref
|
|
340
|
+
environments[new_ref] = entry
|
|
341
|
+
record["current"] = new_ref if record["current"] == old_ref
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def remove_branch_environments(ref)
|
|
348
|
+
state.fetch("worktrees", {}).each_key do |id|
|
|
349
|
+
with_worktree_lock(id) do
|
|
350
|
+
record = state.fetch("worktrees", {})[id]
|
|
351
|
+
entry = record&.fetch("environments", {})&.[](ref)
|
|
352
|
+
next unless entry
|
|
353
|
+
|
|
354
|
+
abort "Could not remove environment #{ref}." unless worktree_manager(entry).remove(entry)
|
|
355
|
+
|
|
356
|
+
with_state_lock do |data|
|
|
357
|
+
record = data.fetch("worktrees").fetch(id)
|
|
358
|
+
record.fetch("environments").delete(ref)
|
|
359
|
+
record["current"] = nil if record["current"] == ref
|
|
360
|
+
data.fetch("worktrees").delete(id) if record.fetch("environments").empty?
|
|
361
|
+
end
|
|
362
|
+
puts "Removed Brunch environment for deleted branch #{ref}."
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
261
367
|
def worktree_status
|
|
262
368
|
data = state
|
|
263
369
|
record, current = current_worktree_entry(data)
|
|
@@ -289,6 +395,20 @@ module Brunch
|
|
|
289
395
|
end
|
|
290
396
|
end
|
|
291
397
|
|
|
398
|
+
def worktree_watch
|
|
399
|
+
raise CLI::OperationError, "brunch watch requires an interactive terminal." unless $stdout.tty?
|
|
400
|
+
|
|
401
|
+
loop do
|
|
402
|
+
print "\e[2J\e[H"
|
|
403
|
+
worktree_ports
|
|
404
|
+
puts "\nRefreshing every second. Press Ctrl-C to stop."
|
|
405
|
+
$stdout.flush
|
|
406
|
+
sleep 1
|
|
407
|
+
end
|
|
408
|
+
rescue Interrupt
|
|
409
|
+
puts "\nStopped watching."
|
|
410
|
+
end
|
|
411
|
+
|
|
292
412
|
def worktree_port
|
|
293
413
|
record, = current_worktree_entry(state)
|
|
294
414
|
raise CLI::OperationError, "No Brunch environment in this worktree." unless record
|