diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 06a0a8b..9cc33dd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,6 +16,8 @@ jobs: ruby: - 3.0.3 - 3.1.0 + - 3.2.2 + - 3.3.2 steps: - uses: actions/checkout@v2 diff --git a/.rubocop.yml b/.rubocop.yml index 60a7e12..9ece655 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -13,11 +13,13 @@ AllCops: Bundler/GemVersion: { EnforcedStyle: forbidden } Metrics/BlockLength: { Exclude: ["spec/**/*_spec.rb"] } +RSpec/MessageExpectation: { EnforcedStyle: expect } Style/ClassAndModuleChildren: { EnforcedStyle: compact } Style/MethodCallWithArgsParentheses: AllowedMethods: - abort + - and - describe - not_to - raise @@ -38,6 +40,8 @@ Layout/LineLength: Bundler/GemComment: { Enabled: false } Lint/ConstantResolution: { Enabled: false } +RSpec/StubbedMock: { Enabled: false } Style/ConstantVisibility: { Enabled: false } Style/Copyright: { Enabled: false } Style/InlineComment: { Enabled: false } +Style/MissingElse: { Enabled: false } diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 850c023..73fb175 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config --auto-gen-only-exclude --exclude-limit 400` -# on 2023-09-18 23:50:42 UTC using RuboCop version 1.56.2. +# on 2024-06-12 23:47:02 UTC using RuboCop version 1.64.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -14,6 +14,27 @@ Bundler/GemVersion: Exclude: - 'Gemfile' +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. +# URISchemes: http, https +Layout/LineLength: + Exclude: + - 'lib/baes/actions/bisect.rb' + +# Offense count: 2 +# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max. +Metrics/AbcSize: + Exclude: + - 'lib/baes/actions/bisect.rb' + +# Offense count: 4 +# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns. +Metrics/MethodLength: + Exclude: + - 'lib/baes/actions/bisect.rb' + - 'lib/baes/actions/run.rb' + # Offense count: 1 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. @@ -22,43 +43,38 @@ RSpec/BeNil: Exclude: - 'spec/baes_spec.rb' -# Offense count: 11 +# Offense count: 16 # Configuration parameters: Max, CountAsOne. RSpec/ExampleLength: Exclude: + - 'spec/baes/actions/bisect_spec.rb' - 'spec/baes/actions/build_tree_spec.rb' - 'spec/baes/actions/rebase_spec.rb' - 'spec/baes/branch_spec.rb' # Offense count: 1 -# Configuration parameters: Include, CustomTransform, IgnoreMethods, SpecSuffixOnly. -# Include: **/*_spec*rb*, **/spec/**/* -RSpec/FilePath: - Exclude: - - 'spec/baes/actions/load_configuration_spec.rb' - -# Offense count: 9 # Configuration parameters: EnforcedStyle. # SupportedStyles: allow, expect RSpec/MessageExpectation: Exclude: - - 'spec/baes/git_spec.rb' + - 'spec/support/stub_system.rb' -# Offense count: 9 +# Offense count: 11 # Configuration parameters: . # SupportedStyles: have_received, receive RSpec/MessageSpies: EnforcedStyle: receive -# Offense count: 6 +# Offense count: 5 # Configuration parameters: Max. RSpec/MultipleExpectations: Exclude: - 'spec/baes/actions/build_tree_spec.rb' - - 'spec/baes/actions/load_configuration_spec.rb' - 'spec/baes/git_spec.rb' -# Offense count: 9 -RSpec/StubbedMock: +# Offense count: 1 +# Configuration parameters: AllowedPatterns. +# AllowedPatterns: ^expect_, ^assert_ +RSpec/NoExpectationExample: Exclude: - - 'spec/baes/git_spec.rb' + - 'spec/baes/actions/bisect_spec.rb' diff --git a/lib/baes/actions.rb b/lib/baes/actions.rb index 82a1d05..9b81d65 100644 --- a/lib/baes/actions.rb +++ b/lib/baes/actions.rb @@ -3,7 +3,9 @@ # namespace for callable modules module Baes::Actions; end +require_relative "actions/bisect" require_relative "actions/build_tree" require_relative "actions/load_configuration" +require_relative "actions/load_rebase_configuration" require_relative "actions/rebase" require_relative "actions/run" diff --git a/lib/baes/actions/bisect.rb b/lib/baes/actions/bisect.rb new file mode 100644 index 0000000..9fcf11c --- /dev/null +++ b/lib/baes/actions/bisect.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +# class that will attempt to locate the first branch in the current chain that +# fails with a given command +module Baes::Actions::Bisect + class << self + include Baes::Configuration::Helpers + + # run the command and return the first branch that fails + def call(command_args) + output.puts("searching for branch that fails command: `#{command_args}`") + branches = generate_branches + root_branch = find_root_branch(branches) + Baes::Actions::BuildTree.call(branches, root_branch: root_branch) + + current_branch_name = git.current_branch_name + current_branch = + branches.find { |branch| branch.name == current_branch_name } + + _, _, status = Open3.capture3(command_args) + + if status.success? + raise ArgumentError, "command must fail to find failure" + end + + fail_branch = + find_failing_branch(root_branch, current_branch, command_args) + + git.checkout(fail_branch.name) + output.puts("first failing branch: #{fail_branch.name}") + end + + private + + def generate_branches + git.branch_names.map do |branch_name| + Baes::Branch.new(branch_name) + end + end + + def find_root_branch(branches) + if root_name + branches.find { |branch| branch.name == root_name } + else + branches.find { |branch| ["main", "master"].include?(branch.name) } + end + end + + def find_failing_branch(success_branch, fail_branch, command_args) + next_branch = find_middle_branch(success_branch, fail_branch) + + return fail_branch if next_branch == fail_branch || next_branch.nil? + + git.checkout(next_branch.name) + _, _, status = Open3.capture3(command_args) + + if status.success? + output.puts("branch #{next_branch.name} succeeded with command `#{command_args}`") + find_failing_branch(next_branch, fail_branch, command_args) + else + output.puts("branch #{next_branch.name} failed with command `#{command_args}`") + find_failing_branch(success_branch, next_branch, command_args) + end + end + + def find_middle_branch(success_branch, fail_branch) + end_number = fail_branch.number + start_number = success_branch.number + + child_branch = + success_branch.children.find do |next_child_branch| + next_child_branch.base_name == fail_branch.base_name + end + + start_number ||= child_branch.number + + middle_number = (start_number + end_number) / 2 + + next_branch = child_branch + + while next_branch.number < middle_number + next_branch = + next_branch.children.find do |next_child_branch| + next_child_branch.base_name == fail_branch.base_name + end + end + + next_branch + end + end +end diff --git a/lib/baes/actions/load_configuration.rb b/lib/baes/actions/load_configuration.rb index df10e61..10881fa 100644 --- a/lib/baes/actions/load_configuration.rb +++ b/lib/baes/actions/load_configuration.rb @@ -9,49 +9,18 @@ class << self def call(options) parser = OptionParser.new - configure_dry_run(parser) configure_help(parser) - configure_root_name(parser) - configure_auto_skip(parser) - configure_ignored_branches(parser) parser.parse(options) end private - def configure_dry_run(parser) - parser.on("--dry-run", "prints branch chain without rebasing") do - Baes::Configuration.dry_run = true - end - end - def configure_help(parser) parser.on("-h", "--help", "prints this help") do Baes::Configuration.output.puts(parser) exit end end - - def configure_root_name(parser) - message = "specify a root branch to rebase on" - parser.on("-r", "--root ROOT", message) do |root_name| - Baes::Configuration.root_name = root_name - end - end - - def configure_auto_skip(parser) - message = "automatically skip all but the most recent commit" - parser.on("--auto-skip", message) do - Baes::Configuration.auto_skip = true - end - end - - def configure_ignored_branches(parser) - message = "don't rebase specified branches or their child branches" - parser.on("--ignore BRANCH1,BRANCH2", Array, message) do |branches| - Baes::Configuration.ignored_branch_names += branches - end - end end end diff --git a/lib/baes/actions/load_rebase_configuration.rb b/lib/baes/actions/load_rebase_configuration.rb new file mode 100644 index 0000000..c7473a9 --- /dev/null +++ b/lib/baes/actions/load_rebase_configuration.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "optparse" + +# callable module to load configuration +module Baes::Actions::LoadRebaseConfiguration + class << self + # loads options, typically passed via the command line + def call(options) + parser = OptionParser.new + + configure_dry_run(parser) + configure_help(parser) + configure_root_name(parser) + configure_auto_skip(parser) + configure_ignored_branches(parser) + + parser.parse(options) + end + + private + + def configure_dry_run(parser) + parser.on("--dry-run", "prints branch chain without rebasing") do + Baes::Configuration.dry_run = true + end + end + + def configure_help(parser) + parser.on("-h", "--help", "prints this help") do + Baes::Configuration.output.puts(parser) + exit + end + end + + def configure_root_name(parser) + message = "specify a root branch to rebase on" + parser.on("-r", "--root ROOT", message) do |root_name| + Baes::Configuration.root_name = root_name + end + end + + def configure_auto_skip(parser) + message = "automatically skip all but the most recent commit" + parser.on("--auto-skip", message) do + Baes::Configuration.auto_skip = true + end + end + + def configure_ignored_branches(parser) + message = "don't rebase specified branches or their child branches" + parser.on("--ignore BRANCH1,BRANCH2", Array, message) do |branches| + Baes::Configuration.ignored_branch_names += branches + end + end + end +end diff --git a/lib/baes/actions/run.rb b/lib/baes/actions/run.rb index 27d2296..c1cd5cb 100644 --- a/lib/baes/actions/run.rb +++ b/lib/baes/actions/run.rb @@ -4,8 +4,19 @@ module Baes::Actions::Run # parse options and execute command def self.call(options) - Baes::Actions::LoadConfiguration.call(options) + case options.first + when "bisect" + Baes::Actions::Bisect.call(options[1..].join(" ")) + when "rebase" + Baes::Actions::LoadRebaseConfiguration.call(options) - Baes::Actions::Rebase.call + Baes::Actions::Rebase.call + when nil + Baes::Actions::LoadConfiguration.call(["-h"]) + when /^-/ + Baes::Actions::LoadConfiguration.call(options) + else + abort("Invalid command #{options.inspect}") + end end end diff --git a/lib/baes/git.rb b/lib/baes/git.rb index 6b17a80..603872e 100644 --- a/lib/baes/git.rb +++ b/lib/baes/git.rb @@ -31,6 +31,17 @@ def self.rebase(branch_name) status end + # get current branch name and raise on error + def self.current_branch_name + stdout, stderr, status = Open3.capture3("git rev-parse --abbrev-ref HEAD") + + output.puts(stderr) unless stderr.empty? + + raise GitError, "failed to get current branch" unless status.success? + + stdout.strip + end + # list branch names and raise on failure def self.branch_names stdout, stderr, status = Open3.capture3("git branch") diff --git a/spec/baes/actions/bisect_spec.rb b/spec/baes/actions/bisect_spec.rb new file mode 100644 index 0000000..3c8e298 --- /dev/null +++ b/spec/baes/actions/bisect_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +RSpec.describe Baes::Actions::Bisect do + include Baes::Configuration::Helpers + + def stub3(command, stdout: "", stderr: "", success: true) + status = instance_double(Process::Status, success?: success) + result = [stdout, stderr, status] + expect(Open3).to receive(:capture3).with(command).and_return(result) + end + + it "raises an error when command does not fail" do + FakeGit.branch_names = ["main", "my_branch_1", "my_branch_2"] + stub3(["echo 'foo'"]) + + expect { described_class.call(["echo 'foo'"]) } + .to raise_error(ArgumentError, "command must fail to find failure") + end + + it "checks out the first failing branch" do + FakeGit.branch_names = ["main", "my_branch_1", "my_branch_2"] + FakeGit.current_branch_name = "my_branch_2" + stub3(["echo 'foo'"], success: false) + stub3(["echo 'foo'"], success: false) + + described_class.call(["echo 'foo'"]) + + expect(FakeGit.current_branch_name).to eq("my_branch_1") + end + + it "prints the first failing branch" do + FakeGit.branch_names = ["main", "my_branch_1", "my_branch_2"] + FakeGit.current_branch_name = "my_branch_2" + stub3(["echo 'foo'"], success: false) + stub3(["echo 'foo'"], success: false) + + described_class.call(["echo 'foo'"]) + + expect(output.string).to include("first failing branch: my_branch_1\n") + end + + it "uses the configured root branch" do + FakeGit.branch_names = ["main", "my_branch_1", "my_branch_2"] + FakeGit.current_branch_name = "my_branch_2" + Baes::Configuration.root_name = "main" + stub3(["echo 'foo'"], success: false) + stub3(["echo 'foo'"], success: false) + + described_class.call(["echo 'foo'"]) + end + + it "checks out the failed branch" do + FakeGit.branch_names = ["main", "my_branch_1", "my_branch_2"] + FakeGit.current_branch_name = "my_branch_2" + stub3(["echo 'foo'"], success: false) + stub3(["echo 'foo'"], success: true) + + described_class.call(["echo 'foo'"]) + + expect(FakeGit.current_branch_name).to eq("my_branch_2") + end + + it "jumps forward when current branch is success" do + FakeGit.branch_names = ["main", "my_branch_1", "my_branch_2", "my_branch_3"] + FakeGit.current_branch_name = "my_branch_3" + stub3(["echo 'foo'"], success: false) + stub3(["echo 'foo'"], success: true) + + described_class.call(["echo 'foo'"]) + + expect(FakeGit.current_branch_name).to eq("my_branch_3") + end +end diff --git a/spec/baes/actions/load_configuration_spec.rb b/spec/baes/actions/load_rebase_configuration_spec.rb similarity index 86% rename from spec/baes/actions/load_configuration_spec.rb rename to spec/baes/actions/load_rebase_configuration_spec.rb index d180d3c..07ac366 100644 --- a/spec/baes/actions/load_configuration_spec.rb +++ b/spec/baes/actions/load_rebase_configuration_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Baes::Actions::LoadConfiguration, "#call" do +RSpec.describe Baes::Actions::LoadRebaseConfiguration do it "sets the dry_run configuration given --dry-run" do described_class.call(["--dry-run"]) @@ -10,8 +10,7 @@ it "displays the help and exits when given -h" do expect { described_class.call(["-h"]) } .to raise_error(SystemExit) - - expect(Baes::Configuration.output.string).to include("prints this help") + .and output(/prints this help/).to_configured_output end it "sets the root_name configuration given --root" do diff --git a/spec/baes/actions/run_spec.rb b/spec/baes/actions/run_spec.rb index 4887c6d..06b247b 100644 --- a/spec/baes/actions/run_spec.rb +++ b/spec/baes/actions/run_spec.rb @@ -1,22 +1,47 @@ # frozen_string_literal: true RSpec.describe Baes::Actions::Run do + def stub3(command, stdout: "", stderr: "", success: true) + status = instance_double(Process::Status, success?: success) + result = [stdout, stderr, status] + expect(Open3).to receive(:capture3).with(command).and_return(result) + end + describe ".call" do it "loads configuration" do - FakeGit.branch_names = ["main", "my_branch"] - options = ["--dry-run"] + expect { described_class.call(["-h"]) } + .to raise_error(SystemExit) + .and output(/prints this help/).to_configured_output + end - described_class.call(options) + it "displays help when no arguments are given" do + expect { described_class.call([]) } + .to raise_error(SystemExit) + .and output(/prints this help/).to_configured_output + end - expect(Baes::Configuration.dry_run?).to be(true) + it "bisects when given the bisect command" do + FakeGit.branch_names = ["main", "my_branch_1"] + FakeGit.current_branch_name = "my_branch_1" + stub3("foo", success: false) + + described_class.call(["bisect", "foo"]) + + expect(FakeGit.current_branch_name).to eq("my_branch_1") end - it "rebases branches" do + it "rebases branches when given the rebase command" do FakeGit.branch_names = ["main", "my_branch"] - described_class.call([]) + described_class.call(["rebase"]) expect(FakeGit.rebases).to eq([["my_branch", "main"]]) end + + it "raises an error when given an invalid command" do + expect { described_class.call(["foo"]) } + .to raise_error(SystemExit) + .and output("Invalid command [\"foo\"]\n").to_stderr + end end end diff --git a/spec/baes/git_spec.rb b/spec/baes/git_spec.rb index d96ef47..1ac7bff 100644 --- a/spec/baes/git_spec.rb +++ b/spec/baes/git_spec.rb @@ -60,6 +60,31 @@ def stub3(command, stdout: "", stderr: "", success: true) end end + describe "#current_branch_name" do + it "prints stderr when present" do + stub3("git rev-parse --abbrev-ref HEAD", stderr: "error") + + described_class.current_branch_name + + expect(output.string).to eq("error\n") + end + + it "raises an error when status is not success" do + stub3("git rev-parse --abbrev-ref HEAD", success: false) + + expect { described_class.current_branch_name } + .to raise_error("failed to get current branch") + end + + it "returns the current branch name from stdout" do + stub3("git rev-parse --abbrev-ref HEAD", stdout: "main") + + result = described_class.current_branch_name + + expect(result).to eq("main") + end + end + describe "#branch_names" do it "prints stderr when present" do stub3("git branch", stdout: "out", stderr: "error") diff --git a/spec/matchers/capture_configured_output_spec.rb b/spec/matchers/capture_configured_output_spec.rb new file mode 100644 index 0000000..d445e01 --- /dev/null +++ b/spec/matchers/capture_configured_output_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +RSpec.describe CaptureConfiguredOutput do + describe ".name" do + it "returns the name of the matcher" do + expect(described_class.name).to eq("configured output") + end + end + + describe ".capture" do + it "captures the output of a block" do + output = + described_class.capture(-> { Baes::Configuration.output.puts("foo") }) + + expect(output).to eq("foo\n") + end + + it "does not modify the original output stream" do + described_class.capture(-> { Baes::Configuration.output.puts("foo") }) + + expect(Baes::Configuration.output.string).to be_empty + end + + it "restores the original output stream when an error occurs" do + begin + described_class.capture(-> { raise StandardError, "error" }) + rescue StandardError + # noop + end + + expect(Baes::Configuration.output.string).to be_empty + end + end +end diff --git a/spec/support/coverage.rb b/spec/support/coverage.rb index 667a4a4..bbbab3e 100644 --- a/spec/support/coverage.rb +++ b/spec/support/coverage.rb @@ -5,6 +5,7 @@ SimpleCov.start do enable_coverage :branch add_filter "_spec.rb" + add_filter "spec/support/stub_system.rb" add_group "Lib", "lib" add_group "Support", "spec/support" end diff --git a/spec/support/fake_git.rb b/spec/support/fake_git.rb index b90cb9c..347c255 100644 --- a/spec/support/fake_git.rb +++ b/spec/support/fake_git.rb @@ -26,7 +26,7 @@ def self.rebase(base_branch_name) end def self.branch_names - @branch_names ||= "" + @branch_names ||= [] end def self.rebase_skip diff --git a/spec/support/matchers.rb b/spec/support/matchers.rb index e8e51c0..2916336 100644 --- a/spec/support/matchers.rb +++ b/spec/support/matchers.rb @@ -6,4 +6,5 @@ def rebase(branch_name) end end +require_relative "matchers/capture_configured_output" require_relative "matchers/rebase" diff --git a/spec/support/matchers/capture_configured_output.rb b/spec/support/matchers/capture_configured_output.rb new file mode 100644 index 0000000..8078fc0 --- /dev/null +++ b/spec/support/matchers/capture_configured_output.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module CaptureConfiguredOutput + def self.name + "configured output" + end + + def self.capture(block) + old_output = Baes::Configuration.output + new_output = StringIO.new + Baes::Configuration.output = new_output + + block.call + + new_output.string + ensure + Baes::Configuration.output = old_output + end +end + +module Matchers::OutputExtensions + def to_configured_output + @stream_capturer = CaptureConfiguredOutput + self + end +end + +RSpec::Matchers::BuiltIn::Output.include(Matchers::OutputExtensions) diff --git a/spec/support/stub_system.rb b/spec/support/stub_system.rb index 7302687..8d76a03 100644 --- a/spec/support/stub_system.rb +++ b/spec/support/stub_system.rb @@ -23,4 +23,11 @@ def system(command) Baes::Configuration.dry_run = false Baes::Configuration.auto_skip = false end + + config.around do |example| + example.run + rescue SystemExit => e + puts(e.backtrace) + raise StandardError, "uncaught SystemExit" + end end