โฏ๏ธ Bash::Merge
if ci_badges.map(&:color).detect { it != "green"} โ๏ธ let me know on Discord or RubyForum, as I may have missed the notification.
if ci_badges.map(&:color).all? { it == "green"} ๐๏ธ send money so I can do more of this. FLOSS maintenance is now my full-time job.
๐ฃ How will this project approach the September 2025 hostile takeover of RubyGems? ๐๏ธ
I've summarized my thoughts in this blog post.
๐ป Synopsis

Bash::Merge intelligently merges two versions of a Bash script using the StructuredMerge Ruby stack. It is built on ast-merge and tree_haver, with Bash parsing supplied through the current tree-sitter language-pack backend.
Key Features
- Tree-Sitter Powered: Uses tree-sitter-bash for accurate AST parsing
- Script-Aware: Understands Bash syntax including functions, variables, and commands
- Intelligent: Matches functions and variable assignments by name
- Comment-Preserving: Comments are preserved in their context
- Shebang Handling: Properly handles
#!/bin/bashand similar shebangs - Freeze Block Support: Respects freeze markers (default:
bash-merge:freeze/bash-merge:unfreeze) for merge control - customizable to match your project's conventions - Full Provenance: Tracks origin of every node
- StructuredMerge Native: Depends on
ast-mergeandtree_haver; parser availability comes from the registered backend providers -
Customizable:
- `signature_generator` - callable custom signature generators - `preference` - setting of `:template`, `:destination`, or a Hash for per-node-type preferences - `node_splitter` - Hash mapping node types to callables for per-node-type merge customization (see [ast-merge][ast-merge] docs) - `add_template_only_nodes` - setting to retain nodes that do not exist in destination - `freeze_token` - customize freeze block markers (default: `"bash-merge"`)
Supported Node Types
| Node Type | Signature Format | Matching Behavior |
|---|---|---|
| Function Definition | [:function, name] |
Functions match by name |
| Variable Assignment | [:assignment, name] |
Variables match by name |
| Command | [:command, name, args...] |
Commands match by name and arguments |
| Comment | [:comment, text] |
Comments preserved in context |
| Pipeline | [:pipeline, commands...] |
Pipelines match by command sequence |
| If Statement | [:if, condition_sig] |
Conditionals match by condition signature |
| For Loop | [:for, variable] |
For loops match by loop variable |
| While Loop | [:while, condition_sig] |
While loops match by condition signature |
Example
require "bash/merge"
template = File.read("template.sh")
destination = File.read("destination.sh")
merger = Bash::Merge::SmartMerger.new(template, destination)
result = merger.merge
File.write("merged.sh", result.to_bash)
๐ก Info you can shake a stick at
| Tokens to Remember | |
|---|---|
| Works with MRI Ruby 4 | |
| Support & Community | |
| Source | |
| Documentation | |
| Compliance | |
| Style | |
| Maintainer ๐๏ธ | |
... ๐ |
Compatibility
Compatible with MRI Ruby 4.0.0+, and concordant releases of JRuby, and TruffleRuby.
CI workflows and Appraisals are generated for MRI Ruby 4.0.0+.
This test floor is configured by ruby.test_minimum in .kettle-jem.yml and
may be higher than the gem's runtime compatibility floor when legacy Rubies are
not practical for the current toolchain.
The amazing test matrix is powered by the kettle-dev stack.
How kettle-dev manages complexity in tests
| Gem | Source | Role | Total downloads |
|---|---|---|---|
| appraisal2 | GitHub | multi-dependency Appraisal matrix generation | |
| appraisal2-rubocop | GitHub | RuboCop Appraisal generator integration | |
| kettle-dev | GitHub | development, release, and CI workflow tooling | |
| kettle-jem | GitHub | Appraisals & CI workflow templates | |
| kettle-soup-cover | GitHub | SimpleCov coverage policy and reporting | |
| kettle-test | GitHub | standard test runner and coverage harness | |
| rubocop-lts | GitHub | Ruby-version-aware linting | |
| turbo_tests2 | GitHub | parallel test execution |
โจ Installation
Install the gem and add to the application's Gemfile by executing:
bundle add bash-merge
If bundler is not being used to manage dependencies, install the gem by executing:
gem install bash-merge
โ๏ธ Configuration
merger = Bash::Merge::SmartMerger.new(
template_content,
dest_content,
# Which version to prefer when nodes match
# :destination (default) - keep destination code
# :template - use template code
preference: :destination,
# Whether to add template-only nodes to the result
# false (default) - only include nodes that exist in destination
# true - include all template nodes (functions, variables, etc.)
add_template_only_nodes: false,
# Token for freeze block markers
# Default: "bash-merge"
# Looks for: # bash-merge:freeze / # bash-merge:unfreeze
freeze_token: "bash-merge",
# Custom signature generator (optional)
# Receives a node, returns a signature array or nil
signature_generator: ->(node) { [:function, node.name] if node.type == :function_definition },
)
๐ง Basic Usage
Simple Merge
require "bash/merge"
# Template defines the structure
template = <<~BASH
#!/bin/bash
# Configuration
APP_NAME="myapp"
DEBUG=false
# Main function
main() {
echo "Starting $APP_NAME"
setup
run
}
setup() {
echo "Setting up..."
}
run() {
echo "Running..."
}
main "$@"
BASH
# Destination has customizations
destination = <<~BASH
#!/bin/bash
# Configuration
APP_NAME="myapp-custom"
DEBUG=true
LOG_FILE="/var/log/myapp.log"
# Main function with custom logging
main() {
echo "Starting $APP_NAME" | tee -a "$LOG_FILE"
setup
run
}
setup() {
echo "Custom setup..."
}
main "$@"
BASH
merger = Bash::Merge::SmartMerger.new(template, destination)
result = merger.merge
puts result.to_bash
Using Freeze Blocks
Freeze blocks protect sections from being overwritten during merge:
#!/bin/bash
# Configuration
APP_NAME="myapp"
# bash-merge:freeze Custom credentials
DB_USER="production_user"
DB_PASS="super_secret_password"
API_KEY="my_production_api_key"
# bash-merge:unfreeze
# Standard functions
main() {
echo "Starting $APP_NAME"
}
main "$@"
Content between # bash-merge:freeze and # bash-merge:unfreeze markers is preserved from the destination file, regardless of what the template contains.
Adding Template-Only Nodes
merger = Bash::Merge::SmartMerger.new(
template,
destination,
add_template_only_nodes: true,
)
result = merger.merge
# Result includes functions/variables from template that don't exist in destination
๐ Security
See SECURITY.md.
๐ค Contributing
If you need some ideas of where to help, you could work on adding more code coverage, or if it is already ๐ฏ (see below) check issues or PRs, or use the gem and think about how it could be better.
We so if you make changes, remember to update it.
See CONTRIBUTING.md for more detailed instructions.
Code Coverage
Coverage service badges
๐ Versioning
This library follows for its public API where practical.
For most applications, prefer the Pessimistic Version Constraint with two digits of precision.
For example:
spec.add_dependency("bash-merge", "~> 7.0")
๐ Is "Platform Support" part of the public API? More details inside.
Dropping support for a platform can be a breaking change for affected users. If a release changes supported platforms, it should be called out clearly in the changelog and versioned with that impact in mind.
To get a better understanding of how SemVer is intended to work over a project's lifetime, read this article from the creator of SemVer:
See CHANGELOG.md for a list of releases.
๐ License
The gem is available under the following licenses: AGPL-3.0-only, PolyForm-Small-Business-1.0.0. See LICENSE.md for details.
If none of the available licenses suit your use case, please contact us to discuss a custom commercial license.
