Skip to content

Refactor Git::Log to use explicit execution with #execute method #814

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
88 changes: 84 additions & 4 deletions lib/git/log.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,76 @@ 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)
# .max_count(:all)
# .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
Expand All @@ -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`
Expand Down Expand Up @@ -140,39 +209,50 @@ 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


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
Expand Down
3 changes: 3 additions & 0 deletions tests/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down
154 changes: 154 additions & 0 deletions tests/units/test_log_execute.rb
Original file line number Diff line number Diff line change
@@ -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