diff --git a/CHANGELOG.md b/CHANGELOG.md index e06b30a..da1dd17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ### ✨ Features +- `edit` in `.worktree.yml` — alias for `replace` with two new entry shapes: + - `append: ` — unconditionally appends a line to the file + - `upsert: ` + `with: ` — replaces if pattern matches, otherwise appends - MFA required to publish this gem ## 0.6.0 diff --git a/README.md b/README.md index 5cf4127..f5286a0 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ ports: - PORT - WEBPACK_PORT -replace: +edit: mise.toml: - "^PORT=.*": "PORT=$PORT" @@ -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 | |----------|---------|-------------| @@ -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`): @@ -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 ``` +`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 diff --git a/bin/bonchi-dev b/bin/bonchi-dev new file mode 100755 index 0000000..e477bc6 --- /dev/null +++ b/bin/bonchi-dev @@ -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) diff --git a/lib/bonchi/cli.rb b/lib/bonchi/cli.rb index db86bb2..41323ba 100644 --- a/lib/bonchi/cli.rb +++ b/lib/bonchi/cli.rb @@ -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: diff --git a/lib/bonchi/config.rb b/lib/bonchi/config.rb index fafd245..bed74be 100644 --- a/lib/bonchi/config.rb +++ b/lib/bonchi/config.rb @@ -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 @@ -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" @@ -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 diff --git a/lib/bonchi/setup.rb b/lib/bonchi/setup.rb index b52589a..0ed3455 100644 --- a/lib/bonchi/setup.rb +++ b/lib/bonchi/setup.rb @@ -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}" diff --git a/test/test_setup.rb b/test/test_setup.rb new file mode 100644 index 0000000..92f8f47 --- /dev/null +++ b/test/test_setup.rb @@ -0,0 +1,144 @@ +require "minitest/autorun" +require "tmpdir" +require "fileutils" +require "bonchi" + +class TestSetupEdits < Minitest::Test + def setup + @tmpdir = Dir.mktmpdir + @setup = Bonchi::Setup.allocate + @setup.instance_variable_set(:@worktree, @tmpdir) + end + + def teardown + FileUtils.remove_entry(@tmpdir) + end + + def write(file, content) + path = File.join(@tmpdir, file) + File.write(path, content) + path + end + + def read(file) + File.read(File.join(@tmpdir, file)) + end + + def apply(file, entries) + @setup.send(:replace_in_files, {file => entries}) + end + + def silenced + out = $stdout + $stdout = StringIO.new + yield + ensure + $stdout = out + end + + # ----- existing replace behavior ----- + + def test_short_form_replace + write("f", "PORT=1234\n") + silenced { apply("f", [{"^PORT=.*" => "PORT=4000"}]) } + assert_equal "PORT=4000\n", read("f") + end + + def test_full_form_replace_with_env + ENV["WORKTREE_BRANCH_SLUG"] = "feat_x" + write("f", "DATABASE_URL=postgres:///old\n") + silenced { apply("f", [{"match" => "^DATABASE_URL=.*", "with" => "DATABASE_URL=postgres:///app_$WORKTREE_BRANCH_SLUG"}]) } + assert_equal "DATABASE_URL=postgres:///app_feat_x\n", read("f") + ensure + ENV.delete("WORKTREE_BRANCH_SLUG") + end + + def test_replace_missing_warn + write("f", "hello\n") + silenced { apply("f", [{"match" => "^NOPE", "with" => "x", "missing" => "warn"}]) } + assert_equal "hello\n", read("f") + end + + def test_replace_missing_halts + write("f", "hello\n") + assert_raises(SystemExit) do + silenced { apply("f", [{"match" => "^NOPE", "with" => "x"}]) } + end + end + + # ----- append ----- + + def test_append_adds_line + write("f", "FOO=bar\n") + silenced { apply("f", [{"append" => "BAZ=qux"}]) } + assert_equal "FOO=bar\nBAZ=qux\n", read("f") + end + + def test_append_normalizes_missing_trailing_newline + write("f", "FOO=bar") + silenced { apply("f", [{"append" => "BAZ=qux"}]) } + assert_equal "FOO=bar\nBAZ=qux\n", read("f") + end + + def test_append_expands_env_vars + ENV["WORKTREE_BRANCH"] = "feat/x" + write("f", "") + silenced { apply("f", [{"append" => "BRANCH=$WORKTREE_BRANCH"}]) } + assert_equal "BRANCH=feat/x\n", read("f") + ensure + ENV.delete("WORKTREE_BRANCH") + end + + def test_append_aborts_on_missing_env_var + write("f", "") + assert_raises(SystemExit) do + silenced { apply("f", [{"append" => "X=$NOPE_NOT_SET_ANYWHERE"}]) } + end + end + + # ----- upsert ----- + + def test_upsert_replaces_when_pattern_matches + write("f", "FOO=old\nBAR=keep\n") + silenced { apply("f", [{"upsert" => "^FOO=.*", "with" => "FOO=new"}]) } + assert_equal "FOO=new\nBAR=keep\n", read("f") + end + + def test_upsert_appends_when_pattern_missing + write("f", "BAR=keep\n") + silenced { apply("f", [{"upsert" => "^FOO=", "with" => "FOO=new"}]) } + assert_equal "BAR=keep\nFOO=new\n", read("f") + end + + def test_upsert_appended_value_normalizes_trailing_newline + write("f", "BAR=keep") + silenced { apply("f", [{"upsert" => "^FOO=", "with" => "FOO=new"}]) } + assert_equal "BAR=keep\nFOO=new\n", read("f") + end + + def test_upsert_without_with_aborts + write("f", "x\n") + assert_raises(SystemExit) do + silenced { apply("f", [{"upsert" => "^FOO="}]) } + end + end + + # ----- mixed list ----- + + def test_mixed_entries_run_in_order + write("f", "PORT=1234\n") + entries = [ + {"^PORT=.*" => "PORT=4000"}, + {"append" => "FOO=bar"}, + {"upsert" => "^FOO=.*", "with" => "FOO=baz"} + ] + silenced { apply("f", entries) } + assert_equal "PORT=4000\nFOO=baz\n", read("f") + end + + def test_missing_file_aborts + assert_raises(SystemExit) do + silenced { apply("nope", [{"append" => "x"}]) } + end + end +end