brunch 0.8.3 → 0.9.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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +52 -7
- 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 +107 -2
- 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: 4701eb90f48c72479c1c5018c76b0d8dc8c3c93accf4cd696f28fd75829321c5
|
|
4
|
+
data.tar.gz: 4b8116a2365dbc41672702a9be4180a6c93f7f21e3a7098df1d1e3ca1cad8576
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7ea73a21990f517294eb5255bce523ac49e3bc5ca7f71f2c2e55c9e57cf8c0d5d3125819a8db5563cc15904a02ee62f18e0b73449d13309adcd8e99d79246884
|
|
7
|
+
data.tar.gz: c61a44ca01705759b66512972a87e75040c3f9d3445731bf4b73e506a72259c8022b3617e12c20fdfc045e43869a19c0565702df9a18ce655774fa56dcf1c83e
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.9.0 - 2026-10-02
|
|
6
|
+
|
|
7
|
+
- Remove environments for deleted local branches after the next Brunch command,
|
|
8
|
+
while preserving them across branch renames, including reftable renames that
|
|
9
|
+
do not emit a deletion event. Run `brunch install` again to install the new
|
|
10
|
+
`branch-deleted` hook.
|
|
11
|
+
- Keep the same Compose project and named volumes when restarting after a branch rename.
|
|
12
|
+
- Exercise the config-based git-hooks-ext bridge with Git 2.55 in CI.
|
|
13
|
+
|
|
14
|
+
## 0.8.4 - 2026-10-01
|
|
15
|
+
|
|
16
|
+
- Test against git-hooks-ext 0.6.0 in CI and clarify bridge upgrades and
|
|
17
|
+
worktree Git requirements.
|
|
18
|
+
- Document a minimal Rails setup and update the Brunch artwork.
|
|
19
|
+
|
|
5
20
|
## 0.8.3 - 2026-09-29
|
|
6
21
|
|
|
7
22
|
- 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:
|
|
@@ -257,7 +293,16 @@ If a custom `remove` command depends on project files, those files must be commi
|
|
|
257
293
|
|
|
258
294
|
`brunch cleanup` removes environments belonging to worktrees that no longer exist.
|
|
259
295
|
|
|
260
|
-
|
|
296
|
+
With `git-hooks-ext` 0.6.0+, deleting a local branch queues its Brunch
|
|
297
|
+
environments for removal. On the next regular Brunch command, such as
|
|
298
|
+
`brunch status` or `brunch cleanup`, Brunch checks the current refs and removes
|
|
299
|
+
the branch's managed resources, including Compose containers and volumes.
|
|
300
|
+
It also checks for missing branches when Git emits no deletion event. This
|
|
301
|
+
delay protects environments when `git branch -m` reports a deletion before
|
|
302
|
+
creating the renamed branch. A renamed branch keeps its environment under the
|
|
303
|
+
new name. If Git reflogs are unavailable and Brunch cannot distinguish a
|
|
304
|
+
rename from a deletion, it keeps the environment. A later `brunch restart`
|
|
305
|
+
recreates the container in the same Compose project, preserving named volumes.
|
|
261
306
|
|
|
262
307
|
## Commands
|
|
263
308
|
|
|
@@ -326,7 +371,7 @@ Check Git, installed hooks, configuration, manager availability, and ports.
|
|
|
326
371
|
brunch cleanup
|
|
327
372
|
```
|
|
328
373
|
|
|
329
|
-
Remove environments belonging to deleted worktrees.
|
|
374
|
+
Remove environments belonging to deleted worktrees and process pending branch deletions.
|
|
330
375
|
|
|
331
376
|
## Upgrading from older configurations
|
|
332
377
|
|
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,6 +20,7 @@ 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
26
|
in ["ports"] then worktree_ports
|
|
@@ -140,8 +141,8 @@ module Brunch
|
|
|
140
141
|
end
|
|
141
142
|
abort "Could not reset environment for #{path}." if existing && !worktree_manager(existing).reset(existing)
|
|
142
143
|
|
|
143
|
-
project = "brunch-#{id[0, 12]}-#{Digest::SHA256.hexdigest(ref)[0, 12]}"
|
|
144
|
-
control = File.join(state_root, "controls", project)
|
|
144
|
+
project = existing ? existing.fetch("project") : "brunch-#{id[0, 12]}-#{Digest::SHA256.hexdigest(ref)[0, 12]}"
|
|
145
|
+
control = existing ? existing.fetch("control") : File.join(state_root, "controls", project)
|
|
145
146
|
archive_ref(head, control)
|
|
146
147
|
compose_file = config.fetch("compose_file")
|
|
147
148
|
if %w[docker_compose podman_compose].include?(config.fetch("manager"))
|
|
@@ -258,6 +259,110 @@ module Brunch
|
|
|
258
259
|
end
|
|
259
260
|
end
|
|
260
261
|
|
|
262
|
+
def record_deleted_branch(ref, oid)
|
|
263
|
+
with_state_lock do |data|
|
|
264
|
+
records = data.fetch("worktrees", {}).values
|
|
265
|
+
next unless records.any? { |record| record.fetch("environments", {}).key?(ref) }
|
|
266
|
+
|
|
267
|
+
(data["deleted_branches"] ||= {})[ref] = oid
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def reconcile_deleted_branches
|
|
272
|
+
pending = state.fetch("deleted_branches", {})
|
|
273
|
+
return if pending.empty?
|
|
274
|
+
|
|
275
|
+
refs = local_branch_refs
|
|
276
|
+
pending.each do |ref, oid|
|
|
277
|
+
unless refs.include?("refs/heads/#{ref}")
|
|
278
|
+
renamed = renamed_branch(ref, oid, refs)
|
|
279
|
+
next if renamed == :unknown
|
|
280
|
+
|
|
281
|
+
renamed ? rename_branch_environments(ref, renamed) : remove_branch_environments(ref)
|
|
282
|
+
end
|
|
283
|
+
with_state_lock { |data| data.fetch("deleted_branches").delete(ref) }
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def reconcile_missing_branches
|
|
288
|
+
data = state
|
|
289
|
+
names = data.fetch("worktrees", {}).values.flat_map { |record| record.fetch("environments", {}).keys }.uniq
|
|
290
|
+
names -= data.fetch("deleted_branches", {}).keys
|
|
291
|
+
names.reject! { |ref| ref.start_with?("detached-") }
|
|
292
|
+
return if names.empty?
|
|
293
|
+
|
|
294
|
+
refs = local_branch_refs
|
|
295
|
+
names.each do |ref|
|
|
296
|
+
next if refs.include?("refs/heads/#{ref}")
|
|
297
|
+
|
|
298
|
+
renamed = renamed_branch(ref, nil, refs)
|
|
299
|
+
next if renamed == :unknown
|
|
300
|
+
|
|
301
|
+
renamed ? rename_branch_environments(ref, renamed) : remove_branch_environments(ref)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def local_branch_refs
|
|
306
|
+
output, status = Open3.capture2("git", "for-each-ref", "--format=%(refname)", "refs/heads")
|
|
307
|
+
abort "Could not inspect local branches." unless status.success?
|
|
308
|
+
|
|
309
|
+
output.lines.map(&:strip)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def renamed_branch(old_ref, old_oid, refs)
|
|
313
|
+
missing_reflog = false
|
|
314
|
+
full_ref = refs.find do |new_ref|
|
|
315
|
+
output, status = Open3.capture2("git", "reflog", "show", "--format=%H %gs", new_ref)
|
|
316
|
+
missing_reflog ||= !status.success?
|
|
317
|
+
status.success? && output.lines.any? do |line|
|
|
318
|
+
oid, message = line.split(" ", 2)
|
|
319
|
+
(old_oid.nil? || oid == old_oid) && message.to_s.strip == "Branch: renamed refs/heads/#{old_ref} to #{new_ref}"
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
return full_ref.delete_prefix("refs/heads/") if full_ref
|
|
323
|
+
|
|
324
|
+
:unknown if missing_reflog
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def rename_branch_environments(old_ref, new_ref)
|
|
328
|
+
state.fetch("worktrees", {}).each_key do |id|
|
|
329
|
+
with_worktree_lock(id) do
|
|
330
|
+
with_state_lock do |data|
|
|
331
|
+
record = data.fetch("worktrees", {})[id]
|
|
332
|
+
next unless record
|
|
333
|
+
|
|
334
|
+
environments = record.fetch("environments", {})
|
|
335
|
+
next unless environments.key?(old_ref) && !environments.key?(new_ref)
|
|
336
|
+
|
|
337
|
+
entry = environments.delete(old_ref)
|
|
338
|
+
entry["ref"] = new_ref
|
|
339
|
+
environments[new_ref] = entry
|
|
340
|
+
record["current"] = new_ref if record["current"] == old_ref
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def remove_branch_environments(ref)
|
|
347
|
+
state.fetch("worktrees", {}).each_key do |id|
|
|
348
|
+
with_worktree_lock(id) do
|
|
349
|
+
record = state.fetch("worktrees", {})[id]
|
|
350
|
+
entry = record&.fetch("environments", {})&.[](ref)
|
|
351
|
+
next unless entry
|
|
352
|
+
|
|
353
|
+
abort "Could not remove environment #{ref}." unless worktree_manager(entry).remove(entry)
|
|
354
|
+
|
|
355
|
+
with_state_lock do |data|
|
|
356
|
+
record = data.fetch("worktrees").fetch(id)
|
|
357
|
+
record.fetch("environments").delete(ref)
|
|
358
|
+
record["current"] = nil if record["current"] == ref
|
|
359
|
+
data.fetch("worktrees").delete(id) if record.fetch("environments").empty?
|
|
360
|
+
end
|
|
361
|
+
puts "Removed Brunch environment for deleted branch #{ref}."
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
261
366
|
def worktree_status
|
|
262
367
|
data = state
|
|
263
368
|
record, current = current_worktree_entry(data)
|