brunch 0.9.2 → 1.1.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: 5f7a05f3aae716ba9e82f616156c85380fad4437a5d0d440056a834999830fc6
4
- data.tar.gz: dccd1027c1fb0a185807427cfe0ffbe06bc94362f656de08a064f52786c39ee2
3
+ metadata.gz: bfbfbc36f9114715c3b656aeeb831010e8e75e1ccc15d8c7229813b4489ceac6
4
+ data.tar.gz: 6a98d0331e7d758bacafad88344dd92a0a105b60c06ead342d35fb928e36cc0f
5
5
  SHA512:
6
- metadata.gz: 550ae4c541eabe5196634628561c094e28039a578ccbb795c97ee41bb67b20fd48a58d4742bd685b877da57654784daa6a57004697422ad5b15f633199e9dc33
7
- data.tar.gz: b6e4d92569d518d6ba19a8eb2b326996d0351a00cae53a03e4eecd23f0653a3cf5a6247a0db199b34050a47c49d8434e3ff6148860136dc99856fe8616f97ddc
6
+ metadata.gz: 64bbfe38e607cf1cf8cc1e9ce042e104653a4ac0958fdeca87ff6e40e4febf0f09e287d9c55f6cb7ce75c17a3062ad6edb1cd159f18407d3a52415192360c52b
7
+ data.tar.gz: ea493ddaa2e3431665a0a0a0d1c42f99a3013c9ab5ccafc98b170a98dfddd20188a7bd53dc6384f99a1469a5aba1b52298b1edd9881287e4d8c3365d246719c6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0 - 2026-10-16
4
+
5
+ - Add opt-in `ports: auto` for Docker Compose and Podman Compose. Brunch now
6
+ renders a private Compose configuration and assigns every published host port
7
+ independently for each worktree, without modifying the repository file.
8
+ - Add `brunch ports` and `brunch port SERVICE CONTAINER_PORT` for discovering
9
+ the complete set of assigned host ports.
10
+
11
+ ## 1.0.0 - 2026-10-16
12
+
13
+ - Recover automatically when a stopped Compose environment cannot resume: Brunch
14
+ recreates the failed environment instead of leaving its worktree unavailable.
15
+ - Correctly reconcile forced branch renames such as `git branch -M source target`
16
+ when the destination already has an environment.
17
+ - Keep the 100% line and branch coverage gate for the regular test suite while
18
+ allowing manager integration jobs to report functional failures directly.
19
+ - Clarify the lifecycle of stopped Compose containers, Rails PID files, forced
20
+ branch renames, and `brunch cleanup`.
21
+
3
22
  ## 0.9.2 - 2026-10-05
4
23
 
5
24
  - Settle branch deletions automatically after the Git process exits, preserving
data/README.md CHANGED
@@ -96,6 +96,44 @@ preferred_port: 4000
96
96
 
97
97
  Port assignments are persisted per worktree in `.git/brunch/state.json`.
98
98
 
99
+ ### Existing Compose projects with several ports
100
+
101
+ By default Brunch manages the `BRUNCH_PORT` variable shown above. To run an
102
+ existing Compose project that publishes several fixed host ports, opt in with:
103
+
104
+ ```yaml
105
+ ports: auto
106
+ ```
107
+
108
+ Brunch renders the current Compose configuration, reserves every published
109
+ host port, and writes a private generated configuration under `.git/brunch/`.
110
+ It never changes the repository's `compose.yaml`. The first worktree keeps the
111
+ declared ports whenever they are free; additional worktrees receive free ports
112
+ for every published service port.
113
+
114
+ For example, this requires no variables in the Compose file:
115
+
116
+ ```yaml
117
+ services:
118
+ web:
119
+ ports:
120
+ - "3000:3000"
121
+ - "5173:5173"
122
+ mail:
123
+ ports:
124
+ - "8025:8025"
125
+ ```
126
+
127
+ `brunch ports` displays all assigned mappings. Query one mapping with:
128
+
129
+ ```bash
130
+ brunch port web 3000
131
+ ```
132
+
133
+ Only `ports:` entries that publish a host port are managed; internal service
134
+ ports and `expose:` remain unchanged. `BRUNCH_PORT` continues to contain the
135
+ first published port for compatibility with existing scripts.
136
+
99
137
  ## Getting started
100
138
 
101
139
  Commit `brunch.yml` and the application configuration first. The repository must contain at least one commit.
@@ -140,6 +178,15 @@ With Compose, each branch keeps its own Compose project and named volumes, so pe
140
178
  Returning to a previously activated branch starts its stopped containers again,
141
179
  preserving files written inside them. The branch keeps the same host port.
142
180
 
181
+ If a resumed Compose application exits immediately, Brunch recreates that
182
+ environment automatically. This recovers the worktree but discards files kept
183
+ only in the container's writable layer; named volumes remain intact.
184
+
185
+ Applications that write a PID file inside the container should remove it before
186
+ starting. For example, a Rails Compose command can begin with
187
+ `rm -f /tmp/rails-server.pid &&`. A stopped container keeps its writable layer,
188
+ including a stale PID file, until it is recreated.
189
+
143
190
  To manually start the environment after `brunch stop`:
144
191
 
145
192
  ```bash
@@ -176,12 +223,12 @@ Then:
176
223
  cd ../feature-a
177
224
 
178
225
  brunch port
179
- brunch list
226
+ brunch ports
180
227
  ```
181
228
 
182
229
  `brunch port` prints the current worktree's port.
183
230
 
184
- `brunch list` groups the current and previously activated branches under each
231
+ `brunch ports` groups the current and previously activated branches under each
185
232
  worktree. Stopped branches appear in gray and retain their worktree's port for
186
233
  the next activation.
187
234
 
@@ -194,6 +241,10 @@ brunch watch
194
241
 
195
242
  Switching branches inside one worktree does not affect environments running in other worktrees.
196
243
 
244
+ For reliable automatic handling of forced branch renames such as
245
+ `git branch -M source target`, use a Git version supported by `ghe doctor` and
246
+ run `brunch install` after upgrading git-hooks-ext.
247
+
197
248
  If you create a worktree with plain Git:
198
249
 
199
250
  ```bash
@@ -214,6 +265,9 @@ brunch cleanup
214
265
  ```
215
266
 
216
267
  Brunch never deletes the worktree source directory.
268
+ It removes environments for worktrees that Git no longer reports; it does not
269
+ act as a general-purpose command for deleting the current worktree's stopped
270
+ containers or volumes.
217
271
 
218
272
  ## Managers
219
273
 
data/lib/brunch/cli.rb CHANGED
@@ -10,7 +10,7 @@ module Brunch
10
10
  include WorktreeMode
11
11
 
12
12
  PORT_RANGE = (1024..49_151)
13
- CONFIGURATION_KEYS = %w[manager compose_file command commands preferred_port].freeze
13
+ CONFIGURATION_KEYS = %w[manager compose_file command commands preferred_port ports].freeze
14
14
  COMMAND_KEYS = %w[create start stop remove status health logs].freeze
15
15
 
16
16
  class ConfigurationError < StandardError; end
@@ -90,7 +90,8 @@ module Brunch
90
90
  path = File.join(root, "brunch.yml")
91
91
  configured = File.file?(path) ? YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {} : {}
92
92
  validate_configuration!(configured)
93
- { "manager" => "docker_compose", "compose_file" => "compose.yaml", "preferred_port" => 3000 }.merge(configured)
93
+ { "manager" => "docker_compose", "compose_file" => "compose.yaml", "preferred_port" => 3000,
94
+ "ports" => "manual" }.merge(configured)
94
95
  rescue Psych::Exception => e
95
96
  raise ConfigurationError, "Invalid brunch.yml: #{e.message}"
96
97
  end
@@ -101,7 +102,8 @@ module Brunch
101
102
  unknown = configured.keys - CONFIGURATION_KEYS
102
103
  raise ConfigurationError, "Unknown brunch.yml key(s): #{unknown.join(', ')}." unless unknown.empty?
103
104
 
104
- value = { "manager" => "docker_compose", "compose_file" => "compose.yaml", "preferred_port" => 3000 }.merge(configured)
105
+ value = { "manager" => "docker_compose", "compose_file" => "compose.yaml", "preferred_port" => 3000,
106
+ "ports" => "manual" }.merge(configured)
105
107
  unless %w[docker_compose podman_compose local_process command].include?(value["manager"])
106
108
  raise ConfigurationError, "Unknown Brunch manager: #{value['manager']}."
107
109
  end
@@ -111,6 +113,10 @@ module Brunch
111
113
  if %w[docker_compose podman_compose].include?(value["manager"]) && (!value["compose_file"].is_a?(String) || value["compose_file"].empty?)
112
114
  raise ConfigurationError, "compose_file must be a non-empty string."
113
115
  end
116
+ raise ConfigurationError, "ports must be manual or auto." unless %w[manual auto].include?(value["ports"])
117
+ if value["ports"] == "auto" && !%w[docker_compose podman_compose].include?(value["manager"])
118
+ raise ConfigurationError, "ports: auto requires a Docker Compose or Podman Compose manager."
119
+ end
114
120
  if value["manager"] == "local_process" && (!value["command"].is_a?(String) || value["command"].empty?)
115
121
  raise ConfigurationError, "local_process manager requires a non-empty command."
116
122
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brunch
4
+ # Reads the resolved Compose model produced by `compose config` and changes
5
+ # only published host ports. Keeping this separate from the source YAML lets
6
+ # Brunch use existing Compose projects without asking them to add variables.
7
+ module ComposePorts
8
+ module_function
9
+
10
+ def extract(document)
11
+ services = document.fetch("services", {})
12
+ raise CLI::ConfigurationError, "Compose config must contain a services mapping." unless services.is_a?(Hash)
13
+
14
+ services.flat_map do |service, definition|
15
+ next [] unless definition.is_a?(Hash)
16
+
17
+ Array(definition["ports"]).filter_map do |port|
18
+ published_mapping(service, port)
19
+ end
20
+ end
21
+ end
22
+
23
+ def rewrite(document, assignments)
24
+ indexed = assignments.to_h { |assignment| [key(assignment), assignment.fetch("host")] }
25
+ document.fetch("services", {}).each do |service, definition|
26
+ next unless definition.is_a?(Hash)
27
+
28
+ definition["ports"] = Array(definition["ports"]).map do |port|
29
+ mapping = published_mapping(service, port)
30
+ next port unless mapping
31
+
32
+ port.merge("published" => indexed.fetch(key(mapping)))
33
+ end
34
+ end
35
+ document
36
+ end
37
+
38
+ def key(mapping)
39
+ [mapping.fetch("service"), mapping.fetch("target"), mapping.fetch("protocol"), mapping.fetch("host_ip")]
40
+ end
41
+
42
+ def published_mapping(service, port)
43
+ raise CLI::ConfigurationError, "Unsupported Compose port for #{service}; use Compose long syntax with ports: auto." unless port.is_a?(Hash)
44
+ return unless port.key?("published") && !port["published"].to_s.empty?
45
+
46
+ published = integer(port["published"], "published host port for #{service}")
47
+ target = integer(port["target"], "container port for #{service}")
48
+ protocol = port.fetch("protocol", "tcp")
49
+ raise CLI::ConfigurationError, "Unsupported protocol #{protocol.inspect} for #{service}." unless %w[tcp udp].include?(protocol)
50
+
51
+ { "service" => service.to_s, "target" => target, "protocol" => protocol,
52
+ "host_ip" => port.fetch("host_ip", "").to_s, "published" => published }
53
+ end
54
+ private_class_method :published_mapping
55
+
56
+ def integer(value, description)
57
+ Integer(value).tap do |port|
58
+ raise CLI::ConfigurationError, "Invalid #{description}: #{value.inspect}." unless CLI::PORT_RANGE.cover?(port)
59
+ end
60
+ rescue ArgumentError, TypeError
61
+ raise CLI::ConfigurationError, "Invalid #{description}: #{value.inspect}."
62
+ end
63
+ private_class_method :integer
64
+ end
65
+ end
@@ -98,6 +98,13 @@ module Brunch
98
98
  compose(entry, "logs", *arguments)
99
99
  end
100
100
 
101
+ def render_compose(entry)
102
+ output, error, status = Open3.capture3(environment(entry), *source_compose_command(entry, "config"), **compose_options(entry))
103
+ return [output, nil] if status.success?
104
+
105
+ [nil, error]
106
+ end
107
+
101
108
  def port_available?(port)
102
109
  ports, = Open3.capture2(*ports_command)
103
110
  return false if ports.match?(/:#{port}->/)
@@ -125,11 +132,19 @@ module Brunch
125
132
 
126
133
  def compose_command(entry, *arguments)
127
134
  directory = compose_directory(entry)
128
- compose_file = File.join(directory, entry.fetch("compose_file"))
135
+ compose_file = entry.fetch("rendered_compose", File.join(directory, entry.fetch("compose_file")))
129
136
  ["docker", "compose", "--project-name", entry.fetch("project"), "--project-directory", directory, "--file",
130
137
  compose_file, *arguments]
131
138
  end
132
139
 
140
+ def source_compose_command(entry, *arguments)
141
+ directory = entry.fetch("snapshot")
142
+ ["docker", "compose", "--project-name", entry.fetch("project"), "--project-directory", directory, "--file",
143
+ File.join(directory, entry.fetch("compose_file")), *arguments]
144
+ end
145
+
146
+ def compose_options(_entry) = {}
147
+
133
148
  def compose_directory(entry)
134
149
  source = entry.fetch("snapshot")
135
150
  File.file?(File.join(source, entry.fetch("compose_file"))) ? source : entry.fetch("control", source)
@@ -138,7 +153,7 @@ module Brunch
138
153
  def ports_command = ["docker", "ps", "--format", "{{.Ports}}"]
139
154
 
140
155
  def compose(entry, *arguments)
141
- system(environment(entry), *compose_command(entry, *arguments))
156
+ system(environment(entry), *compose_command(entry, *arguments), **compose_options(entry))
142
157
  end
143
158
  end
144
159
 
@@ -159,14 +174,21 @@ module Brunch
159
174
 
160
175
  def compose_command(entry, *arguments)
161
176
  directory = compose_directory(entry)
162
- compose_file = File.join(directory, entry.fetch("compose_file"))
177
+ compose_file = entry.fetch("rendered_compose", File.join(directory, entry.fetch("compose_file")))
163
178
  ["podman", "compose", "-p", entry.fetch("project"), "-f", compose_file, *arguments]
164
179
  end
165
180
 
181
+ def source_compose_command(entry, *arguments)
182
+ directory = entry.fetch("snapshot")
183
+ ["podman", "compose", "-p", entry.fetch("project"), "-f", File.join(directory, entry.fetch("compose_file")), *arguments]
184
+ end
185
+
186
+ def compose_options(entry) = { chdir: entry.fetch("snapshot") }
187
+
166
188
  def ports_command = ["podman", "ps", "--format", "{{.Ports}}"]
167
189
 
168
190
  def compose(entry, *arguments)
169
- system(environment(entry), *compose_command(entry, *arguments), chdir: compose_directory(entry))
191
+ system(environment(entry), *compose_command(entry, *arguments), **compose_options(entry))
170
192
  end
171
193
  end
172
194
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brunch
4
- VERSION = "0.9.2"
4
+ VERSION = "1.1.0"
5
5
  end
@@ -24,14 +24,16 @@ module Brunch
24
24
  in ["branch-settled", ref, token] then settle_deleted_branch(ref, token)
25
25
  in ["cleanup"] | ["register", _] then worktree_cleanup
26
26
  in ["status"] then worktree_status
27
- in ["list"] then worktree_ports
27
+ in ["list"] | ["ports"] then worktree_ports
28
28
  in ["watch"] then worktree_watch
29
29
  in ["port"] then worktree_port
30
+ in ["port", service, target] then worktree_port(service, target)
30
31
  in ["logs", *arguments] then worktree_logs(arguments)
31
32
  in ["run", "--", *command] if !command.empty? then worktree_run(command)
32
33
  in ["doctor"] then worktree_doctor
33
34
  else
34
- warn "Usage: brunch {activate|start <current-ref>|stop|restart|status|list|watch|port|logs [args]|run -- <cmd>|doctor|cleanup}"
35
+ warn "Usage: brunch {activate|start <current-ref>|stop|restart|status|list|ports|watch|" \
36
+ "port [service container-port]|logs [args]|run -- <cmd>|doctor|cleanup}"
35
37
  return 64
36
38
  end
37
39
  0
@@ -87,12 +89,72 @@ module Brunch
87
89
  end
88
90
 
89
91
  def worktree_port_for(id, data, manager, base: preferred_port)
90
- reserved = data.fetch("worktrees", {}).values.map { |record| record["port"] }
92
+ reserved = reserved_ports(data)
91
93
  (base..CLI::PORT_RANGE.end).chain(CLI::PORT_RANGE.begin...base).find do |port|
92
94
  !reserved.include?(port) && manager.port_available?(port)
93
95
  end || abort("No free port for worktree #{id}.")
94
96
  end
95
97
 
98
+ def reserved_ports(data)
99
+ assigned_ports(data).uniq
100
+ end
101
+
102
+ def assigned_ports(data)
103
+ data.fetch("worktrees", {}).values.flat_map do |record|
104
+ [record["port"], *Array(record["ports"]).map { |mapping| mapping["host"] }]
105
+ end.compact
106
+ end
107
+
108
+ def auto_ports?(config) = config.fetch("ports") == "auto"
109
+
110
+ def reserve_compose_ports(id, data, manager, mappings)
111
+ record = data.fetch("worktrees").fetch(id)
112
+ known = Array(record["ports"])
113
+ all_reserved = reserved_ports(data)
114
+ assignments = mappings.map do |mapping|
115
+ existing = known.find { |candidate| ComposePorts.key(candidate) == ComposePorts.key(mapping) }
116
+ next existing if existing
117
+
118
+ preferred = mapping.fetch("published")
119
+ host = if known.empty? && record["port"] == preferred
120
+ preferred
121
+ elsif !all_reserved.include?(preferred) && manager.port_available?(preferred)
122
+ preferred
123
+ else
124
+ worktree_port_for(id, data, manager, base: preferred)
125
+ end
126
+ assignment = mapping.merge("host" => host)
127
+ known << assignment
128
+ all_reserved << host
129
+ assignment
130
+ end
131
+ record["ports"] = known
132
+ record["port"] = assignments.first.fetch("host") unless assignments.empty?
133
+ assignments
134
+ end
135
+
136
+ def render_compose_ports(path, entry, manager)
137
+ rendered, error = manager.render_compose(entry)
138
+ abort "Could not render #{entry.fetch('compose_file')}: #{error.strip}" unless rendered
139
+
140
+ document = YAML.safe_load(rendered, permitted_classes: [], aliases: false)
141
+ mappings = ComposePorts.extract(document)
142
+ assignments = nil
143
+ with_state_lock do |data|
144
+ assignments = reserve_compose_ports(worktree_identity(path), data, manager, mappings)
145
+ end
146
+ ComposePorts.rewrite(document, assignments)
147
+ target = File.join(entry.fetch("control"), "brunch-compose.yaml")
148
+ FileUtils.mkdir_p(File.dirname(target))
149
+ File.write(target, YAML.dump(document))
150
+ entry["ports"] = assignments
151
+ entry["port"] = assignments.first.fetch("host") unless assignments.empty?
152
+ entry["rendered_compose"] = target
153
+ entry
154
+ rescue Psych::Exception => e
155
+ raise CLI::ConfigurationError, "Could not parse rendered Compose configuration: #{e.message}"
156
+ end
157
+
96
158
  def worktree_activate(path, force: false)
97
159
  path = File.realpath(path)
98
160
  id = worktree_identity(path)
@@ -130,16 +192,16 @@ module Brunch
130
192
  end
131
193
  if existing && !force && existing["snapshot"] == path && existing["status"] != "starting"
132
194
  with_state_lock { |data| data.fetch("worktrees").fetch(id)["current"] = ref }
133
- unless worktree_manager(existing).resume(existing)
134
- abort "Could not resume environment for #{path}; the container may have exited. Check brunch logs. " \
135
- "If dependencies changed, rebuild with brunch restart (this replaces the container)."
195
+ if worktree_manager(existing).resume(existing)
196
+ with_state_lock do |data|
197
+ data.fetch("worktrees").fetch(id).fetch("environments").fetch(ref)["status"] = "running"
198
+ end
199
+ puts "Resumed #{ref} in #{path} at http://127.0.0.1:#{existing.fetch('port')}"
200
+ return
136
201
  end
137
202
 
138
- with_state_lock do |data|
139
- data.fetch("worktrees").fetch(id).fetch("environments").fetch(ref)["status"] = "running"
140
- end
141
- puts "Resumed #{ref} in #{path} at http://127.0.0.1:#{existing.fetch('port')}"
142
- return
203
+ warn "Could not resume #{ref}; recreating its environment."
204
+ force = true
143
205
  end
144
206
  abort "Could not reset environment for #{path}." if existing && !worktree_manager(existing).reset(existing)
145
207
 
@@ -150,13 +212,16 @@ module Brunch
150
212
  if %w[docker_compose podman_compose].include?(config.fetch("manager"))
151
213
  source_file = File.join(path, compose_file)
152
214
  abort "Missing #{compose_file} in #{path}." unless File.file?(source_file)
153
- destination = File.join(control, compose_file)
154
- FileUtils.mkdir_p(File.dirname(destination))
155
- FileUtils.cp(source_file, destination)
215
+ unless auto_ports?(config)
216
+ destination = File.join(control, compose_file)
217
+ FileUtils.mkdir_p(File.dirname(destination))
218
+ FileUtils.cp(source_file, destination)
219
+ end
156
220
  end
157
221
  entry = { "source_type" => "worktree", "snapshot" => path, "control" => control,
158
222
  "ref" => ref, "head" => head, "port" => port, "project" => project,
159
223
  "compose_file" => compose_file, "configuration" => config, "status" => "starting" }
224
+ entry = render_compose_ports(path, entry, manager) if auto_ports?(config)
160
225
  with_state_lock do |data|
161
226
  record = data.fetch("worktrees").fetch(id)
162
227
  record.fetch("environments")[ref] = entry
@@ -167,7 +232,7 @@ module Brunch
167
232
  with_state_lock do |data|
168
233
  data.fetch("worktrees").fetch(id).fetch("environments")[ref] = entry.merge("status" => "running")
169
234
  end
170
- puts "Started #{ref} in #{path} at http://127.0.0.1:#{port}"
235
+ puts "Started #{ref} in #{path} at http://127.0.0.1:#{entry.fetch('port')}"
171
236
  end
172
237
  end
173
238
 
@@ -331,7 +396,7 @@ module Brunch
331
396
  renamed = renamed_branch(ref, nil, refs)
332
397
  next if renamed == :unknown
333
398
 
334
- renamed ? rename_branch_environments(ref, renamed) : remove_branch_environments(ref)
399
+ renamed ? rename_branch_environments(ref, renamed, replace_existing: true) : remove_branch_environments(ref)
335
400
  end
336
401
  end
337
402
 
@@ -405,7 +470,8 @@ module Brunch
405
470
  record, current = current_worktree_entry(data)
406
471
  return puts "No Brunch environment in this worktree." unless current
407
472
 
408
- puts "#{current.fetch('ref')} (#{record.fetch('path')}): #{worktree_manager(current).status(current)}, http://127.0.0.1:#{record.fetch('port')}"
473
+ puts "#{current.fetch('ref')} (#{record.fetch('path')}): #{worktree_manager(current).status(current)}, http://127.0.0.1:#{current.fetch('port')}"
474
+ print_port_mappings(current)
409
475
  end
410
476
 
411
477
  def worktree_ports
@@ -422,11 +488,11 @@ module Brunch
422
488
  prefix = index == branches.length - 1 ? " └─" : " ├─"
423
489
  running = ref == record["current"] && entry["status"] == "running"
424
490
  unless running
425
- puts color("#{prefix} ○ #{ref} #{entry.fetch('port')}", 90)
491
+ puts color("#{prefix} ○ #{ref} #{port_label(entry)}", 90)
426
492
  next
427
493
  end
428
494
 
429
- puts "#{prefix} #{color("● #{ref}", 32)} #{entry.fetch('port')}"
495
+ puts "#{prefix} #{color("● #{ref}", 32)} #{port_label(entry)}"
430
496
  end
431
497
  end
432
498
  end
@@ -445,11 +511,31 @@ module Brunch
445
511
  puts "\nStopped watching."
446
512
  end
447
513
 
448
- def worktree_port
449
- record, = current_worktree_entry(state)
450
- raise CLI::OperationError, "No Brunch environment in this worktree." unless record
514
+ def worktree_port(service = nil, target = nil)
515
+ _record, entry = current_worktree_entry(state)
516
+ raise CLI::OperationError, "No Brunch environment in this worktree." unless entry
517
+
518
+ return puts entry.fetch("port") unless service
519
+
520
+ mapping = Array(entry["ports"]).find do |candidate|
521
+ candidate["service"] == service && candidate["target"].to_s == target
522
+ end
523
+ raise CLI::OperationError, "No published port for #{service}:#{target}." unless mapping
451
524
 
452
- puts record.fetch("port")
525
+ puts mapping.fetch("host")
526
+ end
527
+
528
+ def port_label(entry)
529
+ mappings = Array(entry["ports"])
530
+ return entry.fetch("port").to_s if mappings.empty?
531
+
532
+ mappings.map { |mapping| "#{mapping.fetch('service')}:#{mapping.fetch('target')}/#{mapping.fetch('protocol')}→#{mapping.fetch('host')}" }.join(", ")
533
+ end
534
+
535
+ def print_port_mappings(entry)
536
+ Array(entry["ports"]).each do |mapping|
537
+ puts " #{mapping.fetch('service')}:#{mapping.fetch('target')}/#{mapping.fetch('protocol')} → 127.0.0.1:#{mapping.fetch('host')}"
538
+ end
453
539
  end
454
540
 
455
541
  def worktree_logs(arguments)
@@ -469,7 +555,7 @@ module Brunch
469
555
 
470
556
  def worktree_doctor
471
557
  data = state
472
- ports = data.fetch("worktrees", {}).values.map { |record| record["port"] }
558
+ ports = assigned_ports(data)
473
559
  paths = worktree_paths
474
560
  hooks_dir = git_output("rev-parse", "--git-path", "hooks")
475
561
  checks = {
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/compose_ports"
5
6
  require_relative "brunch/branch_settlement"
6
7
  require_relative "brunch/worktree_mode"
7
8
  require_relative "brunch/cli"
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.2
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maciej Ciemborowicz
@@ -94,6 +94,7 @@ files:
94
94
  - lib/brunch.rb
95
95
  - lib/brunch/branch_settlement.rb
96
96
  - lib/brunch/cli.rb
97
+ - lib/brunch/compose_ports.rb
97
98
  - lib/brunch/hooks.rb
98
99
  - lib/brunch/managers.rb
99
100
  - lib/brunch/version.rb