diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index d4f9743c6339efc0a2b5e7b84e0b28d27145b756..c1a7d36ae44020fba432466f945eec0a9de678a2 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -3407,6 +3407,27 @@ Input type: `ProjectSetComplianceFrameworkInput`
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| `project` | [`Project`](#project) | Project after mutation. |
+### `Mutation.projectSetLocked`
+
+Input type: `ProjectSetLockedInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `filePath` | [`String!`](#string) | Full path to the file. |
+| `lock` | [`Boolean!`](#boolean) | Whether or not to lock the file path. |
+| `projectPath` | [`ID!`](#id) | Full path of the project to mutate. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+| `project` | [`Project`](#project) | Project after mutation. |
+
### `Mutation.prometheusIntegrationCreate`
Input type: `PrometheusIntegrationCreateInput`
diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb
index c5b76077a532ef018c7f04e7e8772de57d3bddf2..7de4261bfdae6c0ff86a760af91102f12089aba6 100644
--- a/ee/app/graphql/ee/types/mutation_type.rb
+++ b/ee/app/graphql/ee/types/mutation_type.rb
@@ -23,6 +23,7 @@ module MutationType
mount_mutation ::Mutations::Epics::SetSubscription
mount_mutation ::Mutations::Epics::AddIssue
mount_mutation ::Mutations::GitlabSubscriptions::Activate
+ mount_mutation ::Mutations::Projects::SetLocked
mount_mutation ::Mutations::Iterations::Create
mount_mutation ::Mutations::Iterations::Update
mount_mutation ::Mutations::Iterations::Delete
diff --git a/ee/app/graphql/mutations/projects/set_locked.rb b/ee/app/graphql/mutations/projects/set_locked.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ee509f3060acb64af27c10e055ddea87a205c0b0
--- /dev/null
+++ b/ee/app/graphql/mutations/projects/set_locked.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Projects
+ class SetLocked < BaseMutation
+ include FindsProject
+
+ graphql_name 'ProjectSetLocked'
+
+ authorize :push_code
+
+ argument :project_path, GraphQL::Types::ID,
+ required: true,
+ description: 'Full path of the project to mutate.'
+
+ argument :file_path, GraphQL::Types::String,
+ required: true,
+ description: 'Full path to the file.'
+
+ argument :lock,
+ GraphQL::Types::Boolean,
+ required: true,
+ description: 'Whether or not to lock the file path.'
+
+ field :project, Types::ProjectType, null: true,
+ description: 'Project after mutation.'
+
+ attr_reader :project
+
+ def resolve(project_path:, file_path:, lock:)
+ @project = authorized_find!(project_path)
+
+ unless allowed?
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'FileLocks feature disabled'
+ end
+
+ if lock
+ lock_path(file_path)
+ else
+ unlock_path(file_path)
+ end
+
+ {
+ project: project,
+ errors: []
+ }
+ rescue PathLocks::UnlockService::AccessDenied => e
+ {
+ project: nil,
+ errors: [e.message]
+ }
+ end
+
+ private
+
+ delegate :repository, to: :project
+
+ def fetch_path_lock(file_path)
+ project.path_locks.find_by(path: file_path) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ def lock_path(file_path)
+ return if fetch_path_lock(file_path)
+
+ path_lock = PathLocks::LockService.new(project, current_user).execute(file_path)
+
+ if path_lock.persisted? && sync_with_lfs?(file_path)
+ Lfs::LockFileService.new(project, current_user, path: file_path, create_path_lock: false).execute
+ end
+ end
+
+ def unlock_path(file_path)
+ path_lock = fetch_path_lock(file_path)
+
+ return unless path_lock
+
+ PathLocks::UnlockService.new(project, current_user).execute(path_lock)
+
+ if sync_with_lfs?(file_path)
+ Lfs::UnlockFileService.new(project, current_user, path: file_path, force: true).execute
+ end
+ end
+
+ def sync_with_lfs?(file_path)
+ project.lfs_enabled? && lfs_file?(file_path)
+ end
+
+ def lfs_file?(file_path)
+ blob = repository.blob_at_branch(repository.root_ref, file_path)
+
+ return false unless blob
+
+ lfs_blob_ids = LfsPointersFinder.new(repository, file_path).execute
+
+ lfs_blob_ids.include?(blob.id)
+ end
+
+ def allowed?
+ project.licensed_feature_available?(:file_locks)
+ end
+ end
+ end
+end
diff --git a/ee/spec/graphql/mutations/projects/set_locked_spec.rb b/ee/spec/graphql/mutations/projects/set_locked_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..29ef097956ff68eef19cb29ea8e91fcf9bc94d3e
--- /dev/null
+++ b/ee/spec/graphql/mutations/projects/set_locked_spec.rb
@@ -0,0 +1,174 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Projects::SetLocked do
+ subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :repository) }
+
+ describe '#resolve' do
+ subject { mutation.resolve(project_path: project.full_path, file_path: file_path, lock: lock) }
+
+ let(:file_path) { 'README.md' }
+ let(:lock) { true }
+ let(:mutated_path_locks) { subject[:project].path_locks }
+
+ it 'raises an error if the resource is not accessible to the user' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+
+ context 'when the user can lock the file' do
+ let(:lock) { true }
+
+ before do
+ project.add_developer(user)
+ end
+
+ context 'when file_locks feature is not available' do
+ before do
+ stub_licensed_features(file_locks: false)
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when file is not locked' do
+ it 'sets path locks for the project' do
+ expect { subject }.to change { project.path_locks.count }.by(1)
+ expect(mutated_path_locks.first).to have_attributes(path: file_path, user: user)
+ end
+ end
+
+ context 'when file is already locked' do
+ before do
+ create(:path_lock, project: project, path: file_path)
+ end
+
+ it 'does not change the lock' do
+ expect { subject }.not_to change { project.path_locks.count }
+ end
+ end
+
+ context 'when LFS is enabled' do
+ let(:file_path) { 'files/lfs/lfs_object.iso' }
+
+ before do
+ allow_next_found_instance_of(Project) do |project|
+ allow(project).to receive(:lfs_enabled?) { true }
+ end
+ end
+
+ it 'locks the file in LFS' do
+ expect { subject }.to change { project.lfs_file_locks.count }.by(1)
+ end
+
+ context 'when file is not tracked in LFS' do
+ let(:file_path) { 'README.md' }
+
+ it 'does not lock the file' do
+ expect { subject }.not_to change { project.lfs_file_locks.count }
+ end
+ end
+
+ context 'when locking a directory' do
+ let(:file_path) { 'lfs/' }
+
+ it 'locks the directory' do
+ expect { subject }.to change { project.path_locks.count }.by(1)
+ end
+
+ it 'does not locks the directory through LFS' do
+ expect { subject }.not_to change { project.lfs_file_locks.count }
+ end
+ end
+ end
+ end
+
+ context 'when the user can unlock the file' do
+ let(:lock) { false }
+
+ before do
+ project.add_developer(user)
+ end
+
+ context 'when file is already locked by the same user' do
+ before do
+ create(:path_lock, project: project, path: file_path, user: user)
+ end
+
+ it 'unlocks the file' do
+ expect { subject }.to change { project.path_locks.count }.by(-1)
+ expect(mutated_path_locks).to be_empty
+ end
+ end
+
+ context 'when file is already locked by somebody else' do
+ before do
+ create(:path_lock, project: project, path: file_path)
+ end
+
+ it 'returns an error' do
+ expect(subject[:errors]).to eq(['You have no permissions'])
+ end
+ end
+
+ context 'when file is not locked' do
+ it 'does nothing' do
+ expect { subject }.not_to change { project.path_locks.count }
+ expect(mutated_path_locks).to be_empty
+ end
+ end
+
+ context 'when LFS is enabled' do
+ let(:file_path) { 'files/lfs/lfs_object.iso' }
+
+ before do
+ allow_next_found_instance_of(Project) do |project|
+ allow(project).to receive(:lfs_enabled?) { true }
+ end
+ end
+
+ context 'when file is locked' do
+ before do
+ create(:lfs_file_lock, project: project, path: file_path, user: user)
+ create(:path_lock, project: project, path: file_path, user: user)
+ end
+
+ it 'unlocks the file' do
+ expect { subject }.to change { project.path_locks.count }.by(-1)
+ end
+
+ it 'unlocks the file in LFS' do
+ expect { subject }.to change { project.lfs_file_locks.count }.by(-1)
+ end
+
+ context 'when file is not tracked in LFS' do
+ let(:file_path) { 'README.md' }
+
+ it 'does not unlock the file' do
+ expect { subject }.not_to change { project.lfs_file_locks.count }
+ end
+ end
+
+ context 'when unlocking a directory' do
+ let(:file_path) { 'lfs/' }
+
+ it 'unlocks the directory' do
+ expect { subject }.to change { project.path_locks.count }.by(-1)
+ end
+
+ it 'does not call the LFS unlock service' do
+ expect(Lfs::UnlockFileService).not_to receive(:new)
+
+ subject
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/ee/spec/requests/api/graphql/mutations/projects/lock_path_spec.rb b/ee/spec/requests/api/graphql/mutations/projects/lock_path_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..73826bb83e7ec5d16d76340ed98ead3002ce61fc
--- /dev/null
+++ b/ee/spec/requests/api/graphql/mutations/projects/lock_path_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe "Lock/unlock project's file path" do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+
+ let(:attributes) { { file_path: file_path, lock: lock } }
+ let(:file_path) { 'README.md' }
+ let(:lock) { true }
+ let(:mutation) do
+ params = { project_path: project.full_path }.merge(attributes)
+
+ graphql_mutation(:project_set_locked, params) do
+ <<-QL.strip_heredoc
+ project {
+ id
+ pathLocks {
+ nodes {
+ path
+ }
+ }
+ }
+ errors
+ QL
+ end
+ end
+
+ def mutation_response
+ graphql_mutation_response(:project_set_locked)
+ end
+
+ context 'when the user does not have permission' do
+ it_behaves_like 'a mutation that returns a top-level access error'
+
+ it 'does not create requirement' do
+ expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change { project.path_locks.count }
+ end
+ end
+
+ context 'when the user has permission' do
+ before do
+ project.add_developer(current_user)
+ end
+
+ it 'creates the path lock' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ project_hash = mutation_response['project']
+
+ expect(project_hash.dig('pathLocks', 'nodes', 0, 'path')).to eq(file_path)
+ end
+
+ context 'when there are validation errors' do
+ let(:lock) { false }
+
+ before do
+ create(:path_lock, project: project, path: file_path)
+ end
+
+ it_behaves_like 'a mutation that returns errors in the response',
+ errors: ['You have no permissions']
+ end
+ end
+end