From e5f6f3054787227eca0b80225f9cc5948476de51 Mon Sep 17 00:00:00 2001 From: Neil Carvalho Date: Mon, 25 May 2026 10:55:20 -0300 Subject: [PATCH 1/2] Commit Rails app in the integration test --- .github/workflows/integration.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 83b68fa..05935bd 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -34,6 +34,12 @@ jobs: --skip-bootsnap \ --skip-test \ --quiet + cd /tmp/test-app + git init + git add . + git config user.email "test@example.com" + git config user.name "Integration Test" + git commit -m "Initial commit" - name: Pin outdated packages run: | From 8f7cc35d5908875abc7ad7598648a408fe0d3af5 Mon Sep 17 00:00:00 2001 From: Neil Carvalho Date: Mon, 25 May 2026 10:42:14 -0300 Subject: [PATCH 2/2] Replace manual git shell-outs with ruby-git Similarly to how this action used `gh` for GitHub interactions, it also runs the `git` CLI for Git interactions. This commit replaces that usage for the `ruby-git` gem. The `git` CLI usage wasn't problematic per se, since `ruby-git` just wraps the CLI, but using it avoids a class of problems when creating commit messages that need to be escaped. --- Gemfile | 1 + Gemfile.lock | 47 ++++++++++++++++++++- exe/importmap-update | 6 ++- lib/commands.rb | 16 +++---- lib/executor.rb | 3 +- lib/git_client.rb | 58 ++++++++++++------------- test/commands_test.rb | 59 +++++++++++++------------- test/git_client_test.rb | 94 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 212 insertions(+), 72 deletions(-) create mode 100644 test/git_client_test.rb diff --git a/Gemfile b/Gemfile index c25b3cb..7af7bf9 100644 --- a/Gemfile +++ b/Gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" ruby ">= 3.2" gem "octokit" +gem "git", "~> 4.3" group :development, :test do gem "rake" diff --git a/Gemfile.lock b/Gemfile.lock index 27a331b..8a50fac 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,26 @@ GEM remote: https://rubygems.org/ specs: + activesupport (8.1.3) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) + base64 (0.3.0) + bigdecimal (4.1.2) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) drb (2.2.3) faraday (2.14.2) faraday-net_http (>= 2.0, < 3.5) @@ -11,6 +28,13 @@ GEM logger faraday-net_http (3.4.2) net-http (~> 0.5) + git (4.3.2) + activesupport (>= 5.0) + addressable (~> 2.8) + process_executer (~> 4.0) + rchardet (~> 1.9) + i18n (1.14.8) + concurrent-ruby (~> 1.0) json (2.19.5) language_server-protocol (3.17.0.5) lint_roller (1.1.0) @@ -29,10 +53,13 @@ GEM ast (~> 2.4.1) racc prism (1.9.0) + process_executer (4.0.4) + track_open_instances (~> 0.1) public_suffix (7.0.5) racc (1.8.1) rainbow (3.1.1) rake (13.4.2) + rchardet (1.10.0) regexp_parser (2.12.0) rubocop (1.84.2) json (~> 2.3) @@ -56,6 +83,7 @@ GEM sawyer (0.9.3) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) + securerandom (0.4.1) standard (1.54.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) @@ -70,6 +98,9 @@ GEM rubocop-performance (~> 1.26.0) standardrb (1.0.1) standard + track_open_instances (0.1.15) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) @@ -80,6 +111,7 @@ PLATFORMS ruby DEPENDENCIES + git (~> 4.3) minitest minitest-mock (~> 5.27) octokit @@ -87,11 +119,19 @@ DEPENDENCIES standardrb CHECKSUMS + activesupport (8.1.3) sha256=21a5e0dfbd4c3ddd9e1317ec6a4d782fa226e7867dc70b0743acda81a1dca20e addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bundler (4.0.12) sha256=7f8b757d28dfb636e7b24fba2344ac6dd13b5b24f4b46d62573d483f211825ac + concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 faraday (2.14.2) sha256=73ccb9994a9e8648f010e32eca2ae82e41c57860aa10932cda29418b9e0223ad faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c + git (4.3.2) sha256=a3b0706573bb8cdd9edc630c33e2611ca5cbdece086f7c142bef73bc38522907 + i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 json (2.19.5) sha256=218a18553e4801d579ca7e0f5bc72bafd776d7397238a1fb4e74db5b0a812c59 language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 @@ -103,20 +143,25 @@ CHECKSUMS parallel (1.28.0) sha256=33e6de1484baf2524792d178b0913fc8eb94c628d6cfe45599ad4458c638c970 parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + process_executer (4.0.4) sha256=6c179bd31b876e220e7a1ed30c8f8ee7333b9b5b4b8c301474b947b707c3ba6f public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rchardet (1.10.0) sha256=d5ea2ed61a720a220f1914778208e718a0c7ed2a484b6d357ba695aa7001390f regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb rubocop (1.84.2) sha256=5692cea54168f3dc8cb79a6fe95c5424b7ea893c707ad7a4307b0585e88dbf5f rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 sawyer (0.9.3) sha256=0d0f19298408047037638639fe62f4794483fb04320269169bd41af2bdcf5e41 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 standard (1.54.0) sha256=7a4b08f83d9893083c8f03bc486f0feeb6a84d48233b40829c03ef4767ea0100 standard-custom (1.0.2) sha256=424adc84179a074f1a2a309bb9cf7cd6bfdb2b6541f20c6bf9436c0ba22a652b standard-performance (1.9.0) sha256=49483d31be448292951d80e5e67cdcb576c2502103c7b40aec6f1b6e9c88e3f2 standardrb (1.0.1) sha256=7a1328be429f4e97a97e357e2446f3509e80164a59ff00bc6a4daa78e3351f2c + track_open_instances (0.1.15) sha256=7f0e48821e6b4c881daaa40fb1583e308937c22a9c84883c150b399c3b5c3029 + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 @@ -125,4 +170,4 @@ RUBY VERSION ruby 4.0.1 BUNDLED WITH - 4.0.7 + 4.0.12 diff --git a/exe/importmap-update b/exe/importmap-update index 00d0bf4..6a2d151 100755 --- a/exe/importmap-update +++ b/exe/importmap-update @@ -27,6 +27,7 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__) require "optparse" require "yaml" +require "git" require "config" require "planner" require "reconciler" @@ -162,10 +163,11 @@ when :run exit 2 end - runner = Importmap::Update::Commands::ShellRunner.new(cwd: ENV.fetch("RAILS_ROOT", ".")) + rails_root = ENV.fetch("RAILS_ROOT", ".") + runner = Importmap::Update::Commands::ShellRunner.new(cwd: rails_root) gh = Importmap::Update::GitHubClient.new(repo: repo, token: token) git = Importmap::Update::GitClient.new( - runner: runner, + repo: Git.open(rails_root), author_name: ENV.fetch("IMPORTMAP_AUTHOR_NAME", "github-actions[bot]"), author_email: ENV.fetch("IMPORTMAP_AUTHOR_EMAIL", "github-actions[bot]@users.noreply.github.com") ) diff --git a/lib/commands.rb b/lib/commands.rb index c33534d..d6c754b 100644 --- a/lib/commands.rb +++ b/lib/commands.rb @@ -5,14 +5,14 @@ module Importmap module Update - # Abstracts execution of external commands (gh, git, bin/importmap) so - # the rest of the codebase doesn't shell out directly. This is the seam - # tests hook into — production code runs commands for real, tests inject - # a FixtureRunner that replays pre-recorded (argv → stdout, exit) tuples. + # Abstracts execution of external commands (bin/importmap) so the rest + # of the codebase doesn't shell out directly. This is the seam tests hook + # into — production code runs commands for real, tests inject a + # FixtureRunner that replays pre-recorded (argv → stdout, exit) tuples. # # The interface deliberately mirrors what Open3.capture3 returns: # - # runner.run("gh", "pr", "list", "--state", "open") + # runner.run("bin/importmap", "outdated") # # => Result(stdout: "...", stderr: "...", success: true, exit: 0) # # Commands are passed as an argv array, not a shell string. That's both @@ -38,17 +38,15 @@ def initialize(argv, result) # Production runner: actually executes the command. class ShellRunner # @param cwd [String, nil] working directory (defaults to current) - # @param env [Hash, nil] additional environment variables - def initialize(cwd: nil, env: nil) + def initialize(cwd: nil) @cwd = cwd - @env = env || {} end def run(*argv) opts = {} opts[:chdir] = @cwd if @cwd Bundler.with_unbundled_env do - stdout, stderr, status = Open3.capture3(@env, *argv, opts) + stdout, stderr, status = Open3.capture3(*argv, opts) Result.new(stdout: stdout, stderr: stderr, exit_code: status.exitstatus) end end diff --git a/lib/executor.rb b/lib/executor.rb index 3017fd4..046229f 100644 --- a/lib/executor.rb +++ b/lib/executor.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "git" require_relative "commands" require_relative "github_client" require_relative "git_client" @@ -75,7 +76,7 @@ def call(actions) else Outcome.new(type: action.type, status: :failed, detail: "Unknown action type") end - rescue Commands::CommandError => e + rescue Commands::CommandError, Git::FailedError => e warnings << "#{action.type} on #{describe(action)}: #{e.message}" Outcome.new( type: action.type, status: :failed, diff --git a/lib/git_client.rb b/lib/git_client.rb index 9b9cbe9..b443050 100644 --- a/lib/git_client.rb +++ b/lib/git_client.rb @@ -1,53 +1,53 @@ # frozen_string_literal: true -require_relative "commands" +require "git" module Importmap module Update # Git operations the executor needs: creating/resetting a branch from - # base, committing changes, pushing (with optional --force for the - # force_push action). Every command runs through the injected runner - # so tests can replay fixtures the same way they do for gh. + # base, committing changes, and pushing. Every method delegates to an + # injected Git::Base repo object (or a Minitest::Mock in tests) so the + # class does no orchestration — that's the executor's job. class GitClient - def initialize(author_name:, author_email:, runner: Commands::ShellRunner.new) - @runner = runner + def initialize(repo:, author_name:, author_email:) + @repo = repo @author_name = author_name @author_email = author_email end # Resets the working tree to base and creates/switches to `branch`. - # If `branch` already exists locally (e.g. from a previous run on - # the same worker), it's force-reset to base so we start from a - # known state. This is destructive of local state by design — the - # action runs in CI where there's no "uncommitted work to save". + # If `branch` already exists locally it is checked out and hard-reset + # to origin/base, mirroring `git checkout -B branch origin/base`. + # This is destructive of local state by design — the action runs in + # CI where there is no uncommitted work to save. def checkout_fresh_branch(branch:, base:) - @runner.run!("git", "fetch", "origin", base) - @runner.run!("git", "checkout", "-B", branch, "origin/#{base}") + @repo.fetch("origin", ref: base) + begin + @repo.checkout(branch) + rescue Git::Error + # Branch does not exist yet — create it at origin/base and stop. + @repo.checkout(branch, new_branch: true, start_point: "origin/#{base}") + return nil + end + # Branch existed; reset it to origin/base from a clean state. + @repo.reset_hard("origin/#{base}") nil end - # Stages all changes and commits with the given message. Returns true - # if a commit was actually created, false if there was nothing to - # commit (which usually means `bin/importmap pin` was a no-op). + # Stages the importmap and vendored JS files and commits them. + # Returns true iff a commit was actually created; false when + # bin/importmap pin was a no-op and there is nothing to commit. def commit_changes(message:) - @runner.run!("git", "add", "config/importmap.rb", "vendor/javascript") - # `git diff --cached --quiet` exits 0 if there are no staged changes. - diff = @runner.run("git", "diff", "--cached", "--quiet") - return false if diff.success? - - @runner.run!( - "git", - "-c", "user.name=#{@author_name}", - "-c", "user.email=#{@author_email}", - "commit", "-m", message - ) + @repo.add(["config/importmap.rb", "vendor/javascript"]) + @repo.commit(message, author: "#{@author_name} <#{@author_email}>") true + rescue Git::FailedError => e + return false if e.result.stderr.to_s.include?("nothing to commit") + raise end def push(branch:, force: false) - argv = ["git", "push", "origin", "#{branch}:#{branch}"] - argv.push("--force") if force - @runner.run!(*argv) + @repo.push("origin", branch, force: force) nil end end diff --git a/test/commands_test.rb b/test/commands_test.rb index 4a7c770..259b8af 100644 --- a/test/commands_test.rb +++ b/test/commands_test.rb @@ -45,23 +45,23 @@ def test_shell_runner_argv_is_safe_from_shell_metacharacters def test_fixture_runner_returns_recorded_result_for_exact_argv_match runner = Commands::FixtureRunner.new runner.add( - pattern: ["gh", "pr", "list", "--state", "open"], - stdout: "[]\n" + pattern: ["bin/importmap", "outdated"], + stdout: "| Package | Current | Latest |\n" ) - result = runner.run("gh", "pr", "list", "--state", "open") - assert_equal "[]\n", result.stdout + result = runner.run("bin/importmap", "outdated") + assert_equal "| Package | Current | Latest |\n", result.stdout assert_predicate result, :success? end def test_fixture_runner_records_calls_in_order runner = Commands::FixtureRunner.new - runner.add(pattern: ["gh", "auth", "status"], stdout: "ok\n") - runner.add(pattern: ["echo", "hi"], stdout: "hi\n") - runner.run("gh", "auth", "status") - runner.run("echo", "hi") + runner.add(pattern: ["bin/importmap", "outdated"], stdout: "") + runner.add(pattern: ["bin/importmap", "audit"], stdout: "") + runner.run("bin/importmap", "outdated") + runner.run("bin/importmap", "audit") assert_equal [ - ["gh", "auth", "status"], - ["echo", "hi"] + ["bin/importmap", "outdated"], + ["bin/importmap", "audit"] ], runner.calls end @@ -70,29 +70,28 @@ def test_fixture_runner_first_matching_pattern_wins # specific patterns. This test pins the behavior so callers know to # register specific patterns before general ones. runner = Commands::FixtureRunner.new - runner.add(pattern: ["echo", "specific"], stdout: "first\n") - runner.add(pattern: ["echo", "specific"], stdout: "second\n") - assert_equal "first\n", runner.run("echo", "specific").stdout + runner.add(pattern: ["bin/importmap", "pin", "lodash@4.17.21"], stdout: "first\n") + runner.add(pattern: ["bin/importmap", "pin", "lodash@4.17.21"], stdout: "second\n") + assert_equal "first\n", runner.run("bin/importmap", "pin", "lodash@4.17.21").stdout end # ---- FixtureRunner: regex matching ---- def test_fixture_runner_supports_regex_elements_in_patterns - # The branch SHA changes every run; the pattern uses a regex to allow - # any 40-char hex SHA in that position. + # Package versions change; the pattern uses a regex to allow any semver. runner = Commands::FixtureRunner.new runner.add( - pattern: ["git", "rev-parse", /\A[0-9a-f]{7,40}\z/], - stdout: "ok\n" + pattern: ["bin/importmap", "pin", /\Alodash@\d+\.\d+\.\d+\z/], + stdout: "Pinned lodash\n" ) - assert_equal "ok\n", runner.run("git", "rev-parse", "abcdef1234567").stdout + assert_equal "Pinned lodash\n", runner.run("bin/importmap", "pin", "lodash@4.17.21").stdout end def test_fixture_runner_regex_must_match_exactly_at_position runner = Commands::FixtureRunner.new - runner.add(pattern: ["git", "checkout", /\Aupdates\//], stdout: "ok\n") + runner.add(pattern: ["bin/importmap", "pin", /\Alodash@/], stdout: "ok\n") err = assert_raises(RuntimeError) do - runner.run("git", "checkout", "main") + runner.run("bin/importmap", "pin", "axios@1.7.0") end assert_match(/No fixture matched/, err.message) end @@ -101,28 +100,28 @@ def test_fixture_runner_regex_must_match_exactly_at_position def test_fixture_runner_raises_clearly_when_no_pattern_matches runner = Commands::FixtureRunner.new - runner.add(pattern: ["gh", "pr", "view"], stdout: "") - err = assert_raises(RuntimeError) { runner.run("gh", "pr", "close", "1") } + runner.add(pattern: ["bin/importmap", "outdated"], stdout: "") + err = assert_raises(RuntimeError) { runner.run("bin/importmap", "audit") } assert_includes err.message, "No fixture matched" - assert_includes err.message, "close" + assert_includes err.message, "audit" end def test_fixture_runner_argv_size_mismatch_does_not_match - # A 3-element pattern must not match a 4-element call. + # A 2-element pattern must not match a 3-element call. runner = Commands::FixtureRunner.new - runner.add(pattern: ["gh", "pr", "list"], stdout: "ok\n") - assert_raises(RuntimeError) { runner.run("gh", "pr", "list", "--json", "number") } + runner.add(pattern: ["bin/importmap", "outdated"], stdout: "ok\n") + assert_raises(RuntimeError) { runner.run("bin/importmap", "outdated", "--verbose") } end def test_fixture_runner_bang_raises_command_error_on_recorded_failure runner = Commands::FixtureRunner.new runner.add( - pattern: ["gh", "pr", "create"], - stderr: "GraphQL: branch already exists", + pattern: ["bin/importmap", "pin", "lodash@4.17.21"], + stderr: "network error", exit_code: 1 ) - err = assert_raises(Commands::CommandError) { runner.run!("gh", "pr", "create") } + err = assert_raises(Commands::CommandError) { runner.run!("bin/importmap", "pin", "lodash@4.17.21") } assert_equal 1, err.result.exit_code - assert_includes err.message, "branch already exists" + assert_includes err.message, "network error" end end diff --git a/test/git_client_test.rb b/test/git_client_test.rb new file mode 100644 index 0000000..7b448cf --- /dev/null +++ b/test/git_client_test.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require_relative "test_helper" +require "minitest/mock" +require "git" +require "git_client" + +class GitClientTest < Minitest::Test + GitClient = Importmap::Update::GitClient + + AUTHOR_NAME = "Test Bot" + AUTHOR_EMAIL = "bot@example.com" + AUTHOR_STRING = "Test Bot " + + def setup + @repo = Minitest::Mock.new + @client = GitClient.new(repo: @repo, author_name: AUTHOR_NAME, author_email: AUTHOR_EMAIL) + end + + def teardown + assert_mock @repo + end + + # ---- checkout_fresh_branch ---- + + def test_checkout_fresh_branch_creates_branch_when_it_does_not_exist + @repo.expect(:fetch, nil, ["origin"], ref: "main") + @repo.expect(:checkout, nil) { raise Git::Error } + @repo.expect(:checkout, nil, ["importmap-updates/patch"], + new_branch: true, start_point: "origin/main") + + assert_nil @client.checkout_fresh_branch(branch: "importmap-updates/patch", base: "main") + end + + def test_checkout_fresh_branch_resets_existing_branch_to_base + @repo.expect(:fetch, nil, ["origin"], ref: "main") + @repo.expect(:checkout, nil, ["importmap-updates/patch"]) + @repo.expect(:reset_hard, nil, ["origin/main"]) + + assert_nil @client.checkout_fresh_branch(branch: "importmap-updates/patch", base: "main") + end + + # ---- commit_changes ---- + + def test_commit_changes_stages_and_commits_returning_true + @repo.expect(:add, nil, [["config/importmap.rb", "vendor/javascript"]]) + @repo.expect(:commit, nil, ["Bump lodash from 4.17.20 to 4.17.21"], + author: AUTHOR_STRING) + + assert_equal true, @client.commit_changes(message: "Bump lodash from 4.17.20 to 4.17.21") + end + + def test_commit_changes_returns_false_when_nothing_to_commit + @repo.expect(:add, nil, [["config/importmap.rb", "vendor/javascript"]]) + @repo.expect(:commit, nil) do |_msg, **_opts| + raise git_failed_error("nothing to commit, working tree clean") + end + + assert_equal false, @client.commit_changes(message: "irrelevant") + end + + def test_commit_changes_re_raises_unexpected_git_errors + @repo.expect(:add, nil, [["config/importmap.rb", "vendor/javascript"]]) + @repo.expect(:commit, nil) do |_msg, **_opts| + raise git_failed_error("lock file exists") + end + + assert_raises(Git::FailedError) do + @client.commit_changes(message: "irrelevant") + end + end + + # ---- push ---- + + def test_push_without_force + @repo.expect(:push, nil, ["origin", "importmap-updates/patch"], force: false) + assert_nil @client.push(branch: "importmap-updates/patch") + end + + def test_push_with_force + @repo.expect(:push, nil, ["origin", "importmap-updates/patch"], force: true) + assert_nil @client.push(branch: "importmap-updates/patch", force: true) + end + + private + + # Git::FailedError wraps a Git::CommandLineResult which needs a status + # object. We build the minimum required for e.result.stderr to work. + def git_failed_error(stderr) + fake_status = Struct.new(:exitstatus, :pid) { def to_s = "pid #{pid} exit #{exitstatus}" }.new(1, 0) + result = Git::CommandLineResult.new(["git", "commit"], fake_status, "", stderr) + Git::FailedError.new(result) + end +end