From 0921fe502c1cedef12a0e6f11a0ac18448983ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtek=20Meloun?= Date: Sun, 29 Mar 2026 15:29:39 +0000 Subject: [PATCH 01/14] Stop sign-off after failed dummy commit --- lib/gotsha/actions/commit.rb | 4 +++- spec/gotsha/actions/commit_spec.rb | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/gotsha/actions/commit.rb b/lib/gotsha/actions/commit.rb index d57f2f44..5bde1f0f 100644 --- a/lib/gotsha/actions/commit.rb +++ b/lib/gotsha/actions/commit.rb @@ -6,7 +6,9 @@ class Commit DESCRIPTION = "runs tests on a dummy commit for manual sign-off" def call - BashCommand.silent_run!('git -c core.hooksPath=/dev/null commit --allow-empty -m "Run Gotsha"') + command = BashCommand.silent_run!('git -c core.hooksPath=/dev/null commit --allow-empty -m "Run Gotsha"') + + raise Errors::HardFail, "something went wrong" unless command.success? Test.new.call end diff --git a/spec/gotsha/actions/commit_spec.rb b/spec/gotsha/actions/commit_spec.rb index 44457e62..c4dbe908 100644 --- a/spec/gotsha/actions/commit_spec.rb +++ b/spec/gotsha/actions/commit_spec.rb @@ -3,13 +3,31 @@ RSpec.describe Gotsha::Actions::Commit do describe "commit" do it "creates a blank commit and triggers `test` on it" do + git_commit = double("git_commit", success?: true) + expect(Gotsha::BashCommand) .to receive(:silent_run!) .with("git -c core.hooksPath=/dev/null commit --allow-empty -m \"Run Gotsha\"") + .and_return(git_commit) expect_any_instance_of(Gotsha::Actions::Test).to(receive(:call)) described_class.new.call end + + it "does not trigger `test` when the blank commit fails" do + git_commit = double("git_commit", success?: false) + + expect(Gotsha::BashCommand) + .to receive(:silent_run!) + .with("git -c core.hooksPath=/dev/null commit --allow-empty -m \"Run Gotsha\"") + .and_return(git_commit) + + expect_any_instance_of(Gotsha::Actions::Test).not_to receive(:call) + + expect do + described_class.new.call + end.to raise_error(Gotsha::Errors::HardFail, "something went wrong") + end end end From e6b5d3ffcc6ffa9ea1378809301cc85047749ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtek=20Meloun?= Date: Sun, 29 Mar 2026 15:34:11 +0000 Subject: [PATCH 02/14] Coerce scalar config ENV overrides --- lib/gotsha/user_config.rb | 15 +++++++++++++-- spec/gotsha/user_config_spec.rb | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/lib/gotsha/user_config.rb b/lib/gotsha/user_config.rb index 007840a7..9fb22f00 100644 --- a/lib/gotsha/user_config.rb +++ b/lib/gotsha/user_config.rb @@ -4,15 +4,26 @@ module Gotsha class UserConfig def self.get(key) config = new.to_h + env_key = "GOTSHA_#{key.to_s.upcase}" + env_value = ENV[env_key] - ENV["GOTSHA_#{key.to_s.upcase}"] || # this allows changing config via ENV vars - config[key] + return config[key] if key.to_sym == :commands + return coerce_env_value(env_value) unless env_value.nil? + + config[key] end def self.blank? new.to_h.empty? end + def self.coerce_env_value(value) + return true if value == "true" + return false if value == "false" + + value + end + def to_h TomlRB.load_file(Config::CONFIG_FILE).transform_keys(&:to_sym) rescue Errno::ENOENT diff --git a/spec/gotsha/user_config_spec.rb b/spec/gotsha/user_config_spec.rb index 6f0294e1..065437c3 100644 --- a/spec/gotsha/user_config_spec.rb +++ b/spec/gotsha/user_config_spec.rb @@ -1,6 +1,12 @@ # frozen_string_literal: true RSpec.describe Gotsha::UserConfig do + around do |example| + original_env = ENV.to_hash + example.run + ENV.replace(original_env) + end + describe "when the config file does not exists" do before do stub_const "Gotsha::Config::CONFIG_FILE", "not/exists" @@ -30,4 +36,31 @@ expect(described_class.get(:test_key)).to eq("test_value") end end + + describe "when ENV overrides are used" do + before do + allow(TomlRB) + .to receive(:load_file) + .with(Gotsha::Config::CONFIG_FILE) + .and_return({ "verbose" => true, "commands" => ["bin/rspec"] }) + end + + it "coerces false boolean values" do + ENV["GOTSHA_VERBOSE"] = "false" + + expect(described_class.get(:verbose)).to eq(false) + end + + it "coerces true boolean values" do + ENV["GOTSHA_CI"] = "true" + + expect(described_class.get(:ci)).to eq(true) + end + + it "does not override commands via ENV string values" do + ENV["GOTSHA_COMMANDS"] = "bin/test" + + expect(described_class.get(:commands)).to eq(["bin/rspec"]) + end + end end From a3ec1f3eaa79ddaec2f188fb2de41a6ea4f6fa10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtek=20Meloun?= Date: Sun, 29 Mar 2026 15:36:45 +0000 Subject: [PATCH 03/14] Allow help before Gotsha setup --- lib/gotsha/action_dispatcher.rb | 2 +- spec/gotsha/action_dispatcher_spec.rb | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/gotsha/action_dispatcher.rb b/lib/gotsha/action_dispatcher.rb index dcc80a99..f1ec2328 100644 --- a/lib/gotsha/action_dispatcher.rb +++ b/lib/gotsha/action_dispatcher.rb @@ -2,7 +2,7 @@ module Gotsha class ActionDispatcher - SKIP_CONFIG_VERIFICATION_FOR = %w[init configure uninstall].freeze + SKIP_CONFIG_VERIFICATION_FOR = %w[init configure uninstall help].freeze DEFAULT_ACTION = "help" HELP_ACTION_SHORTCUT = "-h" VERSION_ACTION_SHORTCUT = "-v" diff --git a/spec/gotsha/action_dispatcher_spec.rb b/spec/gotsha/action_dispatcher_spec.rb index 27c37b96..281dfda1 100644 --- a/spec/gotsha/action_dispatcher_spec.rb +++ b/spec/gotsha/action_dispatcher_spec.rb @@ -38,6 +38,8 @@ end describe "with autogenerated config" do + let(:action) { "status" } + before do allow(Gotsha::UserConfig).to receive(:get).and_return(true) @@ -46,7 +48,7 @@ it "fails with proper error" do expect do - described_class.call + described_class.call(action) end.to raise_exception( Gotsha::Errors::HardFail, "autogenerated config detected! Please, remove `autogenerated = true` from `.gotsha/config.toml` " \ @@ -63,9 +65,11 @@ end context "with other action than `init`" do + let(:action) { "status" } + it "fails with proper error" do expect do - described_class.call + described_class.call(action) end.to raise_exception( Gotsha::Errors::HardFail, "config files not found, please run `gotsha init` first" @@ -80,6 +84,14 @@ described_class.call(:init) end end + + context "with `help` action" do + it "calls the help action without exception" do + expect_any_instance_of(Gotsha::Actions::Help).to receive(:call) + + described_class.call(:help) + end + end end describe "with help action shortcut" do From 5ddcf674d999836d758373108d23c21ca4d8fb47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtek=20Meloun?= Date: Sun, 29 Mar 2026 15:41:52 +0000 Subject: [PATCH 04/14] Use branch remote for notes sync --- lib/gotsha/actions/fetch.rb | 17 +++++++++++++++- lib/gotsha/actions/push.rb | 17 +++++++++++++++- spec/gotsha/actions/fetch_spec.rb | 32 ++++++++++++++++++++++++++++++ spec/gotsha/actions/push_spec.rb | 33 +++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 2 deletions(-) diff --git a/lib/gotsha/actions/fetch.rb b/lib/gotsha/actions/fetch.rb index 216fe945..68bd8c88 100644 --- a/lib/gotsha/actions/fetch.rb +++ b/lib/gotsha/actions/fetch.rb @@ -6,12 +6,27 @@ class Fetch DESCRIPTION = "fetches Gotsha test results from remote" def call - command = BashCommand.silent_run!("git fetch --force origin 'refs/notes/gotsha:refs/notes/gotsha'") + command = BashCommand.silent_run!("git fetch --force #{remote} 'refs/notes/gotsha:refs/notes/gotsha'") raise(Errors::HardFail, "something went wrong") unless command.success? "fetched" end + + private + + def remote + branch_name = BashCommand.run!("git branch --show-current").text_output + return "origin" if branch_name.empty? + + push_remote = BashCommand.run!("git config --get branch.#{branch_name}.pushRemote").text_output + return push_remote unless push_remote.empty? + + branch_remote = BashCommand.run!("git config --get branch.#{branch_name}.remote").text_output + return branch_remote unless branch_remote.empty? + + "origin" + end end end end diff --git a/lib/gotsha/actions/push.rb b/lib/gotsha/actions/push.rb index 50f718e7..8255184f 100644 --- a/lib/gotsha/actions/push.rb +++ b/lib/gotsha/actions/push.rb @@ -6,12 +6,27 @@ class Push DESCRIPTION = "pushes Gotsha test results to remote" def call - command = BashCommand.silent_run!("git push --no-verify --force origin refs/notes/gotsha:refs/notes/gotsha") + command = BashCommand.silent_run!("git push --no-verify --force #{remote} refs/notes/gotsha:refs/notes/gotsha") raise(Errors::HardFail, "something went wrong") unless command.success? "pushed" end + + private + + def remote + branch_name = BashCommand.run!("git branch --show-current").text_output + return "origin" if branch_name.empty? + + push_remote = BashCommand.run!("git config --get branch.#{branch_name}.pushRemote").text_output + return push_remote unless push_remote.empty? + + branch_remote = BashCommand.run!("git config --get branch.#{branch_name}.remote").text_output + return branch_remote unless branch_remote.empty? + + "origin" + end end end end diff --git a/spec/gotsha/actions/fetch_spec.rb b/spec/gotsha/actions/fetch_spec.rb index d01c721c..975e4a92 100644 --- a/spec/gotsha/actions/fetch_spec.rb +++ b/spec/gotsha/actions/fetch_spec.rb @@ -3,8 +3,19 @@ RSpec.describe Gotsha::Actions::Fetch do describe "fetch" do let(:git_command_mock) { double("git", success?: true) } + let(:remote_name) { "origin" } it "calls the Git command to fetch notes" do + expect(Gotsha::BashCommand) + .to receive(:run!) + .with("git branch --show-current") + .and_return(double("git", text_output: "main")) + + expect(Gotsha::BashCommand) + .to receive(:run!) + .with("git config --get branch.main.pushRemote") + .and_return(double("git", text_output: remote_name)) + expect(Gotsha::BashCommand) .to receive(:silent_run!) .with("git fetch --force origin 'refs/notes/gotsha:refs/notes/gotsha'") @@ -12,5 +23,26 @@ expect(described_class.new.call).to eq("fetched") end + + it "uses branch pushRemote when configured" do + remote_name = "upstream" + + expect(Gotsha::BashCommand) + .to receive(:run!) + .with("git branch --show-current") + .and_return(double("git", text_output: "main")) + + expect(Gotsha::BashCommand) + .to receive(:run!) + .with("git config --get branch.main.pushRemote") + .and_return(double("git", text_output: remote_name)) + + expect(Gotsha::BashCommand) + .to receive(:silent_run!) + .with("git fetch --force #{remote_name} 'refs/notes/gotsha:refs/notes/gotsha'") + .and_return(git_command_mock) + + expect(described_class.new.call).to eq("fetched") + end end end diff --git a/spec/gotsha/actions/push_spec.rb b/spec/gotsha/actions/push_spec.rb index b24c0a9b..908133aa 100644 --- a/spec/gotsha/actions/push_spec.rb +++ b/spec/gotsha/actions/push_spec.rb @@ -2,9 +2,21 @@ RSpec.describe Gotsha::Actions::Push do let(:git_push_result) { double("git", success?: true) } + let(:git_remote_result) { double("git", text_output: remote_name) } + let(:remote_name) { "origin" } describe "push" do it "force pushes Git notes" do + expect(Gotsha::BashCommand) + .to receive(:run!) + .with("git branch --show-current") + .and_return(double("git", text_output: "main")) + + expect(Gotsha::BashCommand) + .to receive(:run!) + .with("git config --get branch.main.pushRemote") + .and_return(git_remote_result) + expect(Gotsha::BashCommand) .to receive(:silent_run!) .with("git push --no-verify --force origin refs/notes/gotsha:refs/notes/gotsha") @@ -12,5 +24,26 @@ expect(described_class.new.call).to eq("pushed") end + + it "uses branch pushRemote when configured" do + remote_name = "upstream" + + expect(Gotsha::BashCommand) + .to receive(:run!) + .with("git branch --show-current") + .and_return(double("git", text_output: "main")) + + expect(Gotsha::BashCommand) + .to receive(:run!) + .with("git config --get branch.main.pushRemote") + .and_return(double("git", text_output: remote_name)) + + expect(Gotsha::BashCommand) + .to receive(:silent_run!) + .with("git push --no-verify --force #{remote_name} refs/notes/gotsha:refs/notes/gotsha") + .and_return(git_push_result) + + expect(described_class.new.call).to eq("pushed") + end end end From f639f17cc1ca607f3a530d3f693c3fe2b3d7ba35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtek=20Meloun?= Date: Sun, 29 Mar 2026 15:42:56 +0000 Subject: [PATCH 05/14] Remove GitLab config on uninstall --- lib/gotsha/actions/uninstall.rb | 1 + spec/gotsha/actions/uninstall_spec.rb | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/lib/gotsha/actions/uninstall.rb b/lib/gotsha/actions/uninstall.rb index 9208cbc8..391cde78 100644 --- a/lib/gotsha/actions/uninstall.rb +++ b/lib/gotsha/actions/uninstall.rb @@ -10,6 +10,7 @@ def call File.exist?(Config::CONFIG_DIR) && FileUtils.rm_rf(Config::CONFIG_DIR) File.exist?(Config::GH_CONFIG_FILE) && FileUtils.rm(Config::GH_CONFIG_FILE) + File.exist?(Config::GL_CONFIG_FILE) && FileUtils.rm(Config::GL_CONFIG_FILE) puts "Unsetting Git hooks path..." BashCommand.silent_run!("git config --unset core.hooksPath") diff --git a/spec/gotsha/actions/uninstall_spec.rb b/spec/gotsha/actions/uninstall_spec.rb index b4c148e3..d75347c9 100644 --- a/spec/gotsha/actions/uninstall_spec.rb +++ b/spec/gotsha/actions/uninstall_spec.rb @@ -3,8 +3,14 @@ RSpec.describe Gotsha::Actions::Uninstall do describe "uninstall" do it "removes config files and Git configuration" do + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with(Gotsha::Config::CONFIG_DIR).and_return(true) + allow(File).to receive(:exist?).with(Gotsha::Config::GH_CONFIG_FILE).and_return(true) + allow(File).to receive(:exist?).with(Gotsha::Config::GL_CONFIG_FILE).and_return(true) + expect(FileUtils).to receive(:rm_rf).with(Gotsha::Config::CONFIG_DIR) expect(FileUtils).to receive(:rm).with(Gotsha::Config::GH_CONFIG_FILE) + expect(FileUtils).to receive(:rm).with(Gotsha::Config::GL_CONFIG_FILE) expect(Gotsha::BashCommand).to receive(:silent_run!).with("git config --unset core.hooksPath") described_class.new.call From 4caa3304546713dddd5ca4bacf81794f703a969e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtek=20Meloun?= Date: Sun, 29 Mar 2026 15:43:57 +0000 Subject: [PATCH 06/14] Allow fetch before notes exist --- lib/gotsha/actions/fetch.rb | 6 +++++- spec/gotsha/actions/fetch_spec.rb | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/gotsha/actions/fetch.rb b/lib/gotsha/actions/fetch.rb index 68bd8c88..2996e072 100644 --- a/lib/gotsha/actions/fetch.rb +++ b/lib/gotsha/actions/fetch.rb @@ -8,7 +8,7 @@ class Fetch def call command = BashCommand.silent_run!("git fetch --force #{remote} 'refs/notes/gotsha:refs/notes/gotsha'") - raise(Errors::HardFail, "something went wrong") unless command.success? + raise(Errors::HardFail, "something went wrong") unless command.success? || missing_notes_ref?(command) "fetched" end @@ -27,6 +27,10 @@ def remote "origin" end + + def missing_notes_ref?(command) + command.text_output.include?("couldn't find remote ref refs/notes/gotsha") + end end end end diff --git a/spec/gotsha/actions/fetch_spec.rb b/spec/gotsha/actions/fetch_spec.rb index 975e4a92..67541f5d 100644 --- a/spec/gotsha/actions/fetch_spec.rb +++ b/spec/gotsha/actions/fetch_spec.rb @@ -44,5 +44,24 @@ expect(described_class.new.call).to eq("fetched") end + + it "does not fail when the remote notes ref does not exist yet" do + expect(Gotsha::BashCommand) + .to receive(:run!) + .with("git branch --show-current") + .and_return(double("git", text_output: "main")) + + expect(Gotsha::BashCommand) + .to receive(:run!) + .with("git config --get branch.main.pushRemote") + .and_return(double("git", text_output: remote_name)) + + expect(Gotsha::BashCommand) + .to receive(:silent_run!) + .with("git fetch --force #{remote_name} 'refs/notes/gotsha:refs/notes/gotsha'") + .and_return(double("git", success?: false, text_output: "fatal: couldn't find remote ref refs/notes/gotsha")) + + expect(described_class.new.call).to eq("fetched") + end end end From 85fda50dfc8db1c54cd357eafa246f533debd1f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtek=20Meloun?= Date: Sun, 29 Mar 2026 15:44:28 +0000 Subject: [PATCH 07/14] Run Gotsha From bcae3dbe95e0b0c16e41456dd2cdd0c0dddc5357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtek=20Meloun?= Date: Mon, 30 Mar 2026 23:17:09 +0000 Subject: [PATCH 08/14] Fix help shortcuts before setup --- lib/gotsha/action_dispatcher.rb | 2 +- spec/gotsha/action_dispatcher_spec.rb | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/lib/gotsha/action_dispatcher.rb b/lib/gotsha/action_dispatcher.rb index f1ec2328..e3a00a44 100644 --- a/lib/gotsha/action_dispatcher.rb +++ b/lib/gotsha/action_dispatcher.rb @@ -2,7 +2,7 @@ module Gotsha class ActionDispatcher - SKIP_CONFIG_VERIFICATION_FOR = %w[init configure uninstall help].freeze + SKIP_CONFIG_VERIFICATION_FOR = %w[init configure uninstall help -h --help].freeze DEFAULT_ACTION = "help" HELP_ACTION_SHORTCUT = "-h" VERSION_ACTION_SHORTCUT = "-v" diff --git a/spec/gotsha/action_dispatcher_spec.rb b/spec/gotsha/action_dispatcher_spec.rb index 281dfda1..ca168e96 100644 --- a/spec/gotsha/action_dispatcher_spec.rb +++ b/spec/gotsha/action_dispatcher_spec.rb @@ -6,7 +6,12 @@ allow(Gotsha::UserConfig).to receive(:get).with(:ci).and_return(false) allow(Gotsha::UserConfig).to receive(:get).with(:autogenerated).and_return(false) allow(Gotsha::BashCommand).to receive(:run!).with("git config core.hooksPath") - .and_return(instance_double(Gotsha::BashCommand, text_output: Gotsha::Config::HOOKS_DIR)) + .and_return( + instance_double( + Gotsha::BashCommand, + text_output: Gotsha::Config::HOOKS_DIR + ) + ) end describe "without any action name" do @@ -92,6 +97,22 @@ described_class.call(:help) end end + + context "with `-h` shortcut" do + it "calls the help action without exception" do + expect_any_instance_of(Gotsha::Actions::Help).to receive(:call) + + described_class.call("-h") + end + end + + context "with `--help` shortcut" do + it "calls the help action without exception" do + expect_any_instance_of(Gotsha::Actions::Help).to receive(:call) + + described_class.call("--help") + end + end end describe "with help action shortcut" do From b9580bdcb506791561d5fa567cd1ba900a230970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtek=20Meloun?= Date: Mon, 30 Mar 2026 23:19:47 +0000 Subject: [PATCH 09/14] Run Gotsha From dc0083f3eef708514057ae52ae92b5825154718c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtek=20Meloun?= Date: Mon, 30 Mar 2026 23:20:53 +0000 Subject: [PATCH 10/14] Add base64 for Ruby 3.4 --- Gemfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Gemfile b/Gemfile index 57f052d6..3e6abb1c 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,7 @@ source "https://rubygems.org" # Specify your gem's dependencies in gotsha.gemspec gemspec +gem "base64" gem "irb" gem "rake", "~> 13.0" gem "rspec", "~> 3.0" From 52a7d09a94bb5113d5f253d58921759d01960122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtek=20Meloun?= Date: Mon, 30 Mar 2026 23:20:56 +0000 Subject: [PATCH 11/14] Run Gotsha From a26b23e5f3493497dab938dc384a5c2057a1c04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtek=20Meloun?= Date: Thu, 2 Apr 2026 20:36:29 +0000 Subject: [PATCH 12/14] Fix subcommand help before setup --- lib/gotsha/action_dispatcher.rb | 6 +++--- spec/gotsha/action_dispatcher_spec.rb | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/gotsha/action_dispatcher.rb b/lib/gotsha/action_dispatcher.rb index e3a00a44..bbe15860 100644 --- a/lib/gotsha/action_dispatcher.rb +++ b/lib/gotsha/action_dispatcher.rb @@ -16,13 +16,13 @@ def self.call(action_name = DEFAULT_ACTION, *args) def call(action_name, *args) @action_name = action_name + return Actions::Help.new.call(action_name) if args == [HELP_ACTION_SHORTCUT] + return Actions::Help.new.call(action_name) if args == ["--help"] + verify_configuration! action_class.new.call(*args) rescue ArgumentError - return Actions::Help.new.call(action_name) if args == [HELP_ACTION_SHORTCUT] - return Actions::Help.new.call(action_name) if args == ["--help"] - raise Errors::HardFail, "too many arguments" end diff --git a/spec/gotsha/action_dispatcher_spec.rb b/spec/gotsha/action_dispatcher_spec.rb index ca168e96..e7788942 100644 --- a/spec/gotsha/action_dispatcher_spec.rb +++ b/spec/gotsha/action_dispatcher_spec.rb @@ -113,6 +113,22 @@ described_class.call("--help") end end + + context "with a valid command followed by `-h`" do + it "calls the help action without exception" do + expect_any_instance_of(Gotsha::Actions::Help).to receive(:call).with("commit") + + described_class.call("commit", "-h") + end + end + + context "with a valid command followed by `--help`" do + it "calls the help action without exception" do + expect_any_instance_of(Gotsha::Actions::Help).to receive(:call).with("commit") + + described_class.call("commit", "--help") + end + end end describe "with help action shortcut" do From 51d09560da565e52418e7145a15d57f7e050972a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtek=20Meloun?= Date: Thu, 2 Apr 2026 20:36:39 +0000 Subject: [PATCH 13/14] Run Gotsha From 81be8b90999bc5f8d5899bd03fa72e753e85959c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtek=20Meloun?= Date: Sat, 4 Apr 2026 20:17:21 +0000 Subject: [PATCH 14/14] Fix shell injection in remote lookup via branch names Extract RemoteResolver module that uses Open3.capture2 (argv array) instead of interpolating branch names into shell commands via BashCommand.run!. This closes a local code execution vector where a branch name containing shell metacharacters (e.g. $(whoami)) would be evaluated by bash when running gotsha fetch or gotsha push. Also deduplicates the identical remote method from Fetch and Push. --- lib/gotsha.rb | 1 + lib/gotsha/actions/fetch.rb | 14 +---- lib/gotsha/actions/push.rb | 16 +----- lib/gotsha/remote_resolver.rb | 27 ++++++++++ spec/gotsha/actions/fetch_spec.rb | 77 +++++++++++--------------- spec/gotsha/actions/push_spec.rb | 60 +++++++++------------ spec/gotsha/remote_resolver_spec.rb | 84 +++++++++++++++++++++++++++++ 7 files changed, 168 insertions(+), 111 deletions(-) create mode 100644 lib/gotsha/remote_resolver.rb create mode 100644 spec/gotsha/remote_resolver_spec.rb diff --git a/lib/gotsha.rb b/lib/gotsha.rb index 02ccd11c..f7c8206a 100644 --- a/lib/gotsha.rb +++ b/lib/gotsha.rb @@ -18,6 +18,7 @@ require_relative "gotsha/bash_command" require_relative "gotsha/config" require_relative "gotsha/errors" +require_relative "gotsha/remote_resolver" require_relative "gotsha/user_config" require_relative "gotsha/version" diff --git a/lib/gotsha/actions/fetch.rb b/lib/gotsha/actions/fetch.rb index 2996e072..f6083811 100644 --- a/lib/gotsha/actions/fetch.rb +++ b/lib/gotsha/actions/fetch.rb @@ -6,6 +6,7 @@ class Fetch DESCRIPTION = "fetches Gotsha test results from remote" def call + remote = RemoteResolver.resolve command = BashCommand.silent_run!("git fetch --force #{remote} 'refs/notes/gotsha:refs/notes/gotsha'") raise(Errors::HardFail, "something went wrong") unless command.success? || missing_notes_ref?(command) @@ -15,19 +16,6 @@ def call private - def remote - branch_name = BashCommand.run!("git branch --show-current").text_output - return "origin" if branch_name.empty? - - push_remote = BashCommand.run!("git config --get branch.#{branch_name}.pushRemote").text_output - return push_remote unless push_remote.empty? - - branch_remote = BashCommand.run!("git config --get branch.#{branch_name}.remote").text_output - return branch_remote unless branch_remote.empty? - - "origin" - end - def missing_notes_ref?(command) command.text_output.include?("couldn't find remote ref refs/notes/gotsha") end diff --git a/lib/gotsha/actions/push.rb b/lib/gotsha/actions/push.rb index 8255184f..8d1ecf76 100644 --- a/lib/gotsha/actions/push.rb +++ b/lib/gotsha/actions/push.rb @@ -6,27 +6,13 @@ class Push DESCRIPTION = "pushes Gotsha test results to remote" def call + remote = RemoteResolver.resolve command = BashCommand.silent_run!("git push --no-verify --force #{remote} refs/notes/gotsha:refs/notes/gotsha") raise(Errors::HardFail, "something went wrong") unless command.success? "pushed" end - - private - - def remote - branch_name = BashCommand.run!("git branch --show-current").text_output - return "origin" if branch_name.empty? - - push_remote = BashCommand.run!("git config --get branch.#{branch_name}.pushRemote").text_output - return push_remote unless push_remote.empty? - - branch_remote = BashCommand.run!("git config --get branch.#{branch_name}.remote").text_output - return branch_remote unless branch_remote.empty? - - "origin" - end end end end diff --git a/lib/gotsha/remote_resolver.rb b/lib/gotsha/remote_resolver.rb new file mode 100644 index 00000000..6eb1b8c5 --- /dev/null +++ b/lib/gotsha/remote_resolver.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "open3" + +module Gotsha + module RemoteResolver + def self.resolve + branch_name = BashCommand.run!("git branch --show-current").text_output + return "origin" if branch_name.empty? + + push_remote = git_config("branch.#{branch_name}.pushRemote") + return push_remote unless push_remote.empty? + + branch_remote = git_config("branch.#{branch_name}.remote") + return branch_remote unless branch_remote.empty? + + "origin" + end + + def self.git_config(key) + stdout, _status = Open3.capture2("git", "config", "--get", key) + stdout.strip + end + + private_class_method :git_config + end +end diff --git a/spec/gotsha/actions/fetch_spec.rb b/spec/gotsha/actions/fetch_spec.rb index 67541f5d..9d5d78fe 100644 --- a/spec/gotsha/actions/fetch_spec.rb +++ b/spec/gotsha/actions/fetch_spec.rb @@ -3,65 +3,48 @@ RSpec.describe Gotsha::Actions::Fetch do describe "fetch" do let(:git_command_mock) { double("git", success?: true) } - let(:remote_name) { "origin" } - it "calls the Git command to fetch notes" do - expect(Gotsha::BashCommand) - .to receive(:run!) - .with("git branch --show-current") - .and_return(double("git", text_output: "main")) + before do + allow(Gotsha::RemoteResolver).to receive(:resolve).and_return(remote_name) + end - expect(Gotsha::BashCommand) - .to receive(:run!) - .with("git config --get branch.main.pushRemote") - .and_return(double("git", text_output: remote_name)) + context "with default remote" do + let(:remote_name) { "origin" } - expect(Gotsha::BashCommand) - .to receive(:silent_run!) - .with("git fetch --force origin 'refs/notes/gotsha:refs/notes/gotsha'") - .and_return(git_command_mock) + it "calls the Git command to fetch notes" do + expect(Gotsha::BashCommand) + .to receive(:silent_run!) + .with("git fetch --force origin 'refs/notes/gotsha:refs/notes/gotsha'") + .and_return(git_command_mock) - expect(described_class.new.call).to eq("fetched") + expect(described_class.new.call).to eq("fetched") + end end - it "uses branch pushRemote when configured" do - remote_name = "upstream" - - expect(Gotsha::BashCommand) - .to receive(:run!) - .with("git branch --show-current") - .and_return(double("git", text_output: "main")) + context "with custom remote" do + let(:remote_name) { "upstream" } - expect(Gotsha::BashCommand) - .to receive(:run!) - .with("git config --get branch.main.pushRemote") - .and_return(double("git", text_output: remote_name)) + it "uses the resolved remote" do + expect(Gotsha::BashCommand) + .to receive(:silent_run!) + .with("git fetch --force upstream 'refs/notes/gotsha:refs/notes/gotsha'") + .and_return(git_command_mock) - expect(Gotsha::BashCommand) - .to receive(:silent_run!) - .with("git fetch --force #{remote_name} 'refs/notes/gotsha:refs/notes/gotsha'") - .and_return(git_command_mock) - - expect(described_class.new.call).to eq("fetched") + expect(described_class.new.call).to eq("fetched") + end end - it "does not fail when the remote notes ref does not exist yet" do - expect(Gotsha::BashCommand) - .to receive(:run!) - .with("git branch --show-current") - .and_return(double("git", text_output: "main")) - - expect(Gotsha::BashCommand) - .to receive(:run!) - .with("git config --get branch.main.pushRemote") - .and_return(double("git", text_output: remote_name)) + context "when remote notes ref does not exist" do + let(:remote_name) { "origin" } - expect(Gotsha::BashCommand) - .to receive(:silent_run!) - .with("git fetch --force #{remote_name} 'refs/notes/gotsha:refs/notes/gotsha'") - .and_return(double("git", success?: false, text_output: "fatal: couldn't find remote ref refs/notes/gotsha")) + it "does not fail" do + expect(Gotsha::BashCommand) + .to receive(:silent_run!) + .with("git fetch --force origin 'refs/notes/gotsha:refs/notes/gotsha'") + .and_return(double("git", success?: false, text_output: "fatal: couldn't find remote ref refs/notes/gotsha")) - expect(described_class.new.call).to eq("fetched") + expect(described_class.new.call).to eq("fetched") + end end end end diff --git a/spec/gotsha/actions/push_spec.rb b/spec/gotsha/actions/push_spec.rb index 908133aa..40e44a2d 100644 --- a/spec/gotsha/actions/push_spec.rb +++ b/spec/gotsha/actions/push_spec.rb @@ -1,49 +1,37 @@ # frozen_string_literal: true RSpec.describe Gotsha::Actions::Push do - let(:git_push_result) { double("git", success?: true) } - let(:git_remote_result) { double("git", text_output: remote_name) } - let(:remote_name) { "origin" } - describe "push" do - it "force pushes Git notes" do - expect(Gotsha::BashCommand) - .to receive(:run!) - .with("git branch --show-current") - .and_return(double("git", text_output: "main")) - - expect(Gotsha::BashCommand) - .to receive(:run!) - .with("git config --get branch.main.pushRemote") - .and_return(git_remote_result) - - expect(Gotsha::BashCommand) - .to receive(:silent_run!) - .with("git push --no-verify --force origin refs/notes/gotsha:refs/notes/gotsha") - .and_return(git_push_result) - - expect(described_class.new.call).to eq("pushed") + let(:git_push_result) { double("git", success?: true) } + + before do + allow(Gotsha::RemoteResolver).to receive(:resolve).and_return(remote_name) end - it "uses branch pushRemote when configured" do - remote_name = "upstream" + context "with default remote" do + let(:remote_name) { "origin" } - expect(Gotsha::BashCommand) - .to receive(:run!) - .with("git branch --show-current") - .and_return(double("git", text_output: "main")) + it "force pushes Git notes" do + expect(Gotsha::BashCommand) + .to receive(:silent_run!) + .with("git push --no-verify --force origin refs/notes/gotsha:refs/notes/gotsha") + .and_return(git_push_result) + + expect(described_class.new.call).to eq("pushed") + end + end - expect(Gotsha::BashCommand) - .to receive(:run!) - .with("git config --get branch.main.pushRemote") - .and_return(double("git", text_output: remote_name)) + context "with custom remote" do + let(:remote_name) { "upstream" } - expect(Gotsha::BashCommand) - .to receive(:silent_run!) - .with("git push --no-verify --force #{remote_name} refs/notes/gotsha:refs/notes/gotsha") - .and_return(git_push_result) + it "uses the resolved remote" do + expect(Gotsha::BashCommand) + .to receive(:silent_run!) + .with("git push --no-verify --force upstream refs/notes/gotsha:refs/notes/gotsha") + .and_return(git_push_result) - expect(described_class.new.call).to eq("pushed") + expect(described_class.new.call).to eq("pushed") + end end end end diff --git a/spec/gotsha/remote_resolver_spec.rb b/spec/gotsha/remote_resolver_spec.rb new file mode 100644 index 00000000..69fd2682 --- /dev/null +++ b/spec/gotsha/remote_resolver_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "open3" + +RSpec.describe Gotsha::RemoteResolver do + describe ".resolve" do + it "returns pushRemote when configured" do + allow(Gotsha::BashCommand) + .to receive(:run!) + .with("git branch --show-current") + .and_return(double("git", text_output: "main")) + + allow(Open3) + .to receive(:capture2) + .with("git", "config", "--get", "branch.main.pushRemote") + .and_return(["upstream\n", double(success?: true)]) + + expect(described_class.resolve).to eq("upstream") + end + + it "falls back to remote when pushRemote is empty" do + allow(Gotsha::BashCommand) + .to receive(:run!) + .with("git branch --show-current") + .and_return(double("git", text_output: "main")) + + allow(Open3) + .to receive(:capture2) + .with("git", "config", "--get", "branch.main.pushRemote") + .and_return(["", double(success?: false)]) + + allow(Open3) + .to receive(:capture2) + .with("git", "config", "--get", "branch.main.remote") + .and_return(["upstream\n", double(success?: true)]) + + expect(described_class.resolve).to eq("upstream") + end + + it "falls back to origin when both pushRemote and remote are empty" do + allow(Gotsha::BashCommand) + .to receive(:run!) + .with("git branch --show-current") + .and_return(double("git", text_output: "main")) + + allow(Open3) + .to receive(:capture2) + .with("git", "config", "--get", "branch.main.pushRemote") + .and_return(["", double(success?: false)]) + + allow(Open3) + .to receive(:capture2) + .with("git", "config", "--get", "branch.main.remote") + .and_return(["", double(success?: false)]) + + expect(described_class.resolve).to eq("origin") + end + + it "returns origin on detached HEAD" do + allow(Gotsha::BashCommand) + .to receive(:run!) + .with("git branch --show-current") + .and_return(double("git", text_output: "")) + + expect(described_class.resolve).to eq("origin") + end + + it "does not pass branch names through a shell" do + malicious_branch = "$(whoami)" + + allow(Gotsha::BashCommand) + .to receive(:run!) + .with("git branch --show-current") + .and_return(double("git", text_output: malicious_branch)) + + expect(Open3) + .to receive(:capture2) + .with("git", "config", "--get", "branch.$(whoami).pushRemote") + .and_return(["origin\n", double(success?: true)]) + + expect(described_class.resolve).to eq("origin") + end + end +end