brunch 0.9.0 → 0.9.2

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: 4701eb90f48c72479c1c5018c76b0d8dc8c3c93accf4cd696f28fd75829321c5
4
- data.tar.gz: 4b8116a2365dbc41672702a9be4180a6c93f7f21e3a7098df1d1e3ca1cad8576
3
+ metadata.gz: 5f7a05f3aae716ba9e82f616156c85380fad4437a5d0d440056a834999830fc6
4
+ data.tar.gz: dccd1027c1fb0a185807427cfe0ffbe06bc94362f656de08a064f52786c39ee2
5
5
  SHA512:
6
- metadata.gz: 7ea73a21990f517294eb5255bce523ac49e3bc5ca7f71f2c2e55c9e57cf8c0d5d3125819a8db5563cc15904a02ee62f18e0b73449d13309adcd8e99d79246884
7
- data.tar.gz: c61a44ca01705759b66512972a87e75040c3f9d3445731bf4b73e506a72259c8022b3617e12c20fdfc045e43869a19c0565702df9a18ce655774fa56dcf1c83e
6
+ metadata.gz: 550ae4c541eabe5196634628561c094e28039a578ccbb795c97ee41bb67b20fd48a58d4742bd685b877da57654784daa6a57004697422ad5b15f633199e9dc33
7
+ data.tar.gz: b6e4d92569d518d6ba19a8eb2b326996d0351a00cae53a03e4eecd23f0653a3cf5a6247a0db199b34050a47c49d8434e3ff6148860136dc99856fe8616f97ddc
data/CHANGELOG.md CHANGED
@@ -1,6 +1,16 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.9.2 - 2026-10-05
4
+
5
+ - Settle branch deletions automatically after the Git process exits, preserving
6
+ environments across `git branch -m` without requiring another Brunch command.
7
+ Handle `git branch -M` when the destination already has an environment, and
8
+ keep pending state if the background worker cannot start.
9
+
10
+ ## 0.9.1 - 2026-10-03
11
+
12
+ - Replace `brunch ports` with `brunch list` and add `brunch watch` for a
13
+ continuously refreshed interactive view of branch environments.
4
14
 
5
15
  ## 0.9.0 - 2026-10-02
6
16
 
data/README.md CHANGED
@@ -176,15 +176,22 @@ Then:
176
176
  cd ../feature-a
177
177
 
178
178
  brunch port
179
- brunch ports
179
+ brunch list
180
180
  ```
181
181
 
182
182
  `brunch port` prints the current worktree's port.
183
183
 
184
- `brunch ports` groups the current and previously activated branches under each
184
+ `brunch list` groups the current and previously activated branches under each
185
185
  worktree. Stopped branches appear in gray and retain their worktree's port for
186
186
  the next activation.
187
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
+
188
195
  Switching branches inside one worktree does not affect environments running in other worktrees.
189
196
 
190
197
  If you create a worktree with plain Git:
@@ -294,15 +301,20 @@ If a custom `remove` command depends on project files, those files must be commi
294
301
  `brunch cleanup` removes environments belonging to worktrees that no longer exist.
295
302
 
296
303
  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.
304
+ environments for removal. A detached worker waits for the Git process that
305
+ triggered the hook to exit, then checks the final refs and reflogs. A rename
306
+ keeps its environment under the new name; a deletion removes the managed
307
+ resources, including Compose containers and volumes. A Brunch command run
308
+ while the identified Git process is still working leaves the queued environment
309
+ alone. If the process cannot be identified or the worker cannot start, the next
310
+ regular Brunch command after Git exits, such as `brunch status` or
311
+ `brunch cleanup`, performs the check. Brunch also checks for
312
+ missing branches when Git emits no deletion event, on the next regular command.
313
+ If Git reflogs are unavailable and Brunch cannot distinguish a rename from a
314
+ deletion, it keeps the environment. A later `brunch restart` recreates the
315
+ container in the same Compose project, preserving named volumes.
316
+ For `git branch -M old target`, when `target` already has a Brunch environment,
317
+ Brunch removes that overwritten environment and moves `old` to `target`.
306
318
 
307
319
  ## Commands
308
320
 
@@ -331,11 +343,17 @@ brunch port
331
343
  Print the current worktree's port.
332
344
 
333
345
  ```bash
334
- brunch ports
346
+ brunch list
335
347
  ```
336
348
 
337
349
  List current and stopped branch environments grouped by worktree.
338
350
 
351
+ ```bash
352
+ brunch watch
353
+ ```
354
+
355
+ Continuously refresh the branch-environment list in an interactive terminal.
356
+
339
357
  ```bash
340
358
  brunch stop
341
359
  ```
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "securerandom"
5
+
6
+ module Brunch
7
+ module BranchSettlement
8
+ module_function
9
+
10
+ def git_ancestor(pid = Process.ppid)
11
+ 32.times do
12
+ process = process_info(pid)
13
+ break unless process
14
+
15
+ return { "pid" => pid, "started" => process.fetch(:started) } if File.basename(process.fetch(:command)) == "git"
16
+
17
+ pid = process.fetch(:parent)
18
+ break if pid <= 1
19
+ end
20
+ nil
21
+ end
22
+
23
+ def running?(identity)
24
+ return false unless identity.is_a?(Hash) && identity["pid"].is_a?(Integer) && identity["started"].is_a?(String)
25
+
26
+ process = process_info(identity.fetch("pid"))
27
+ return process_exists?(identity.fetch("pid")) unless process
28
+
29
+ process.fetch(:started) == identity.fetch("started") && !process.fetch(:state).start_with?("Z")
30
+ end
31
+
32
+ def process_exists?(pid)
33
+ Process.kill(0, pid)
34
+ true
35
+ rescue Errno::ESRCH
36
+ false
37
+ rescue Errno::EPERM
38
+ true
39
+ end
40
+
41
+ def token = SecureRandom.hex(16)
42
+
43
+ def spawn_worker(root, ref, token)
44
+ executable = File.expand_path("../../exe/brunch", __dir__)
45
+ pid = Process.spawn(Gem.ruby, executable, "branch-settled", ref, token,
46
+ chdir: root, in: File::NULL, out: File::NULL, err: File::NULL,
47
+ pgroup: true, close_others: true)
48
+ Process.detach(pid)
49
+ end
50
+
51
+ def wait_for(identity)
52
+ sleep 0.1 while running?(identity)
53
+ end
54
+
55
+ def process_info(pid)
56
+ output, status = Open3.capture2("ps", "-p", pid.to_s, "-o", "ppid=,lstart=,stat=,comm=", err: File::NULL)
57
+ return unless status.success?
58
+
59
+ fields = output.strip.split(/\s+/, 8)
60
+ return unless fields.length == 8 && fields[0].match?(/\A\d+\z/)
61
+
62
+ { parent: fields[0].to_i, started: fields[1, 5].join(" "), state: fields[6], command: fields[7] }
63
+ rescue Errno::ENOENT
64
+ nil
65
+ end
66
+ end
67
+ end
data/lib/brunch/cli.rb CHANGED
@@ -24,7 +24,7 @@ module Brunch
24
24
  in ["install"] then Hooks.install
25
25
  in ["version"] | ["--version"] | ["-v"] then puts Brunch::VERSION
26
26
  else
27
- unless arguments.first == "branch-deleted"
27
+ unless %w[branch-deleted branch-settled].include?(arguments.first)
28
28
  reconcile_deleted_branches
29
29
  reconcile_missing_branches
30
30
  end
data/lib/brunch/hooks.rb CHANGED
@@ -27,7 +27,9 @@ module Brunch
27
27
  when "branch-created", "remote-branch-created", "remote-branch-updated"
28
28
  CLI.start(["register", arguments.fetch(0)])
29
29
  when "branch-deleted"
30
- CLI.start(["branch-deleted", arguments.fetch(0), arguments.fetch(2)])
30
+ with_branch_deletion_hook do
31
+ CLI.start(["branch-deleted", arguments.fetch(0), arguments.fetch(2)])
32
+ end
31
33
  when "worktree-created", "worktree-removed", "worktree-moved", "worktree-pruned", "worktree-repaired"
32
34
  CLI.start(["worktree-event", event, *arguments])
33
35
  else
@@ -43,6 +45,15 @@ module Brunch
43
45
  end
44
46
  private_class_method :git_output
45
47
 
48
+ def with_branch_deletion_hook
49
+ previous = ENV.fetch("BRUNCH_BRANCH_DELETION_HOOK", nil)
50
+ ENV["BRUNCH_BRANCH_DELETION_HOOK"] = "1"
51
+ yield
52
+ ensure
53
+ previous ? ENV["BRUNCH_BRANCH_DELETION_HOOK"] = previous : ENV.delete("BRUNCH_BRANCH_DELETION_HOOK")
54
+ end
55
+ private_class_method :with_branch_deletion_hook
56
+
46
57
  def install_hook(path, event)
47
58
  contents = hook_contents(event)
48
59
  abort "Refusing to replace existing hook: #{path}" if File.exist?(path) && File.read(path) != contents && !brunch_hook?(path)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brunch
4
- VERSION = "0.9.0"
4
+ VERSION = "0.9.2"
5
5
  end
@@ -21,15 +21,17 @@ module Brunch
21
21
  worktree_activate(repo_root)
22
22
  in ["stop"] then worktree_stop(repo_root)
23
23
  in ["branch-deleted", ref, oid] then record_deleted_branch(ref, oid)
24
+ in ["branch-settled", ref, token] then settle_deleted_branch(ref, token)
24
25
  in ["cleanup"] | ["register", _] then worktree_cleanup
25
26
  in ["status"] then worktree_status
26
- in ["ports"] then worktree_ports
27
+ in ["list"] then worktree_ports
28
+ in ["watch"] then worktree_watch
27
29
  in ["port"] then worktree_port
28
30
  in ["logs", *arguments] then worktree_logs(arguments)
29
31
  in ["run", "--", *command] if !command.empty? then worktree_run(command)
30
32
  in ["doctor"] then worktree_doctor
31
33
  else
32
- warn "Usage: brunch {activate|start <current-ref>|stop|restart|status|ports|port|logs [args]|run -- <cmd>|doctor|cleanup}"
34
+ warn "Usage: brunch {activate|start <current-ref>|stop|restart|status|list|watch|port|logs [args]|run -- <cmd>|doctor|cleanup}"
33
35
  return 64
34
36
  end
35
37
  0
@@ -260,27 +262,58 @@ module Brunch
260
262
  end
261
263
 
262
264
  def record_deleted_branch(ref, oid)
265
+ git = BranchSettlement.git_ancestor if ENV["BRUNCH_BRANCH_DELETION_HOOK"] == "1"
266
+ token = BranchSettlement.token if git
267
+ recorded = false
263
268
  with_state_lock do |data|
264
269
  records = data.fetch("worktrees", {}).values
265
270
  next unless records.any? { |record| record.fetch("environments", {}).key?(ref) }
266
271
 
267
- (data["deleted_branches"] ||= {})[ref] = oid
272
+ (data["deleted_branches"] ||= {})[ref] = git ? { "oid" => oid, "git" => git, "token" => token } : oid
273
+ recorded = true
268
274
  end
275
+ BranchSettlement.spawn_worker(repo_root, ref, token) if recorded && git
276
+ rescue SystemCallError => e
277
+ warn "Could not start branch cleanup worker: #{e.message}; cleanup will run on the next Brunch command."
269
278
  end
270
279
 
271
280
  def reconcile_deleted_branches
272
281
  pending = state.fetch("deleted_branches", {})
273
282
  return if pending.empty?
274
283
 
275
- refs = local_branch_refs
276
- pending.each do |ref, oid|
284
+ pending.each_key { |ref| settle_deleted_branch(ref) }
285
+ end
286
+
287
+ def settle_deleted_branch(ref, expected_token = nil)
288
+ if expected_token
289
+ queued = state.fetch("deleted_branches", {})[ref]
290
+ return unless queued.is_a?(Hash) && queued["token"] == expected_token
291
+
292
+ BranchSettlement.wait_for(queued.fetch("git"))
293
+ end
294
+ directory = File.join(state_root, "branch-locks")
295
+ FileUtils.mkdir_p(directory)
296
+ File.open(File.join(directory, Digest::SHA256.hexdigest(ref)), "w") do |lock|
297
+ lock.flock(File::LOCK_EX)
298
+ pending = state.fetch("deleted_branches", {})[ref]
299
+ next unless pending
300
+ next if expected_token && (!pending.is_a?(Hash) || pending["token"] != expected_token)
301
+
302
+ identity = pending["git"] if pending.is_a?(Hash)
303
+ next if identity && BranchSettlement.running?(identity)
304
+
305
+ oid = pending.is_a?(Hash) ? pending.fetch("oid") : pending
306
+ refs = local_branch_refs
277
307
  unless refs.include?("refs/heads/#{ref}")
278
308
  renamed = renamed_branch(ref, oid, refs)
279
309
  next if renamed == :unknown
280
310
 
281
- renamed ? rename_branch_environments(ref, renamed) : remove_branch_environments(ref)
311
+ renamed ? rename_branch_environments(ref, renamed, replace_existing: pending.is_a?(Hash)) : remove_branch_environments(ref)
312
+ end
313
+ with_state_lock do |data|
314
+ current = data.fetch("deleted_branches", {})[ref]
315
+ data.fetch("deleted_branches").delete(ref) if current == pending
282
316
  end
283
- with_state_lock { |data| data.fetch("deleted_branches").delete(ref) }
284
317
  end
285
318
  end
286
319
 
@@ -324,7 +357,11 @@ module Brunch
324
357
  :unknown if missing_reflog
325
358
  end
326
359
 
327
- def rename_branch_environments(old_ref, new_ref)
360
+ def rename_branch_environments(old_ref, new_ref, replace_existing: false)
361
+ records = state.fetch("worktrees", {}).values
362
+ return unless records.any? { |record| record.fetch("environments", {}).key?(old_ref) }
363
+
364
+ remove_branch_environments(new_ref) if replace_existing
328
365
  state.fetch("worktrees", {}).each_key do |id|
329
366
  with_worktree_lock(id) do
330
367
  with_state_lock do |data|
@@ -394,6 +431,20 @@ module Brunch
394
431
  end
395
432
  end
396
433
 
434
+ def worktree_watch
435
+ raise CLI::OperationError, "brunch watch requires an interactive terminal." unless $stdout.tty?
436
+
437
+ loop do
438
+ print "\e[2J\e[H"
439
+ worktree_ports
440
+ puts "\nRefreshing every second. Press Ctrl-C to stop."
441
+ $stdout.flush
442
+ sleep 1
443
+ end
444
+ rescue Interrupt
445
+ puts "\nStopped watching."
446
+ end
447
+
397
448
  def worktree_port
398
449
  record, = current_worktree_entry(state)
399
450
  raise CLI::OperationError, "No Brunch environment in this worktree." unless record
data/lib/brunch.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "brunch/version"
4
4
  require_relative "brunch/managers"
5
+ require_relative "brunch/branch_settlement"
5
6
  require_relative "brunch/worktree_mode"
6
7
  require_relative "brunch/cli"
7
8
  require_relative "brunch/hooks"
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.9.0
4
+ version: 0.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maciej Ciemborowicz
@@ -92,6 +92,7 @@ files:
92
92
  - brunch.webp
93
93
  - exe/brunch
94
94
  - lib/brunch.rb
95
+ - lib/brunch/branch_settlement.rb
95
96
  - lib/brunch/cli.rb
96
97
  - lib/brunch/hooks.rb
97
98
  - lib/brunch/managers.rb