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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e566d43380555cbea682f52dbbedc64bf1e4a95b681a7c60b266c98aedd5f28e
4
- data.tar.gz: 212f72d4aa56f57d4ca878a9da515fbdb8730317f2e61ac8b55b33128f679b0c
3
+ metadata.gz: 668bcb5a607af2adcad5006541dc927be6e1c5277d4c79f57b46de725e96076b
4
+ data.tar.gz: c9a02342f703ce5546860760165f4e6b3226cbc6d686f1b5b12a3891e27ad85d
5
5
  SHA512:
6
- metadata.gz: 5c3a334e07d02110511e678b252880bbf3c7ac8028377611c72d275d7018d89adeba270377f4a6f325c53783c34a674f23af5e374ccefc8b54e09ac07602ccb7
7
- data.tar.gz: a5f2857d35bb150e3adc613eb731636b43a286ba74ec4e2afd29a3e75ca9c9433dda5e174fa092db8b179ec9840fcf7ecf3bfd198a6e9104eb0272e017e9eaa3
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
- ![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:
@@ -140,15 +176,22 @@ Then:
140
176
  cd ../feature-a
141
177
 
142
178
  brunch port
143
- brunch ports
179
+ brunch list
144
180
  ```
145
181
 
146
182
  `brunch port` prints the current worktree's port.
147
183
 
148
- `brunch ports` groups the current and previously activated branches under each
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
- Git does not expose every branch deletion workflow reliably to hooks, so stopped branch environments may remain until their worktree is removed.
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 ports
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 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.1"
5
5
  end
@@ -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 ["ports"] then worktree_ports
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|ports|port|logs [args]|run -- <cmd>|doctor|cleanup}"
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
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maciej Ciemborowicz