brunch 1.0.0 → 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: c9e410a84d1fd30ba484345f2027d5253ed9596f0b5f26efa1add8d2b863a750
4
- data.tar.gz: 75cf5154603bf83bf8a439f16a2489ff4bf5dda9ce9bb7dc6340e2591559458c
3
+ metadata.gz: bfbfbc36f9114715c3b656aeeb831010e8e75e1ccc15d8c7229813b4489ceac6
4
+ data.tar.gz: 6a98d0331e7d758bacafad88344dd92a0a105b60c06ead342d35fb928e36cc0f
5
5
  SHA512:
6
- metadata.gz: 7cfe235dfe3e43525d0a5fcaf6c8e9fa0e26d31816eec9dde3b57a2474b5b92624f069465a6b8f5b0be580ac97aeadccf44eb9441afeb54e99ea73f77d225316
7
- data.tar.gz: 909b5e6ebb1f6cb14395dc68636db0e24cb26fe24acfedc45def14dd37ea0989b0500e07d8362fb04b92361d3b6d90f954df3edfa783b72fe1f7edcc23f3c49e
6
+ metadata.gz: 64bbfe38e607cf1cf8cc1e9ce042e104653a4ac0958fdeca87ff6e40e4febf0f09e287d9c55f6cb7ce75c17a3062ad6edb1cd159f18407d3a52415192360c52b
7
+ data.tar.gz: ea493ddaa2e3431665a0a0a0d1c42f99a3013c9ab5ccafc98b170a98dfddd20188a7bd53dc6384f99a1469a5aba1b52298b1edd9881287e4d8c3365d246719c6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
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
+
3
11
  ## 1.0.0 - 2026-10-16
4
12
 
5
13
  - Recover automatically when a stopped Compose environment cannot resume: Brunch
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.
@@ -185,12 +223,12 @@ Then:
185
223
  cd ../feature-a
186
224
 
187
225
  brunch port
188
- brunch list
226
+ brunch ports
189
227
  ```
190
228
 
191
229
  `brunch port` prints the current worktree's port.
192
230
 
193
- `brunch list` groups the current and previously activated branches under each
231
+ `brunch ports` groups the current and previously activated branches under each
194
232
  worktree. Stopped branches appear in gray and retain their worktree's port for
195
233
  the next activation.
196
234
 
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 = "1.0.0"
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)
@@ -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
 
@@ -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
524
+
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?
451
531
 
452
- puts record.fetch("port")
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: 1.0.0
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