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" 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/action_dispatcher.rb b/lib/gotsha/action_dispatcher.rb index dcc80a99..bbe15860 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 -h --help].freeze DEFAULT_ACTION = "help" HELP_ACTION_SHORTCUT = "-h" VERSION_ACTION_SHORTCUT = "-v" @@ -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/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/lib/gotsha/actions/fetch.rb b/lib/gotsha/actions/fetch.rb index 216fe945..f6083811 100644 --- a/lib/gotsha/actions/fetch.rb +++ b/lib/gotsha/actions/fetch.rb @@ -6,12 +6,19 @@ 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'") + 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? + raise(Errors::HardFail, "something went wrong") unless command.success? || missing_notes_ref?(command) "fetched" end + + private + + def missing_notes_ref?(command) + command.text_output.include?("couldn't find remote ref refs/notes/gotsha") + end end end end diff --git a/lib/gotsha/actions/push.rb b/lib/gotsha/actions/push.rb index 50f718e7..8d1ecf76 100644 --- a/lib/gotsha/actions/push.rb +++ b/lib/gotsha/actions/push.rb @@ -6,7 +6,8 @@ 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") + 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? 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/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/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/action_dispatcher_spec.rb b/spec/gotsha/action_dispatcher_spec.rb index 27c37b96..e7788942 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 @@ -38,6 +43,8 @@ end describe "with autogenerated config" do + let(:action) { "status" } + before do allow(Gotsha::UserConfig).to receive(:get).and_return(true) @@ -46,7 +53,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 +70,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 +89,46 @@ 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 + + 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 + + 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 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 diff --git a/spec/gotsha/actions/fetch_spec.rb b/spec/gotsha/actions/fetch_spec.rb index d01c721c..9d5d78fe 100644 --- a/spec/gotsha/actions/fetch_spec.rb +++ b/spec/gotsha/actions/fetch_spec.rb @@ -4,13 +4,47 @@ describe "fetch" do let(:git_command_mock) { double("git", success?: true) } - 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) + before do + allow(Gotsha::RemoteResolver).to receive(:resolve).and_return(remote_name) + end + + context "with default remote" do + let(:remote_name) { "origin" } + + 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") + end + end + + context "with custom remote" do + let(:remote_name) { "upstream" } + + 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(described_class.new.call).to eq("fetched") + end + end + + context "when remote notes ref does not exist" do + let(:remote_name) { "origin" } + + 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 b24c0a9b..40e44a2d 100644 --- a/spec/gotsha/actions/push_spec.rb +++ b/spec/gotsha/actions/push_spec.rb @@ -1,16 +1,37 @@ # frozen_string_literal: true RSpec.describe Gotsha::Actions::Push do - let(:git_push_result) { double("git", success?: true) } - describe "push" do - 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) + let(:git_push_result) { double("git", success?: true) } + + before do + allow(Gotsha::RemoteResolver).to receive(:resolve).and_return(remote_name) + end + + context "with default remote" do + let(:remote_name) { "origin" } + + 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 + + context "with custom remote" do + let(:remote_name) { "upstream" } + + 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/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 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 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