Repository: Homebrew/homebrew-bundle
Pull request #1632: Implement `brew bundle add`
Author: login: carlocab
Created: 2025-03-05T14:24:50Z | Merged: 2025-03-07T13:53:28Z

Status: Using `brew bundle add` basically works. It now needs:
- ~lots of polishing~
- inclusion in `brew help bundle`
- ~tests~

Part of #818



Diff:
diff --git a/cmd/bundle.rb b/cmd/bundle.rb
index 147cac2609..3b3835c48f 100755
--- a/cmd/bundle.rb
+++ b/cmd/bundle.rb
@@ -136,7 +136,7 @@ def run
         require_relative "../lib/bundle"
 
         subcommand = args.named.first.presence
-        if subcommand != "exec" && args.named.size > 1
+        if ["exec", "add"].exclude?(subcommand) && args.named.size > 1
           raise UsageError, "This command does not take more than 1 subcommand argument."
         end
 
@@ -240,6 +240,20 @@ def run
             whalebrew: args.whalebrew? || args.all?,
             vscode:    args.vscode? || args.all?,
           )
+        when "add"
+          # We intentionally omit the `s` from `brews` and `casks` for ease of handling later.
+          type_hash = {
+            brew:      args.brews? || no_type_args,
+            cask:      args.casks?,
+            mas:       args.mas?,
+            whalebrew: args.whalebrew?,
+            vscode:    args.vscode?,
+          }
+          selected_types = type_hash.select { |_, v| v }.keys
+          raise UsageError, "`add` supports only one type of entry at a time." if selected_types.count > 1
+
+          _subcommand, *named_args = args.named
+          Bundle::Commands::Add.run(*named_args, type: selected_types.first, global:, file:)
         else
           raise UsageError, "unknown subcommand: #{subcommand}"
         end
diff --git a/lib/bundle.rb b/lib/bundle.rb
index 9eecf6fca5..11d73be682 100644
--- a/lib/bundle.rb
+++ b/lib/bundle.rb
@@ -6,6 +6,7 @@
 require "bundle/brewfile"
 require "bundle/bundle"
 require "bundle/dsl"
+require "bundle/adder"
 require "bundle/checker"
 require "bundle/skipper"
 require "bundle/brew_services"
@@ -31,6 +32,7 @@
 require "bundle/commands/check"
 require "bundle/commands/exec"
 require "bundle/commands/list"
+require "bundle/commands/add"
 require "bundle/whalebrew_installer"
 require "bundle/whalebrew_dumper"
 require "bundle/vscode_extension_checker"
diff --git a/lib/bundle/adder.rb b/lib/bundle/adder.rb
new file mode 100644
index 0000000000..26be7e007f
--- /dev/null
+++ b/lib/bundle/adder.rb
@@ -0,0 +1,29 @@
+# typed: true
+# frozen_string_literal: true
+
+module Bundle
+  module Adder
+    module_function
+
+    def add(*args, type:, global:, file:)
+      brewfile = Brewfile.read(global:, file:)
+      content = brewfile.input
+      # TODO: - support `:describe`
+      new_content = args.map do |arg|
+        case type
+        when :brew
+          Formulary.factory(arg)
+        when :cask
+          Cask::CaskLoader.load(arg)
+        end
+
+        "#{type} \"#{arg}\""
+      end
+
+      content << new_content.join("\n") << "\n"
+      path = Dumper.brewfile_path(global:, file:)
+
+      Dumper.write_file path, content
+    end
+  end
+end
diff --git a/lib/bundle/commands/add.rb b/lib/bundle/commands/add.rb
new file mode 100644
index 0000000000..497468f904
--- /dev/null
+++ b/lib/bundle/commands/add.rb
@@ -0,0 +1,14 @@
+# typed: true
+# frozen_string_literal: true
+
+module Bundle
+  module Commands
+    module Add
+      module_function
+
+      def run(*args, type:, global:, file:)
+        Bundle::Adder.add(*args, type:, global:, file:)
+      end
+    end
+  end
+end
diff --git a/lib/bundle/dsl.rb b/lib/bundle/dsl.rb
index c7fcb96d99..cfab925c1c 100644
--- a/lib/bundle/dsl.rb
+++ b/lib/bundle/dsl.rb
@@ -17,7 +17,7 @@ def to_s
       end
     end
 
-    attr_reader :entries, :cask_arguments
+    attr_reader :entries, :cask_arguments, :input
 
     def initialize(path)
       @path = path
diff --git a/spec/bundle/commands/add_command_spec.rb b/spec/bundle/commands/add_command_spec.rb
new file mode 100644
index 0000000000..36cac59519
--- /dev/null
+++ b/spec/bundle/commands/add_command_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require "cask/cask_loader"
+
+describe Bundle::Commands::Add do
+  subject(:add) do
+    described_class.run(*args, type:, global:, file:)
+  end
+
+  before { FileUtils.touch file }
+  after { FileUtils.rm_f file }
+
+  let(:global) { false }
+
+  context "when called with a valid formula" do
+    let(:args) { ["hello"] }
+    let(:type) { :brew }
+    let(:file) { "/tmp/some_random_brewfile#{Random.rand(2 ** 16)}" }
+
+    it "adds entries to the given Brewfile" do
+      expect { add }.not_to raise_error
+      expect(File.read(file)).to include("#{type} \"#{args.first}\"")
+    end
+  end
+
+  context "when called with a valid cask" do
+    let(:args) { ["alacritty"] }
+    let(:type) { :cask }
+    let(:file) { "/tmp/some_random_brewfile#{Random.rand(2 ** 16)}" }
+
+    it "adds entries to the given Brewfile" do
+      expect { add }.not_to raise_error
+      expect(File.read(file)).to include("#{type} \"#{args.first}\"")
+    end
+  end
+end
diff --git a/spec/stub/cask/cask_loader.rb b/spec/stub/cask/cask_loader.rb
new file mode 100644
index 0000000000..38440d4320
--- /dev/null
+++ b/spec/stub/cask/cask_loader.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Cask
+  module CaskLoader
+    def self.load(*); end
+  end
+end


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## PR Overview

This PR implements the new "brew bundle add" functionality to allow users to add new entries to their Brewfile. Key changes include:
- Introduction of the Bundle::Adder module to provide the core adding functionality.
- Creation of Bundle::Commands::Add as a command interface to invoke the add functionality.
- Updates in the CLI (cmd/bundle.rb) to support the "add" subcommand and its corresponding type handling.
- Minor update to the DSL to expose an "input" attribute, and inclusion of the new modules in the main bundle file.

### Reviewed Changes

| File                        | Description                                                    |
| --------------------------- | -------------------------------------------------------------- |
| lib/bundle/adder.rb         | Adds the core implementation for handling the "add" command.   |
| lib/bundle/commands/add.rb  | Provides a command wrapper for invoking the adder functionality. |
| cmd/bundle.rb               | Updates CLI logic to handle the "add" subcommand and type selection. |
| lib/bundle/dsl.rb           | Updates DSL attr_reader to expose "input" for Brewfile handling.  |
| lib/bundle.rb               | Requires new adder and add command modules for integration.    |

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.



state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T07:09:46Z
user: login: MikeMcQuaid
body: Makes sense so far! As mentioned, may make sense to parse the values from the Brewfile, add whatever package you're adding and then dump back in the same format.

Alternatively, could have `add` just always add on a new line at the end, if that's easier. One of these will mess with `brew bundle dump` Brewfiles and the other will mess with hand-crafted ones so it's a compromise either way 😁 
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-06T09:11:07Z
user: login: MikeMcQuaid
body: Thanks @carlocab! Can merge as-is or after `mas` etc. are added.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-07T12:28:30Z
user: login: carlocab
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-07T13:14:59Z
user: login: MikeMcQuaid
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-07T14:47:44Z
user: login: carlocab
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-07T15:19:16Z
user: login: MikeMcQuaid
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-07T16:25:00Z

Review comments:
diff_hunk: @@ -8,10 +8,19 @@ module Adder
     def add(*args, type:, global:, file:)
       brewfile = Brewfile.read(global:, file:)
       content = brewfile.input
-      # TODO: - validate formulae and casks
-      #       - support `:describe`
-      content << args.map { |arg| "#{type} \"#{arg}\"" }
-                     .join("\n") << "\n"
+      # TODO: - support `:describe`
+      new_content = args.map do |arg|
+        case type
+        when :brew
+          Formulary.factory(arg)
+        when :cask
+          Cask::CaskLoader.load(arg)
path: lib/bundle/adder.rb
user: login: MikeMcQuaid
body: Would be good to support `mas` etc. if not too heavy a lift?
created_at: 2025-03-07T12:27:29Z
author_association: MEMBER
diff_hunk: @@ -8,10 +8,19 @@ module Adder
     def add(*args, type:, global:, file:)
       brewfile = Brewfile.read(global:, file:)
       content = brewfile.input
-      # TODO: - validate formulae and casks
-      #       - support `:describe`
-      content << args.map { |arg| "#{type} \"#{arg}\"" }
-                     .join("\n") << "\n"
+      # TODO: - support `:describe`
+      new_content = args.map do |arg|
+        case type
+        when :brew
+          Formulary.factory(arg)
+        when :cask
+          Cask::CaskLoader.load(arg)
path: lib/bundle/adder.rb
user: login: carlocab
body: Doing
```
brew bundle add --mas foo
```
works. We just don't validate the entry like we do for formulae and casks.
created_at: 2025-03-07T13:14:59Z
author_association: MEMBER
diff_hunk: @@ -8,10 +8,19 @@ module Adder
     def add(*args, type:, global:, file:)
       brewfile = Brewfile.read(global:, file:)
       content = brewfile.input
-      # TODO: - validate formulae and casks
-      #       - support `:describe`
-      content << args.map { |arg| "#{type} \"#{arg}\"" }
-                     .join("\n") << "\n"
+      # TODO: - support `:describe`
+      new_content = args.map do |arg|
+        case type
+        when :brew
+          Formulary.factory(arg)
+        when :cask
+          Cask::CaskLoader.load(arg)
path: lib/bundle/adder.rb
user: login: MikeMcQuaid
body: @carlocab Ah, gotcha, thanks! Might be worth noting that in a follow-up as a Ruby comment.
created_at: 2025-03-07T14:47:44Z
author_association: MEMBER
diff_hunk: @@ -8,10 +8,19 @@ module Adder
     def add(*args, type:, global:, file:)
       brewfile = Brewfile.read(global:, file:)
       content = brewfile.input
-      # TODO: - validate formulae and casks
-      #       - support `:describe`
-      content << args.map { |arg| "#{type} \"#{arg}\"" }
-                     .join("\n") << "\n"
+      # TODO: - support `:describe`
+      new_content = args.map do |arg|
+        case type
+        when :brew
+          Formulary.factory(arg)
+        when :cask
+          Cask::CaskLoader.load(arg)
path: lib/bundle/adder.rb
user: login: carlocab
body: Ah, whoops. I lied about `mas` 😅

It requires an `id` to be included in the entry. `whalebrew` and `vscode` should work fine though.

Let me think a bit more about how to handle `mas` better.
created_at: 2025-03-07T15:19:15Z
author_association: MEMBER
diff_hunk: @@ -8,10 +8,19 @@ module Adder
     def add(*args, type:, global:, file:)
       brewfile = Brewfile.read(global:, file:)
       content = brewfile.input
-      # TODO: - validate formulae and casks
-      #       - support `:describe`
-      content << args.map { |arg| "#{type} \"#{arg}\"" }
-                     .join("\n") << "\n"
+      # TODO: - support `:describe`
+      new_content = args.map do |arg|
+        case type
+        when :brew
+          Formulary.factory(arg)
+        when :cask
+          Cask::CaskLoader.load(arg)
path: lib/bundle/adder.rb
user: login: MikeMcQuaid
body: @carlocab Yeh, `mas` may be tough; would be reasonable to just not support it IMO.
created_at: 2025-03-07T16:25:00Z
author_association: MEMBER

PR comments:
user: login: carlocab
created_at: 2025-03-06T12:35:32Z
body: > Alternatively, could have `add` just always add on a new line at the end, if that's easier. One of these will mess with `brew bundle dump` Brewfiles and the other will mess with hand-crafted ones so it's a compromise either way 😁 

I think I'm coming around to adding it at the end. If you're doing `brew bundle add`, chances are that your `Brewfile` was hand-crafted to begin with. And adding it at the end is _much_ simpler.

I'll mull it over a bit more.
author_association: MEMBER
user: login: MikeMcQuaid
created_at: 2025-03-06T12:41:53Z
body: > I think I'm coming around to adding it at the end. If you're doing `brew bundle add`, chances are that your `Brewfile` was hand-crafted to begin with. And adding it at the end is _much_ simpler.

True. I definitely think for a v1 putting it at the end is completely acceptable until we get requests to do otherwise.
author_association: MEMBER
user: login: carlocab
created_at: 2025-03-07T12:19:14Z
body: > True. I definitely think for a v1 putting it at the end is completely acceptable until we get requests to do otherwise.

That's fair. I think it might make more sense to add a `brew bundle format` subcommand to format a `Brewfile` similarly to `brew bundle dump`. Then `brew bundle add` could support a `--format` flag that formats the `Brewfile` after adding the requested entries.

This is working now.

I've left out the documentation for now until I've implemented `brew bundle remove`, which I plan to do in a separate PR to keep this one small.
author_association: MEMBER