brunch 0.9.1 → 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: 668bcb5a607af2adcad5006541dc927be6e1c5277d4c79f57b46de725e96076b
4
- data.tar.gz: c9a02342f703ce5546860760165f4e6b3226cbc6d686f1b5b12a3891e27ad85d
3
+ metadata.gz: 5f7a05f3aae716ba9e82f616156c85380fad4437a5d0d440056a834999830fc6
4
+ data.tar.gz: dccd1027c1fb0a185807427cfe0ffbe06bc94362f656de08a064f52786c39ee2
5
5
  SHA512:
6
- metadata.gz: d47e2a2fa7a68de063d05696eadaac3e4e41939d4e9faac87c875752c0a4e83542a13dfe9a0171b17590f7208825088de95875c87e156c10b91be2fed7a43393
7
- data.tar.gz: 94449f91dbaa4bbcb70ab6aca393068f4e25e0e92797487195de86ffd05f5d2c50ab7f3f50b6fe2837938f1da6d5e6cbca03f34ffe3309e4789e411854c0fa5f
6
+ metadata.gz: 550ae4c541eabe5196634628561c094e28039a578ccbb795c97ee41bb67b20fd48a58d4742bd685b877da57654784daa6a57004697422ad5b15f633199e9dc33
7
+ data.tar.gz: b6e4d92569d518d6ba19a8eb2b326996d0351a00cae53a03e4eecd23f0653a3cf5a6247a0db199b34050a47c49d8434e3ff6148860136dc99856fe8616f97ddc
data/CHANGELOG.md CHANGED
@@ -1,6 +1,11 @@
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.
4
9
 
5
10
  ## 0.9.1 - 2026-10-03
6
11
 
data/README.md CHANGED
@@ -301,15 +301,20 @@ If a custom `remove` command depends on project files, those files must be commi
301
301
  `brunch cleanup` removes environments belonging to worktrees that no longer exist.
302
302
 
303
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.
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`.
313
318
 
314
319
  ## Commands
315
320
 
@@ -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.1"
4
+ VERSION = "0.9.2"
5
5
  end
@@ -21,6 +21,7 @@ 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
27
  in ["list"] then worktree_ports
@@ -261,27 +262,58 @@ module Brunch
261
262
  end
262
263
 
263
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
264
268
  with_state_lock do |data|
265
269
  records = data.fetch("worktrees", {}).values
266
270
  next unless records.any? { |record| record.fetch("environments", {}).key?(ref) }
267
271
 
268
- (data["deleted_branches"] ||= {})[ref] = oid
272
+ (data["deleted_branches"] ||= {})[ref] = git ? { "oid" => oid, "git" => git, "token" => token } : oid
273
+ recorded = true
269
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."
270
278
  end
271
279
 
272
280
  def reconcile_deleted_branches
273
281
  pending = state.fetch("deleted_branches", {})
274
282
  return if pending.empty?
275
283
 
276
- refs = local_branch_refs
277
- 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
278
307
  unless refs.include?("refs/heads/#{ref}")
279
308
  renamed = renamed_branch(ref, oid, refs)
280
309
  next if renamed == :unknown
281
310
 
282
- 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
283
316
  end
284
- with_state_lock { |data| data.fetch("deleted_branches").delete(ref) }
285
317
  end
286
318
  end
287
319
 
@@ -325,7 +357,11 @@ module Brunch
325
357
  :unknown if missing_reflog
326
358
  end
327
359
 
328
- 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
329
365
  state.fetch("worktrees", {}).each_key do |id|
330
366
  with_worktree_lock(id) do
331
367
  with_state_lock do |data|
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.1
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