diff --git a/config/feature_flags/development/changes_batch_commits.yml b/config/feature_flags/development/changes_batch_commits.yml
new file mode 100644
index 0000000000000000000000000000000000000000..2b276759fb1fa347a36db0b4a6619c44b1484850
--- /dev/null
+++ b/config/feature_flags/development/changes_batch_commits.yml
@@ -0,0 +1,8 @@
+---
+name: changes_batch_commits
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/66731
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/336992
+milestone: '14.2'
+type: development
+group: group::gitaly
+default_enabled: false
diff --git a/ee/spec/lib/gitlab/git_access_spec.rb b/ee/spec/lib/gitlab/git_access_spec.rb
index 4a00c4b282e4f7df2ca2428bcc4e317f9636c31b..aa0fae0ac2201d122a3f80328c5f00d81bd3380c 100644
--- a/ee/spec/lib/gitlab/git_access_spec.rb
+++ b/ee/spec/lib/gitlab/git_access_spec.rb
@@ -77,7 +77,7 @@ def download_ability
       end
 
       it 'allows githook for new branch with an old bad commit' do
-        bad_commit = double("Commit", safe_message: 'Some change').as_null_object
+        bad_commit = double("Commit", safe_message: 'Some change', id: end_sha).as_null_object
         ref_object = double(name: 'heads/master')
         allow(bad_commit).to receive(:refs).and_return([ref_object])
         allow_next_instance_of(Repository) do |instance|
@@ -91,7 +91,7 @@ def download_ability
       end
 
       it 'allows githook for any change with an old bad commit' do
-        bad_commit = double("Commit", safe_message: 'Some change').as_null_object
+        bad_commit = double("Commit", safe_message: 'Some change', id: end_sha).as_null_object
         ref_object = double(name: 'heads/master')
         allow(bad_commit).to receive(:refs).and_return([ref_object])
         allow(project.repository).to receive(:commits_between).and_return([bad_commit])
@@ -103,7 +103,7 @@ def download_ability
       end
 
       it 'does not allow any change from Web UI with bad commit' do
-        bad_commit = double("Commit", safe_message: 'Some change').as_null_object
+        bad_commit = double("Commit", safe_message: 'Some change', id: end_sha).as_null_object
         # We use tmp ref a a temporary for Web UI commiting
         ref_object = double(name: 'refs/tmp')
         allow(bad_commit).to receive(:refs).and_return([ref_object])
diff --git a/lib/gitlab/checks/changes_access.rb b/lib/gitlab/checks/changes_access.rb
index 4e8b293a3e629282401697942641b5b6d4aa05bf..3ec3cdafd7c7ac5ff78ba252a2f92cee0dc01943 100644
--- a/lib/gitlab/checks/changes_access.rb
+++ b/lib/gitlab/checks/changes_access.rb
@@ -29,11 +29,48 @@ def validate!
         true
       end
 
+      # All commits which have been newly introduced via any of the given
+      # changes. This set may also contain commits which are not referenced by
+      # any of the new revisions.
+      def commits
+        newrevs = @changes.map do |change|
+          newrev = change[:newrev]
+          newrev unless newrev.blank? || Gitlab::Git.blank_ref?(newrev)
+        end.compact
+
+        return [] if newrevs.empty?
+
+        @commits ||= project.repository.new_commits(newrevs)
+      end
+
+      # All commits which have been newly introduced via the given revision.
+      def commits_for(newrev)
+        commits_by_id = commits.index_by(&:id)
+
+        result = []
+        pending = [newrev]
+
+        # We go up the parent chain of our newrev and collect all commits which
+        # are new. In case a commit's ID cannot be found in the set of new
+        # commits, then it must already be a preexisting commit.
+        pending.each do |rev|
+          commit = commits_by_id[rev]
+          next if commit.nil?
+
+          pending.push(*commit.parent_ids)
+          result << commit
+        end
+
+        result
+      end
+
       protected
 
       def single_access_checks!
         # Iterate over all changes to find if user allowed all of them to be applied
         changes.each do |change|
+          commits = Gitlab::Lazy.new { commits_for(change[:newrev]) } if Feature.enabled?(:changes_batch_commits)
+
           # If user does not have access to make at least one change, cancel all
           # push by allowing the exception to bubble up
           Checks::SingleChangeAccess.new(
@@ -41,7 +78,8 @@ def single_access_checks!
             user_access: user_access,
             project: project,
             protocol: protocol,
-            logger: logger
+            logger: logger,
+            commits: commits
           ).validate!
         end
       end
diff --git a/lib/gitlab/checks/single_change_access.rb b/lib/gitlab/checks/single_change_access.rb
index 280b2dd25e23e50babf7514fec91437d63017dfa..2fd48dfbfe2c9b957c37961c894f5413de35a946 100644
--- a/lib/gitlab/checks/single_change_access.rb
+++ b/lib/gitlab/checks/single_change_access.rb
@@ -11,7 +11,7 @@ class SingleChangeAccess
 
       def initialize(
         change, user_access:, project:,
-        protocol:, logger:
+        protocol:, logger:, commits: nil
       )
         @oldrev, @newrev, @ref = change.values_at(:oldrev, :newrev, :ref)
         @branch_name = Gitlab::Git.branch_name(@ref)
@@ -19,6 +19,7 @@ def initialize(
         @user_access = user_access
         @project = project
         @protocol = protocol
+        @commits = commits
 
         @logger = logger
         @logger.append_message("Running checks for ref: #{@branch_name || @tag_name}")
diff --git a/spec/lib/gitlab/checks/changes_access_spec.rb b/spec/lib/gitlab/checks/changes_access_spec.rb
index a46732f8255250cc8e90b7bf302faca6914f89e9..1e053d25123fbc95352404b858dc948fb993ec1a 100644
--- a/spec/lib/gitlab/checks/changes_access_spec.rb
+++ b/spec/lib/gitlab/checks/changes_access_spec.rb
@@ -3,40 +3,169 @@
 require 'spec_helper'
 
 RSpec.describe Gitlab::Checks::ChangesAccess do
+  include_context 'changes access checks context'
+
+  subject { changes_access }
+
   describe '#validate!' do
-    include_context 'changes access checks context'
+    shared_examples '#validate!' do
+      before do
+        allow(project).to receive(:lfs_enabled?).and_return(true)
+      end
 
-    before do
-      allow(project).to receive(:lfs_enabled?).and_return(true)
-    end
+      context 'without failed checks' do
+        it "doesn't raise an error" do
+          expect { subject.validate! }.not_to raise_error
+        end
 
-    subject { changes_access }
+        it 'calls lfs checks' do
+          expect_next_instance_of(Gitlab::Checks::LfsCheck) do |instance|
+            expect(instance).to receive(:validate!)
+          end
 
-    context 'without failed checks' do
-      it "doesn't raise an error" do
-        expect { subject.validate! }.not_to raise_error
+          subject.validate!
+        end
       end
 
-      it 'calls lfs checks' do
-        expect_next_instance_of(Gitlab::Checks::LfsCheck) do |instance|
-          expect(instance).to receive(:validate!)
+      context 'when time limit was reached' do
+        it 'raises a TimeoutError' do
+          logger = Gitlab::Checks::TimedLogger.new(start_time: timeout.ago, timeout: timeout)
+          access = described_class.new(changes,
+                                       project: project,
+                                       user_access: user_access,
+                                       protocol: protocol,
+                                       logger: logger)
+
+          expect { access.validate! }.to raise_error(Gitlab::Checks::TimedLogger::TimeoutError)
         end
+      end
+    end
+
+    context 'with batched commits enabled' do
+      before do
+        stub_feature_flags(changes_batch_commits: true)
+      end
+
+      it_behaves_like '#validate!'
+    end
+
+    context 'with batched commits disabled' do
+      before do
+        stub_feature_flags(changes_batch_commits: false)
+      end
+
+      it_behaves_like '#validate!'
+    end
+  end
+
+  describe '#commits' do
+    it 'calls #new_commits' do
+      expect(project.repository).to receive(:new_commits).and_return([])
+
+      expect(subject.commits).to eq([])
+    end
+
+    context 'when changes contain empty revisions' do
+      let(:changes) { [{ newrev: newrev }, { newrev: '' }, { newrev: Gitlab::Git::BLANK_SHA }] }
+      let(:expected_commit) { instance_double(Commit) }
+
+      it 'returns only commits with non empty revisions' do
+        expect(project.repository).to receive(:new_commits).with([newrev]) { [expected_commit] }
+        expect(subject.commits).to eq([expected_commit])
+      end
+    end
+  end
 
-        subject.validate!
+  describe '#commits_for' do
+    let(:new_commits) { [] }
+    let(:expected_commits) { [] }
+
+    shared_examples 'a listing of new commits' do
+      it 'returns expected commits' do
+        expect(subject).to receive(:commits).and_return(new_commits)
+
+        expect(subject.commits_for(newrev)).to eq(expected_commits)
+      end
+    end
+
+    context 'with no commits' do
+      it_behaves_like 'a listing of new commits'
+    end
+
+    context 'with unrelated commits' do
+      let(:new_commits) { [create_commit('1234', %w[1111 2222])] }
+
+      it_behaves_like 'a listing of new commits'
+    end
+
+    context 'with single related commit' do
+      let(:new_commits) { [create_commit(newrev, %w[1111 2222])] }
+      let(:expected_commits) { new_commits }
+
+      it_behaves_like 'a listing of new commits'
+    end
+
+    context 'with single related and unrelated commit' do
+      let(:new_commits) do
+        [
+          create_commit(newrev, %w[1111 2222]),
+          create_commit('abcd', %w[1111 2222])
+        ]
       end
+
+      let(:expected_commits) do
+        [create_commit(newrev, %w[1111 2222])]
+      end
+
+      it_behaves_like 'a listing of new commits'
     end
 
-    context 'when time limit was reached' do
-      it 'raises a TimeoutError' do
-        logger = Gitlab::Checks::TimedLogger.new(start_time: timeout.ago, timeout: timeout)
-        access = described_class.new(changes,
-                                     project: project,
-                                     user_access: user_access,
-                                     protocol: protocol,
-                                     logger: logger)
+    context 'with multiple related commits' do
+      let(:new_commits) do
+        [
+          create_commit(newrev, %w[1111]),
+          create_commit('1111', %w[2222]),
+          create_commit('abcd', [])
+        ]
+      end
 
-        expect { access.validate! }.to raise_error(Gitlab::Checks::TimedLogger::TimeoutError)
+      let(:expected_commits) do
+        [
+          create_commit(newrev, %w[1111]),
+          create_commit('1111', %w[2222])
+        ]
       end
+
+      it_behaves_like 'a listing of new commits'
     end
+
+    context 'with merge commits' do
+      let(:new_commits) do
+        [
+          create_commit(newrev, %w[1111 2222 3333]),
+          create_commit('1111', []),
+          create_commit('3333', %w[4444]),
+          create_commit('4444', [])
+        ]
+      end
+
+      let(:expected_commits) do
+        [
+          create_commit(newrev, %w[1111 2222 3333]),
+          create_commit('1111', []),
+          create_commit('3333', %w[4444]),
+          create_commit('4444', [])
+        ]
+      end
+
+      it_behaves_like 'a listing of new commits'
+    end
+  end
+
+  def create_commit(id, parent_ids)
+    Gitlab::Git::Commit.new(project.repository, {
+      id: id,
+      parent_ids: parent_ids
+    })
   end
 end
diff --git a/spec/lib/gitlab/checks/single_change_access_spec.rb b/spec/lib/gitlab/checks/single_change_access_spec.rb
index 8b235005b3eeaaa515647248df00f1a7351a5c92..e81e4951539616f5f560a363f9e593e45b7fc408 100644
--- a/spec/lib/gitlab/checks/single_change_access_spec.rb
+++ b/spec/lib/gitlab/checks/single_change_access_spec.rb
@@ -58,5 +58,52 @@
         expect { access.validate! }.to raise_error(Gitlab::Checks::TimedLogger::TimeoutError)
       end
     end
+
+    describe '#commits' do
+      let(:expected_commits) { [Gitlab::Git::Commit.new(project.repository, { id: "1234" })] }
+
+      let(:access) do
+        described_class.new(changes,
+                            project: project,
+                            user_access: user_access,
+                            protocol: protocol,
+                            logger: logger,
+                            commits: provided_commits)
+      end
+
+      shared_examples '#commits' do
+        it 'returns expected commits' do
+          expect(access.commits).to eq(expected_commits)
+        end
+
+        it 'returns expected commits on repeated calls' do
+          expect(access.commits).to eq(expected_commits)
+          expect(access.commits).to eq(expected_commits)
+        end
+      end
+
+      context 'with provided commits' do
+        let(:provided_commits) { expected_commits }
+
+        before do
+          expect(project.repository).not_to receive(:new_commits)
+        end
+
+        it_behaves_like '#commits'
+      end
+
+      context 'without provided commits' do
+        let(:provided_commits) { nil }
+
+        before do
+          expect(project.repository)
+            .to receive(:new_commits)
+            .once
+            .and_return(expected_commits)
+        end
+
+        it_behaves_like '#commits'
+      end
+    end
   end
 end