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 +4 -4
- data/CHANGELOG.md +11 -1
- data/README.md +30 -12
- data/lib/brunch/branch_settlement.rb +67 -0
- data/lib/brunch/cli.rb +1 -1
- data/lib/brunch/hooks.rb +12 -1
- data/lib/brunch/version.rb +1 -1
- data/lib/brunch/worktree_mode.rb +59 -8
- data/lib/brunch.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5f7a05f3aae716ba9e82f616156c85380fad4437a5d0d440056a834999830fc6
|
|
4
|
+
data.tar.gz: dccd1027c1fb0a185807427cfe0ffbe06bc94362f656de08a064f52786c39ee2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 550ae4c541eabe5196634628561c094e28039a578ccbb795c97ee41bb67b20fd48a58d4742bd685b877da57654784daa6a57004697422ad5b15f633199e9dc33
|
|
7
|
+
data.tar.gz: b6e4d92569d518d6ba19a8eb2b326996d0351a00cae53a03e4eecd23f0653a3cf5a6247a0db199b34050a47c49d8434e3ff6148860136dc99856fe8616f97ddc
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
|
179
|
+
brunch list
|
|
180
180
|
```
|
|
181
181
|
|
|
182
182
|
`brunch port` prints the current worktree's port.
|
|
183
183
|
|
|
184
|
-
`brunch
|
|
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.
|
|
298
|
-
|
|
299
|
-
the
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
data/lib/brunch/version.rb
CHANGED
data/lib/brunch/worktree_mode.rb
CHANGED
|
@@ -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 ["
|
|
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|
|
|
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
|
-
|
|
276
|
-
|
|
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
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.
|
|
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
|