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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e566d43380555cbea682f52dbbedc64bf1e4a95b681a7c60b266c98aedd5f28e
4
- data.tar.gz: 212f72d4aa56f57d4ca878a9da515fbdb8730317f2e61ac8b55b33128f679b0c
3
+ metadata.gz: 4701eb90f48c72479c1c5018c76b0d8dc8c3c93accf4cd696f28fd75829321c5
4
+ data.tar.gz: 4b8116a2365dbc41672702a9be4180a6c93f7f21e3a7098df1d1e3ca1cad8576
5
5
  SHA512:
6
- metadata.gz: 5c3a334e07d02110511e678b252880bbf3c7ac8028377611c72d275d7018d89adeba270377f4a6f325c53783c34a674f23af5e374ccefc8b54e09ac07602ccb7
7
- data.tar.gz: a5f2857d35bb150e3adc613eb731636b43a286ba74ec4e2afd29a3e75ca9c9433dda5e174fa092db8b179ec9840fcf7ecf3bfd198a6e9104eb0272e017e9eaa3
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
- ![Brunch — container per branch and worktree](brunch.webp)
3
+ ![Brunch logo — container per worktree and branch](brunch.webp)
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 its hooks through `git-hooks-ext` and will not overwrite hooks owned by another tool.
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
- Expose the application through `BRUNCH_PORT`:
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
- Git does not expose every branch deletion workflow reliably to hooks, so stopped branch environments may remain until their worktree is removed.
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 return worktree_command(arguments)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brunch
4
- VERSION = "0.8.3"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -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)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brunch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.3
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maciej Ciemborowicz