Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

### ✨ Features

- `edit` in `.worktree.yml` — alias for `replace` with two new entry shapes:
- `append: <line>` — unconditionally appends a line to the file
- `upsert: <pattern>` + `with: <line>` — replaces if pattern matches, otherwise appends
- MFA required to publish this gem

## 0.6.0
Expand Down
33 changes: 25 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ ports:
- PORT
- WEBPACK_PORT

replace:
edit:
mise.toml:
- "^PORT=.*": "PORT=$PORT"

Expand All @@ -97,31 +97,41 @@ setup: mise exec -- bin/setup
| `copy` | Files copied from main worktree before setup |
| `link` | Files symlinked from main worktree (useful for large directories like `node_modules`) |
| `ports` | Env var names — unique ports allocated from a global pool |
| `replace` | Regex replacements in files — env vars (`$VAR`) are expanded (see below) |
| `edit` | Edits applied to files: `replace`, `append`, `upsert` — env vars (`$VAR`) are expanded (see below). Aliased as `replace` for backwards compatibility. |
| `pre_setup` | Commands run before the setup command (env vars are available) |
| `setup` | The setup command to run (default: `bin/setup`) |

`bonchi create` auto-runs setup when `.worktree.yml` exists. Skip with `--no-setup`.

### Replace
### Edit

Use `replace` to do regex-based find-and-replace in files. Env vars (`$VAR`) are expanded in replacement values.
Use `edit` to modify files during setup. Three actions are available; entries run in order, so you can interleave them. Env vars (`$VAR`) are expanded in replacement values.

```yaml
replace:
# Short form
edit:
mise.toml:
# Replace — short form
- "^PORT=.*": "PORT=$PORT"
# Full form (with optional missing: warn, default: halt)

# Append a line unconditionally
- append: "FOO=bar"

# Replace if the regex matches, otherwise append — natural for env vars
- upsert: "^DATABASE_URL="
with: "DATABASE_URL=postgres:///myapp_$WORKTREE_BRANCH_SLUG"

.env.local:
# Replace — full form (with optional missing: warn, default: halt)
- match: "^DATABASE_URL=.*"
with: "DATABASE_URL=postgres:///myapp_$WORKTREE_BRANCH_SLUG"
missing: warn
```

`replace` works as an alias for `edit` (use one or the other, not both).

### Environment variables

The following env vars are available in `replace` values and `pre_setup` commands:
The following env vars are available in `edit` values and `pre_setup` commands:

| Variable | Example | Description |
|----------|---------|-------------|
Expand All @@ -132,6 +142,8 @@ The following env vars are available in `replace` values and `pre_setup` command
| `$WORKTREE_BRANCH_SLUG` | `feat_new_login` | Branch name with non-alphanumeric chars replaced by `_` |
| `$PORT`, ... | `4012` | Any port names listed under `ports` |

If a referenced env var is unset, setup aborts.

## Global config

Settings are stored in `~/.bonchi.yml` (or `$XDG_CONFIG_HOME/bonchi/config.yml`):
Expand Down Expand Up @@ -160,8 +172,13 @@ bin/setup # Make sure it exits with code 0

# Run tests
rake

# Run the CLI against the local working tree (from any cwd)
bin/bonchi-dev <command>
```

`bin/bonchi-dev` pins `BUNDLE_GEMFILE` to this project, so it uses the in-progress code instead of the installed `bonchi` gem — handy when testing changes from another directory.

Using [mise](https://mise.jdx.dev/) for env-vars is recommended.

### Releasing
Expand Down
8 changes: 8 additions & 0 deletions bin/bonchi-dev
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

ENV["BUNDLE_GEMFILE"] = File.expand_path("../Gemfile", __dir__)
require "bundler/setup"
require "bonchi"

Bonchi::CLI.start(ARGV)
20 changes: 12 additions & 8 deletions lib/bonchi/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -282,17 +282,21 @@ def extract_pr_number(input)
# ports:
# - PORT

# Regex replacements in copied files. Env vars ($VAR) are expanded.
# Short form:
# replace:
# Edits applied to files. Env vars ($VAR) are expanded.
# Three actions: regex replace, append, upsert (replace-or-append).
# edit:
# .env.local:
# # Replace — short form
# - "^PORT=.*": "PORT=$PORT"
# Full form (with optional missing: warn, default: halt):
# replace:
# .env.local:
# - match: "^PORT=.*"
# with: "PORT=$PORT"
# # Replace — full form (with optional missing: warn, default: halt)
# - match: "^DATABASE_URL=.*"
# with: "DATABASE_URL=postgres:///myapp_$WORKTREE_BRANCH_SLUG"
# missing: warn
# # Append a line unconditionally
# - append: "FOO=bar"
# # Replace if pattern matches, otherwise append
# - upsert: "^DEBUG="
# with: "DEBUG=1"

# Commands to run before the setup command (port env vars are available).
# pre_setup:
Expand Down
18 changes: 13 additions & 5 deletions lib/bonchi/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module Bonchi
class Config
include Colors

KNOWN_KEYS = %w[min_version copy link ports replace pre_setup setup].freeze
KNOWN_KEYS = %w[min_version copy link ports replace edit pre_setup setup].freeze

attr_reader :copy, :link, :ports, :replace, :pre_setup, :setup

Expand All @@ -16,10 +16,14 @@ def initialize(path)

check_min_version!(data["min_version"]) if data["min_version"]

if data.key?("replace") && data.key?("edit")
abort "#{color(:red)}Error:#{reset} both 'edit' and 'replace' set in .worktree.yml — use 'edit' (preferred)"
end

@copy = Array(data["copy"])
@link = Array(data["link"])
@ports = Array(data["ports"])
@replace = data["replace"] || {}
@replace = data["edit"] || data["replace"] || {}
@pre_setup = Array(data["pre_setup"])
@setup = data["setup"] || "bin/setup"

Expand Down Expand Up @@ -49,17 +53,21 @@ def check_min_version!(min_version)

def validate!
unless @replace.is_a?(Hash)
abort "#{color(:red)}Error:#{reset} 'replace' must be a mapping of filename to list of replacements"
abort "#{color(:red)}Error:#{reset} 'edit' must be a mapping of filename to list of edits"
end

@replace.each do |file, entries|
unless entries.is_a?(Array)
abort "#{color(:red)}Error:#{reset} 'replace.#{file}' must be a list of replacements"
abort "#{color(:red)}Error:#{reset} 'edit.#{file}' must be a list of edits"
end

entries.each do |entry|
unless entry.is_a?(Hash)
abort "#{color(:red)}Error:#{reset} each replacement in 'replace.#{file}' must be a mapping"
abort "#{color(:red)}Error:#{reset} each edit in 'edit.#{file}' must be a mapping"
end

if entry.key?("upsert") && !entry.key?("with")
abort "#{color(:red)}Error:#{reset} 'upsert' in 'edit.#{file}' requires a 'with' value"
end
end
end
Expand Down
81 changes: 54 additions & 27 deletions lib/bonchi/setup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -96,37 +96,64 @@ def replace_in_files(replacements)
abort "#{color(:red)}Error:#{reset} #{file} not found" unless File.exist?(path)

content = File.read(path)
entries.each do |entry|
if entry.is_a?(Hash) && entry.key?("match")
pattern = entry["match"]
replacement = entry["with"]
missing = entry["missing"] || "halt"
elsif entry.is_a?(Hash)
pattern, replacement = entry.first
missing = "halt"
else
abort "#{color(:red)}Error:#{reset} invalid replace entry in #{file}: #{entry.inspect}"
end

expanded = replacement.gsub(/\$(\w+)/) { ENV[$1] || abort("#{color(:red)}Error:#{reset} $#{$1} not set") }
regex = Regexp.new(pattern)

unless content.match?(regex)
if missing == "warn"
puts "#{color(:yellow)}Warning:#{reset} pattern #{pattern} not found in #{file}, skipping"
next
else
abort "#{color(:red)}Error:#{reset} pattern #{pattern} not found in #{file}"
end
end

content = content.gsub(regex, expanded)
puts "Replaced #{pattern} in #{file}"
end
entries.each { |entry| content = apply_edit(entry, content, file) }
File.write(path, content)
end
end

def apply_edit(entry, content, file)
unless entry.is_a?(Hash)
abort "#{color(:red)}Error:#{reset} invalid edit entry in #{file}: #{entry.inspect}"
end

if entry.key?("append")
line = expand(entry["append"])
puts "Appended to #{file}"
ensure_trailing_newline(content) + line + "\n"
elsif entry.key?("upsert")
unless entry.key?("with")
abort "#{color(:red)}Error:#{reset} 'upsert' requires 'with' in #{file}"
end
pattern = entry["upsert"]
replacement = expand(entry["with"])
regex = Regexp.new(pattern)
if content.match?(regex)
puts "Upserted #{pattern} in #{file} (matched)"
content.gsub(regex, replacement)
else
puts "Upserted #{pattern} in #{file} (appended)"
ensure_trailing_newline(content) + replacement + "\n"
end
elsif entry.key?("match")
replace(content, entry["match"], expand(entry["with"]), entry["missing"] || "halt", file)
else
pattern, replacement = entry.first
replace(content, pattern, expand(replacement), "halt", file)
end
end

def replace(content, pattern, replacement, missing, file)
regex = Regexp.new(pattern)
unless content.match?(regex)
if missing == "warn"
puts "#{color(:yellow)}Warning:#{reset} pattern #{pattern} not found in #{file}, skipping"
return content
else
abort "#{color(:red)}Error:#{reset} pattern #{pattern} not found in #{file}"
end
end
puts "Replaced #{pattern} in #{file}"
content.gsub(regex, replacement)
end

def expand(value)
value.to_s.gsub(/\$(\w+)/) { ENV[$1] || abort("#{color(:red)}Error:#{reset} $#{$1} not set") }
end

def ensure_trailing_newline(content)
content.empty? || content.end_with?("\n") ? content : content + "\n"
end

def run_pre_setup(commands)
commands.each do |cmd|
puts "Running: #{cmd}"
Expand Down
Loading
Loading