diff --git a/app/models/repository.rb b/app/models/repository.rb
index 3ac30af7d3e7600216d36b6c3911a2a17af2b352..8d2f0648a05f241376e2d762807cc9c2b2f8bc4a 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -161,8 +161,8 @@ def commits(ref = nil, opts = {})
     CommitCollection.new(container, commits, ref)
   end
 
-  def commits_between(from, to)
-    commits = Gitlab::Git::Commit.between(raw_repository, from, to)
+  def commits_between(from, to, limit: nil)
+    commits = Gitlab::Git::Commit.between(raw_repository, from, to, limit: limit)
     commits = Commit.decorate(commits, container) if commits.present?
     commits
   end
diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb
index aee2f685e97d9720bd90a66ac22042a6be5a663d..63f3f73905afe2bf81e6ffa90e93d2c4c8220abd 100644
--- a/app/services/git/base_hooks_service.rb
+++ b/app/services/git/base_hooks_service.rb
@@ -25,17 +25,13 @@ def hook_name
       raise NotImplementedError, "Please implement #{self.class}##{__method__}"
     end
 
-    # The changeset, ordered with the newest commit last
-    def commits
-      raise NotImplementedError, "Please implement #{self.class}##{__method__}"
-    end
-
+    # This should return PROCESS_COMMIT_LIMIT commits, ordered with newest last
     def limited_commits
-      @limited_commits ||= commits.last(PROCESS_COMMIT_LIMIT)
+      raise NotImplementedError, "Please implement #{self.class}##{__method__}"
     end
 
     def commits_count
-      commits.count
+      raise NotImplementedError, "Please implement #{self.class}##{__method__}"
     end
 
     def event_message
diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb
index 7a22d7ffcdfd45715ad23388795d04ffdf3b8ef8..9b113be5465dec245a19c516476b3b551d417f09 100644
--- a/app/services/git/branch_hooks_service.rb
+++ b/app/services/git/branch_hooks_service.rb
@@ -18,20 +18,25 @@ def hook_name
       :push_hooks
     end
 
-    def commits
-      strong_memoize(:commits) do
+    def limited_commits
+      strong_memoize(:limited_commits) { threshold_commits.last(PROCESS_COMMIT_LIMIT) }
+    end
+
+    # Taking limit+1 commits allows us to detect when the limit is in effect
+    def threshold_commits
+      strong_memoize(:threshold_commits) do
         if creating_default_branch?
           # The most recent PROCESS_COMMIT_LIMIT commits in the default branch.
           # They are returned newest-to-oldest, but we need to present them oldest-to-newest
-          project.repository.commits(newrev, limit: PROCESS_COMMIT_LIMIT).reverse
+          project.repository.commits(newrev, limit: PROCESS_COMMIT_LIMIT + 1).reverse!
         elsif creating_branch?
           # Use the pushed commits that aren't reachable by the default branch
           # as a heuristic. This may include more commits than are actually
           # pushed, but that shouldn't matter because we check for existing
           # cross-references later.
-          project.repository.commits_between(project.default_branch, newrev)
+          project.repository.commits_between(project.default_branch, newrev, limit: PROCESS_COMMIT_LIMIT + 1)
         elsif updating_branch?
-          project.repository.commits_between(oldrev, newrev)
+          project.repository.commits_between(oldrev, newrev, limit: PROCESS_COMMIT_LIMIT + 1)
         else # removing branch
           []
         end
@@ -39,9 +44,21 @@ def commits
     end
 
     def commits_count
-      return count_commits_in_branch if creating_default_branch?
+      strong_memoize(:commits_count) do
+        next threshold_commits.count if
+          strong_memoized?(:threshold_commits) &&
+          threshold_commits.count <= PROCESS_COMMIT_LIMIT
 
-      super
+        if creating_default_branch?
+          project.repository.commit_count_for_ref(ref)
+        elsif creating_branch?
+          project.repository.count_commits_between(project.default_branch, newrev)
+        elsif updating_branch?
+          project.repository.count_commits_between(oldrev, newrev)
+        else # removing branch
+          0
+        end
+      end
     end
 
     override :invalidated_file_types
@@ -179,12 +196,6 @@ def creating_default_branch?
       creating_branch? && default_branch?
     end
 
-    def count_commits_in_branch
-      strong_memoize(:count_commits_in_branch) do
-        project.repository.commit_count_for_ref(ref)
-      end
-    end
-
     def default_branch?
       strong_memoize(:default_branch) do
         [nil, branch_name].include?(project.default_branch)
diff --git a/app/services/git/tag_hooks_service.rb b/app/services/git/tag_hooks_service.rb
index d83924fec28d91093457f19533ca822b5997032c..01174d8a94250d8ca728a6e7998a473c9df8f053 100644
--- a/app/services/git/tag_hooks_service.rb
+++ b/app/services/git/tag_hooks_service.rb
@@ -8,10 +8,14 @@ def hook_name
       :tag_push_hooks
     end
 
-    def commits
+    def limited_commits
       [tag_commit].compact
     end
 
+    def commits_count
+      limited_commits.count
+    end
+
     def event_message
       tag&.message
     end
diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb
index 49fe365e09eb05e03750f801aaf2b57a72d95598..6605e896ef1d4306dead127d2f0cfc513a5e204a 100644
--- a/lib/gitlab/git/commit.rb
+++ b/lib/gitlab/git/commit.rb
@@ -108,17 +108,26 @@ def last_for_path(repo, ref, path = nil, literal_pathspec: false)
         # See also #repository.commits_between
         #
         # Ex.
-        #   Commit.between(repo, '29eda46b', 'master')
+        #   Commit.between(repo, '29eda46b', 'master') # all commits, ordered oldest to newest
+        #   Commit.between(repo, '29eda46b', 'master', limit: 100) # 100 newest commits, ordered oldest to newest
         #
-        def between(repo, base, head)
+        def between(repo, base, head, limit: nil)
           # In either of these cases, we are guaranteed to return no commits, so
           # shortcut the RPC call
           return [] if Gitlab::Git.blank_ref?(base) || Gitlab::Git.blank_ref?(head)
 
           wrapped_gitaly_errors do
             revisions = [head, "^#{base}"] # base..head
-
-            repo.gitaly_commit_client.list_commits(revisions, reverse: true)
+            client = repo.gitaly_commit_client
+
+            # We must return the commits in chronological order but using both
+            # limit and reverse in the Gitaly RPC would return the oldest N,
+            # rather than newest N, commits, so reorder in Ruby with limit
+            if limit
+              client.list_commits(revisions, pagination_params: { limit: limit }).reverse!
+            else
+              client.list_commits(revisions, reverse: true)
+            end
           end
         end
 
diff --git a/lib/gitlab/gitaly_client/commit_service.rb b/lib/gitlab/gitaly_client/commit_service.rb
index fa616a252e4b246e1bca6507a6ee2cd0b7a567f2..ddf04bd8e79d79743e717dd16f06f2e9d5084d5a 100644
--- a/lib/gitlab/gitaly_client/commit_service.rb
+++ b/lib/gitlab/gitaly_client/commit_service.rb
@@ -255,11 +255,12 @@ def find_all_commits(opts = {})
         consume_commits_response(response)
       end
 
-      def list_commits(revisions, reverse: false)
+      def list_commits(revisions, reverse: false, pagination_params: nil)
         request = Gitaly::ListCommitsRequest.new(
           repository: @gitaly_repo,
           revisions: Array.wrap(revisions),
-          reverse: reverse
+          reverse: reverse,
+          pagination_params: pagination_params
         )
 
         response = GitalyClient.call(@repository.storage, :commit_service, :list_commits, request, timeout: GitalyClient.medium_timeout)
diff --git a/spec/lib/gitlab/git/commit_spec.rb b/spec/lib/gitlab/git/commit_spec.rb
index 174ed43bd401dd910e2ac80e6bafec9453c3e53e..f4dba5e8d586978352f16237f3df7fec4f2c6124 100644
--- a/spec/lib/gitlab/git/commit_spec.rb
+++ b/spec/lib/gitlab/git/commit_spec.rb
@@ -364,12 +364,40 @@
     end
 
     describe '.between' do
-      subject do
-        commits = described_class.between(repository, SeedRepo::Commit::PARENT_ID, SeedRepo::Commit::ID)
-        commits.map { |c| c.id }
+      let(:limit) { nil }
+      let(:commit_ids) { commits.map(&:id) }
+
+      subject(:commits) { described_class.between(repository, from, to, limit: limit) }
+
+      context 'requesting a single commit' do
+        let(:from) { SeedRepo::Commit::PARENT_ID }
+        let(:to) { SeedRepo::Commit::ID }
+
+        it { expect(commit_ids).to contain_exactly(to) }
       end
 
-      it { is_expected.to contain_exactly(SeedRepo::Commit::ID) }
+      context 'requesting a commit range' do
+        let(:from) { 'v1.0.0' }
+        let(:to) { 'v1.2.0' }
+
+        let(:commits_in_range) do
+          %w[
+            570e7b2abdd848b95f2f578043fc23bd6f6fd24d
+            5937ac0a7beb003549fc5fd26fc247adbce4a52e
+            eb49186cfa5c4338011f5f590fac11bd66c5c631
+          ]
+        end
+
+        context 'no limit' do
+          it { expect(commit_ids).to eq(commits_in_range) }
+        end
+
+        context 'limited' do
+          let(:limit) { 2 }
+
+          it { expect(commit_ids).to eq(commits_in_range.last(2)) }
+        end
+      end
     end
 
     describe '.shas_with_signatures' do
diff --git a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
index a0e2d43cf4501320b0ffdd453664966003710c82..554a91f2bc589a7e0c5f9f1e9b24dd16efd400a7 100644
--- a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
@@ -311,6 +311,10 @@
   end
 
   describe '#list_commits' do
+    let(:revisions) { 'master' }
+    let(:reverse) { false }
+    let(:pagination_params) { nil }
+
     shared_examples 'a ListCommits request' do
       before do
         ::Gitlab::GitalyClient.clear_stubs!
@@ -318,26 +322,35 @@
 
       it 'sends a list_commits message' do
         expect_next_instance_of(Gitaly::CommitService::Stub) do |service|
-          expect(service)
-            .to receive(:list_commits)
-            .with(gitaly_request_with_params(expected_params), kind_of(Hash))
-            .and_return([])
+          expected_request = gitaly_request_with_params(
+            Array.wrap(revisions),
+            reverse: reverse,
+            pagination_params: pagination_params
+          )
+
+          expect(service).to receive(:list_commits).with(expected_request, kind_of(Hash)).and_return([])
         end
 
-        client.list_commits(revisions)
+        client.list_commits(revisions, reverse: reverse, pagination_params: pagination_params)
       end
     end
 
-    context 'with a single revision' do
-      let(:revisions) { 'master' }
-      let(:expected_params) { %w[master] }
+    it_behaves_like 'a ListCommits request'
+
+    context 'with multiple revisions' do
+      let(:revisions) { %w[master --not --all] }
+
+      it_behaves_like 'a ListCommits request'
+    end
+
+    context 'with reverse: true' do
+      let(:reverse) { true }
 
       it_behaves_like 'a ListCommits request'
     end
 
-    context 'with multiple revisions' do
-      let(:revisions) { %w[master --not --all] }
-      let(:expected_params) { %w[master --not --all] }
+    context 'with pagination params' do
+      let(:pagination_params) { { limit: 1, page_token: 'foo' } }
 
       it_behaves_like 'a ListCommits request'
     end
diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb
index 064d793588f8104218729f89331475a81ced150b..949892beed5395e130ebd49aae7ff1ab3426deff 100644
--- a/spec/models/repository_spec.rb
+++ b/spec/models/repository_spec.rb
@@ -470,6 +470,29 @@ def create_commit_with_invalid_utf8_path
     end
   end
 
+  describe '#commits_between' do
+    let(:commit) { project.commit }
+
+    it 'delegates to Gitlab::Git::Commit#between, returning decorated commits' do
+      expect(Gitlab::Git::Commit)
+        .to receive(:between)
+        .with(repository.raw_repository, commit.parent_id, commit.id, limit: 5)
+        .and_call_original
+
+      result = repository.commits_between(commit.parent_id, commit.id, limit: 5)
+
+      expect(result).to contain_exactly(instance_of(Commit), instance_of(Commit))
+    end
+
+    it 'defaults to no limit' do
+      expect(Gitlab::Git::Commit)
+        .to receive(:between)
+        .with(repository.raw_repository, commit.parent_id, commit.id, limit: nil)
+
+      repository.commits_between(commit.parent_id, commit.id)
+    end
+  end
+
   describe '#find_commits_by_message' do
     it 'returns commits with messages containing a given string' do
       commit_ids = repository.find_commits_by_message('submodule').map(&:id)
diff --git a/spec/services/git/base_hooks_service_spec.rb b/spec/services/git/base_hooks_service_spec.rb
index 539c294a2e796cb481c988e329a3b9d30327d85f..a8d753ff1244c11f835243acb0528021132bc6f8 100644
--- a/spec/services/git/base_hooks_service_spec.rb
+++ b/spec/services/git/base_hooks_service_spec.rb
@@ -19,9 +19,13 @@ def hook_name
         :push_hooks
       end
 
-      def commits
+      def limited_commits
         []
       end
+
+      def commits_count
+        0
+      end
     end
   end
 
diff --git a/spec/services/git/branch_hooks_service_spec.rb b/spec/services/git/branch_hooks_service_spec.rb
index a93f594b360bfbd8d98341b8f130bce963c9eb1f..79c2cb1fca3a58d144700789477732be62268d66 100644
--- a/spec/services/git/branch_hooks_service_spec.rb
+++ b/spec/services/git/branch_hooks_service_spec.rb
@@ -362,6 +362,9 @@ def clears_extended_cache
       end
     end
 
+    let(:commits_count) { service.send(:commits_count) }
+    let(:threshold_limit) { described_class::PROCESS_COMMIT_LIMIT + 1 }
+
     let(:oldrev) { project.commit(commit_ids.first).parent_id }
     let(:newrev) { commit_ids.last }
 
@@ -373,17 +376,31 @@ def clears_extended_cache
       let(:oldrev) { Gitlab::Git::BLANK_SHA }
 
       it 'processes a limited number of commit messages' do
+        expect(project.repository)
+          .to receive(:commits)
+          .with(newrev, limit: threshold_limit)
+          .and_call_original
+
         expect(ProcessCommitWorker).to receive(:perform_async).twice
 
         service.execute
+
+        expect(commits_count).to eq(project.repository.commit_count_for_ref(newrev))
       end
     end
 
     context 'updating the default branch' do
       it 'processes a limited number of commit messages' do
+        expect(project.repository)
+          .to receive(:commits_between)
+          .with(oldrev, newrev, limit: threshold_limit)
+          .and_call_original
+
         expect(ProcessCommitWorker).to receive(:perform_async).twice
 
         service.execute
+
+        expect(commits_count).to eq(project.repository.count_commits_between(oldrev, newrev))
       end
     end
 
@@ -391,9 +408,13 @@ def clears_extended_cache
       let(:newrev) { Gitlab::Git::BLANK_SHA }
 
       it 'does not process commit messages' do
+        expect(project.repository).not_to receive(:commits)
+        expect(project.repository).not_to receive(:commits_between)
         expect(ProcessCommitWorker).not_to receive(:perform_async)
 
         service.execute
+
+        expect(commits_count).to eq(0)
       end
     end
 
@@ -402,9 +423,16 @@ def clears_extended_cache
       let(:oldrev) { Gitlab::Git::BLANK_SHA }
 
       it 'processes a limited number of commit messages' do
+        expect(project.repository)
+          .to receive(:commits_between)
+          .with(project.default_branch, newrev, limit: threshold_limit)
+          .and_call_original
+
         expect(ProcessCommitWorker).to receive(:perform_async).twice
 
         service.execute
+
+        expect(commits_count).to eq(project.repository.count_commits_between(project.default_branch, branch))
       end
     end
 
@@ -412,9 +440,15 @@ def clears_extended_cache
       let(:branch) { 'fix' }
 
       it 'processes a limited number of commit messages' do
+        expect(project.repository)
+          .to receive(:commits_between)
+          .with(oldrev, newrev, limit: threshold_limit)
+          .and_call_original
+
         expect(ProcessCommitWorker).to receive(:perform_async).twice
 
         service.execute
+        expect(commits_count).to eq(project.repository.count_commits_between(oldrev, newrev))
       end
     end
 
@@ -423,9 +457,13 @@ def clears_extended_cache
       let(:newrev) { Gitlab::Git::BLANK_SHA }
 
       it 'does not process commit messages' do
+        expect(project.repository).not_to receive(:commits)
+        expect(project.repository).not_to receive(:commits_between)
         expect(ProcessCommitWorker).not_to receive(:perform_async)
 
         service.execute
+
+        expect(commits_count).to eq(0)
       end
     end