brunch 0.9.1 → 1.0.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: 668bcb5a607af2adcad5006541dc927be6e1c5277d4c79f57b46de725e96076b
4
- data.tar.gz: c9a02342f703ce5546860760165f4e6b3226cbc6d686f1b5b12a3891e27ad85d
3
+ metadata.gz: c9e410a84d1fd30ba484345f2027d5253ed9596f0b5f26efa1add8d2b863a750
4
+ data.tar.gz: 75cf5154603bf83bf8a439f16a2489ff4bf5dda9ce9bb7dc6340e2591559458c
5
5
  SHA512:
6
- metadata.gz: d47e2a2fa7a68de063d05696eadaac3e4e41939d4e9faac87c875752c0a4e83542a13dfe9a0171b17590f7208825088de95875c87e156c10b91be2fed7a43393
7
- data.tar.gz: 94449f91dbaa4bbcb70ab6aca393068f4e25e0e92797487195de86ffd05f5d2c50ab7f3f50b6fe2837938f1da6d5e6cbca03f34ffe3309e4789e411854c0fa5f
6
+ metadata.gz: 7cfe235dfe3e43525d0a5fcaf6c8e9fa0e26d31816eec9dde3b57a2474b5b92624f069465a6b8f5b0be580ac97aeadccf44eb9441afeb54e99ea73f77d225316
7
+ data.tar.gz: 909b5e6ebb1f6cb14395dc68636db0e24cb26fe24acfedc45def14dd37ea0989b0500e07d8362fb04b92361d3b6d90f954df3edfa783b72fe1f7edcc23f3c49e
data/CHANGELOG.md CHANGED
@@ -1,6 +1,22 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 1.0.0 - 2026-10-16
4
+
5
+ - Recover automatically when a stopped Compose environment cannot resume: Brunch
6
+ recreates the failed environment instead of leaving its worktree unavailable.
7
+ - Correctly reconcile forced branch renames such as `git branch -M source target`
8
+ when the destination already has an environment.
9
+ - Keep the 100% line and branch coverage gate for the regular test suite while
10
+ allowing manager integration jobs to report functional failures directly.
11
+ - Clarify the lifecycle of stopped Compose containers, Rails PID files, forced
12
+ branch renames, and `brunch cleanup`.
13
+
14
+ ## 0.9.2 - 2026-10-05
15
+
16
+ - Settle branch deletions automatically after the Git process exits, preserving
17
+ environments across `git branch -m` without requiring another Brunch command.
18
+ Handle `git branch -M` when the destination already has an environment, and
19
+ keep pending state if the background worker cannot start.
4
20
 
5
21
  ## 0.9.1 - 2026-10-03
6
22
 
data/README.md CHANGED
@@ -140,6 +140,15 @@ With Compose, each branch keeps its own Compose project and named volumes, so pe
140
140
  Returning to a previously activated branch starts its stopped containers again,
141
141
  preserving files written inside them. The branch keeps the same host port.
142
142
 
143
+ If a resumed Compose application exits immediately, Brunch recreates that
144
+ environment automatically. This recovers the worktree but discards files kept
145
+ only in the container's writable layer; named volumes remain intact.
146
+
147
+ Applications that write a PID file inside the container should remove it before
148
+ starting. For example, a Rails Compose command can begin with
149
+ `rm -f /tmp/rails-server.pid &&`. A stopped container keeps its writable layer,
150
+ including a stale PID file, until it is recreated.
151
+
143
152
  To manually start the environment after `brunch stop`:
144
153
 
145
154
  ```bash
@@ -194,6 +203,10 @@ brunch watch
194
203
 
195
204
  Switching branches inside one worktree does not affect environments running in other worktrees.
196
205
 
206
+ For reliable automatic handling of forced branch renames such as
207
+ `git branch -M source target`, use a Git version supported by `ghe doctor` and
208
+ run `brunch install` after upgrading git-hooks-ext.
209
+
197
210
  If you create a worktree with plain Git:
198
211
 
199
212
  ```bash
@@ -214,6 +227,9 @@ brunch cleanup
214
227
  ```
215
228
 
216
229
  Brunch never deletes the worktree source directory.
230
+ It removes environments for worktrees that Git no longer reports; it does not
231
+ act as a general-purpose command for deleting the current worktree's stopped
232
+ containers or volumes.
217
233
 
218
234
  ## Managers
219
235
 
@@ -301,15 +317,20 @@ If a custom `remove` command depends on project files, those files must be commi
301
317
  `brunch cleanup` removes environments belonging to worktrees that no longer exist.
302
318
 
303
319
  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.
320
+ environments for removal. A detached worker waits for the Git process that
321
+ triggered the hook to exit, then checks the final refs and reflogs. A rename
322
+ keeps its environment under the new name; a deletion removes the managed
323
+ resources, including Compose containers and volumes. A Brunch command run
324
+ while the identified Git process is still working leaves the queued environment
325
+ alone. If the process cannot be identified or the worker cannot start, the next
326
+ regular Brunch command after Git exits, such as `brunch status` or
327
+ `brunch cleanup`, performs the check. Brunch also checks for
328
+ missing branches when Git emits no deletion event, on the next regular command.
329
+ If Git reflogs are unavailable and Brunch cannot distinguish a rename from a
330
+ deletion, it keeps the environment. A later `brunch restart` recreates the
331
+ container in the same Compose project, preserving named volumes.
332
+ For `git branch -M old target`, when `target` already has a Brunch environment,
333
+ Brunch removes that overwritten environment and moves `old` to `target`.
313
334
 
314
335
  ## Commands
315
336
 
@@ -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 = "1.0.0"
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
@@ -129,16 +130,16 @@ module Brunch
129
130
  end
130
131
  if existing && !force && existing["snapshot"] == path && existing["status"] != "starting"
131
132
  with_state_lock { |data| data.fetch("worktrees").fetch(id)["current"] = ref }
132
- unless worktree_manager(existing).resume(existing)
133
- abort "Could not resume environment for #{path}; the container may have exited. Check brunch logs. " \
134
- "If dependencies changed, rebuild with brunch restart (this replaces the container)."
133
+ if worktree_manager(existing).resume(existing)
134
+ with_state_lock do |data|
135
+ data.fetch("worktrees").fetch(id).fetch("environments").fetch(ref)["status"] = "running"
136
+ end
137
+ puts "Resumed #{ref} in #{path} at http://127.0.0.1:#{existing.fetch('port')}"
138
+ return
135
139
  end
136
140
 
137
- with_state_lock do |data|
138
- data.fetch("worktrees").fetch(id).fetch("environments").fetch(ref)["status"] = "running"
139
- end
140
- puts "Resumed #{ref} in #{path} at http://127.0.0.1:#{existing.fetch('port')}"
141
- return
141
+ warn "Could not resume #{ref}; recreating its environment."
142
+ force = true
142
143
  end
143
144
  abort "Could not reset environment for #{path}." if existing && !worktree_manager(existing).reset(existing)
144
145
 
@@ -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
 
@@ -299,7 +331,7 @@ module Brunch
299
331
  renamed = renamed_branch(ref, nil, refs)
300
332
  next if renamed == :unknown
301
333
 
302
- renamed ? rename_branch_environments(ref, renamed) : remove_branch_environments(ref)
334
+ renamed ? rename_branch_environments(ref, renamed, replace_existing: true) : remove_branch_environments(ref)
303
335
  end
304
336
  end
305
337
 
@@ -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: 1.0.0
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