From 68c14816f64b43e53e0dcdd4b082fec62dc0c65f Mon Sep 17 00:00:00 2001 From: James Couball Date: Wed, 2 Jul 2025 16:09:22 -0700 Subject: [PATCH] feat: add Log#execute to run the log and return an immutable result This partially implements #813 Log data access methods directly on the Log class will return a deprecation warning since they will be removed in the future. --- README.md | 19 ++++ lib/git/log.rb | 88 +++++++++++++++++- tests/test_helper.rb | 3 + tests/units/test_log_execute.rb | 154 ++++++++++++++++++++++++++++++++ 4 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 tests/units/test_log_execute.rb diff --git a/README.md b/README.md index f62b42f9..ac319337 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ - [Major Objects](#major-objects) - [Errors Raised By This Gem](#errors-raised-by-this-gem) - [Specifying And Handling Timeouts](#specifying-and-handling-timeouts) +- [Deprecations](#deprecations) - [Examples](#examples) - [Ruby version support policy](#ruby-version-support-policy) - [License](#license) @@ -202,6 +203,24 @@ rescue Git::TimeoutError => e end ``` +## Deprecations + +This gem uses ActiveSupport's deprecation mechanism to report deprecation warnings. + +You can silence deprecation warnings by adding this line to your source code: + +```ruby +Git::Deprecation.behavior = :silence +``` + +See [the Active Support Deprecation +documentation](https://api.rubyonrails.org/classes/ActiveSupport/Deprecation.html) +for more details. + +If deprecation warnings are silenced, you should reenable them before upgrading the +git gem to the next major version. This will make it easier to identify changes +needed for the upgrade. + ## Examples Here are a bunch of examples of how to use the Ruby/Git package. diff --git a/lib/git/log.rb b/lib/git/log.rb index 2c0e89f8..3b49e918 100644 --- a/lib/git/log.rb +++ b/lib/git/log.rb @@ -6,13 +6,13 @@ module Git # # @example The last (default number) of commits # git = Git.open('.') - # Git::Log.new(git) #=> Enumerable of the last 30 commits + # Git::Log.new(git).execute #=> Enumerable of the last 30 commits # # @example The last n commits - # Git::Log.new(git).max_commits(50) #=> Enumerable of last 50 commits + # Git::Log.new(git).max_commits(50).execute #=> Enumerable of last 50 commits # # @example All commits returned by `git log` - # Git::Log.new(git).max_count(:all) #=> Enumerable of all commits + # Git::Log.new(git).max_count(:all).execute #=> Enumerable of all commits # # @example All commits that match complex criteria # Git::Log.new(git) @@ -20,12 +20,62 @@ module Git # .object('README.md') # .since('10 years ago') # .between('v1.0.7', 'HEAD') + # .execute # # @api public # class Log include Enumerable + # An immutable collection of commits returned by Git::Log#execute + # + # This object is an Enumerable that contains Git::Object::Commit objects. + # It provides methods to access the commit data without executing any + # further git commands. + # + # @api public + class Result + include Enumerable + + # @private + def initialize(commits) + @commits = commits + end + + # @return [Integer] the number of commits in the result set + def size + @commits.size + end + + # Iterates over each commit in the result set + # + # @yield [Git::Object::Commit] + def each(&block) + @commits.each(&block) + end + + # @return [Git::Object::Commit, nil] the first commit in the result set + def first + @commits.first + end + + # @return [Git::Object::Commit, nil] the last commit in the result set + def last + @commits.last + end + + # @param index [Integer] the index of the commit to return + # @return [Git::Object::Commit, nil] the commit at the given index + def [](index) + @commits[index] + end + + # @return [String] a string representation of the log + def to_s + map { |c| c.to_s }.join("\n") + end + end + # Create a new Git::Log object # # @example @@ -44,6 +94,25 @@ def initialize(base, max_count = 30) max_count(max_count) end + # Executes the git log command and returns an immutable result object. + # + # This is the preferred way to get log data. It separates the query + # building from the execution, making the API more predictable. + # + # @example + # query = g.log.since('2 weeks ago').author('Scott') + # results = query.execute + # puts "Found #{results.size} commits" + # results.each do |commit| + # # ... + # end + # + # @return [Git::Log::Result] an object containing the log results + def execute + run_log + Result.new(@commits) + end + # The maximum number of commits to return # # @example All commits returned by `git log` @@ -140,32 +209,39 @@ def merges end def to_s - self.map { |c| c.to_s }.join("\n") + deprecate_method(__method__) + check_log + @commits.map { |c| c.to_s }.join("\n") end # forces git log to run def size + deprecate_method(__method__) check_log @commits.size rescue nil end def each(&block) + deprecate_method(__method__) check_log @commits.each(&block) end def first + deprecate_method(__method__) check_log @commits.first rescue nil end def last + deprecate_method(__method__) check_log @commits.last rescue nil end def [](index) + deprecate_method(__method__) check_log @commits[index] rescue nil end @@ -173,6 +249,10 @@ def [](index) private + def deprecate_method(method_name) + Git::Deprecation.warn("Calling Git::Log##{method_name} is deprecated and will be removed in a future version. Call #execute and then ##{method_name} on the result object.") + end + def dirty_log @dirty_flag = true end diff --git a/tests/test_helper.rb b/tests/test_helper.rb index 7378db7a..39033732 100644 --- a/tests/test_helper.rb +++ b/tests/test_helper.rb @@ -12,6 +12,9 @@ $stdout.sync = true $stderr.sync = true +# Silence deprecation warnings during tests +Git::Deprecation.behavior = :silence + class Test::Unit::TestCase TEST_ROOT = File.expand_path(__dir__) diff --git a/tests/units/test_log_execute.rb b/tests/units/test_log_execute.rb new file mode 100644 index 00000000..42bfd347 --- /dev/null +++ b/tests/units/test_log_execute.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require 'logger' +require 'test_helper' + +# Tests for the Git::Log#execute method +class TestLogExecute < Test::Unit::TestCase + def setup + clone_working_repo + #@git = Git.open(@wdir, :log => Logger.new(STDOUT)) + @git = Git.open(@wdir) + end + + def test_log_max_count_default + assert_equal(30, @git.log.execute.size) + end + + # In these tests, note that @git.log(n) is equivalent to @git.log.max_count(n) + def test_log_max_count_20 + assert_equal(20, @git.log(20).execute.size) + assert_equal(20, @git.log.max_count(20).execute.size) + end + + def test_log_max_count_nil + assert_equal(72, @git.log(nil).execute.size) + assert_equal(72, @git.log.max_count(nil).execute.size) + end + + def test_log_max_count_all + assert_equal(72, @git.log(:all).execute.size) + assert_equal(72, @git.log.max_count(:all).execute.size) + end + + # Note that @git.log.all does not control the number of commits returned. For that, + # use @git.log.max_count(n) + def test_log_all + assert_equal(72, @git.log(100).execute.size) + assert_equal(76, @git.log(100).all.execute.size) + end + + def test_log_non_integer_count + assert_raises(ArgumentError) { @git.log('foo').execute } + end + + def test_get_first_and_last_entries + log = @git.log.execute + assert(log.first.is_a?(Git::Object::Commit)) + assert_equal('46abbf07e3c564c723c7c039a43ab3a39e5d02dd', log.first.objectish) + + assert(log.last.is_a?(Git::Object::Commit)) + assert_equal('b03003311ad3fa368b475df58390353868e13c91', log.last.objectish) + end + + def test_get_log_entries + assert_equal(30, @git.log.execute.size) + assert_equal(50, @git.log(50).execute.size) + assert_equal(10, @git.log(10).execute.size) + end + + def test_get_log_to_s + log = @git.log.execute + assert_equal(log.to_s.split("\n").first, log.first.sha) + end + + def test_log_skip + three1 = @git.log(3).execute.to_a[-1] + three2 = @git.log(2).skip(1).execute.to_a[-1] + three3 = @git.log(1).skip(2).execute.to_a[-1] + assert_equal(three2.sha, three3.sha) + assert_equal(three1.sha, three2.sha) + end + + def test_get_log_since + l = @git.log.since("2 seconds ago").execute + assert_equal(0, l.size) + + l = @git.log.since("#{Date.today.year - 2006} years ago").execute + assert_equal(30, l.size) + end + + def test_get_log_grep + l = @git.log.grep("search").execute + assert_equal(2, l.size) + end + + def test_get_log_author + l = @git.log(5).author("chacon").execute + assert_equal(5, l.size) + l = @git.log(5).author("lazySusan").execute + assert_equal(0, l.size) + end + + def test_get_log_since_file + l = @git.log.path('example.txt').execute + assert_equal(30, l.size) + + l = @git.log.between('v2.5', 'test').path('example.txt').execute + assert_equal(1, l.size) + end + + def test_get_log_path + log = @git.log.path('example.txt').execute + assert_equal(30, log.size) + log = @git.log.path('example*').execute + assert_equal(30, log.size) + log = @git.log.path(['example.txt','scott/text.txt']).execute + assert_equal(30, log.size) + end + + def test_log_file_noexist + assert_raise Git::FailedError do + @git.log.object('no-exist.txt').execute + end + end + + def test_log_with_empty_commit_message + Dir.mktmpdir do |dir| + git = Git.init(dir) + expected_message = 'message' + git.commit(expected_message, { allow_empty: true }) + git.commit('', { allow_empty: true, allow_empty_message: true }) + log = git.log.execute + assert_equal(2, log.to_a.size) + assert_equal('', log[0].message) + assert_equal(expected_message, log[1].message) + end + end + + def test_log_cherry + l = @git.log.between( 'master', 'cherry').cherry.execute + assert_equal( 1, l.size ) + end + + def test_log_merges + expected_command_line = ['log', '--max-count=30', '--no-color', '--pretty=raw', '--merges', {chdir: nil}] + assert_command_line_eq(expected_command_line) { |git| git.log.merges.execute } + end + + def test_execute_returns_immutable_results + log_query = @git.log(10) + initial_results = log_query.execute + assert_equal(10, initial_results.size) + + # Modify the original query object + log_query.max_count(5) + new_results = log_query.execute + + # The initial result set should not have changed + assert_equal(10, initial_results.size) + + # The new result set should reflect the change + assert_equal(5, new_results.size) + end +end