diff --git a/CHANGELOG b/CHANGELOG
index 94a776a35eba9dbb2fa2737fc2abc1fc05cd7b1a..879d057fff004ab04c6faa73ce7d65f93435c0ff 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -23,6 +23,7 @@ v 8.4.0 (unreleased)
   - Validate README format before displaying
   - Enable Microsoft Azure OAuth2 support (Janis Meybohm)
   - Add file finder feature in tree view (koreamic)
+  - Ajax filter by message for commits page
 
 v 8.3.3 (unreleased)
   - Get "Merge when build succeeds" to work when commits were pushed to MR target branch while builds were running
diff --git a/app/assets/javascripts/commits.js.coffee b/app/assets/javascripts/commits.js.coffee
index c183e78e513ed62a5f98ac13cfc41a15db308c41..ffd3627b1b073d53b6361c33c31c58684f8ad736 100644
--- a/app/assets/javascripts/commits.js.coffee
+++ b/app/assets/javascripts/commits.js.coffee
@@ -1,15 +1,5 @@
 class @CommitsList
-  @data =
-    ref: null
-    limit: 0
-    offset: 0
-  @disable = false
-
-  @showProgress: ->
-    $('.loading').show()
-
-  @hideProgress: ->
-    $('.loading').hide()
+  @timer = null
 
   @init: (ref, limit) ->
     $("body").on "click", ".day-commits-table li.commit", (event) ->
@@ -18,38 +8,32 @@ class @CommitsList
         e.stopPropagation()
         return false
 
-    @data.ref = ref
-    @data.limit = limit
-    @data.offset = limit
+    Pager.init limit, false
+
+    @content = $("#commits-list")
+    @searchField = $("#commits-search")
+    @initSearch()
 
-    this.initLoadMore()
-    this.showProgress()
+  @initSearch: ->
+    @timer = null
+    @searchField.keyup =>
+      clearTimeout(@timer)
+      @timer = setTimeout(@filterResults, 500)
+
+  @filterResults: =>
+    form = $(".commits-search-form")
+    search = @searchField.val()
+    commitsUrl = form.attr("action") + '?' + form.serialize()
+    @content.fadeTo('fast', 0.5)
 
-  @getOld: ->
-    this.showProgress()
     $.ajax
       type: "GET"
-      url: location.href
-      data: @data
-      complete: this.hideProgress
-      success: (data) ->
-        CommitsList.append(data.count, data.html)
+      url: form.attr("action")
+      data: form.serialize()
+      complete: =>
+        @content.fadeTo('fast', 1.0)
+      success: (data) =>
+        @content.html(data.html)
+        # Change url so if user reload a page - search results are saved
+        history.replaceState {page: commitsUrl}, document.title, commitsUrl
       dataType: "json"
-
-  @append: (count, html) ->
-    $("#commits-list").append(html)
-    if count > 0
-      @data.offset += count
-    else
-      @disable = true
-
-  @initLoadMore: ->
-    $(document).unbind('scroll')
-    $(document).endlessScroll
-      bottomPixels: 400
-      fireDelay: 1000
-      fireOnce: true
-      ceaseFire: =>
-        @disable
-      callback: =>
-        this.getOld()
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index 206d39cc9b3b198f42f82729eb5b1aca18062876..fa0e70847f34d86e7ddcec02243b31d8090b80aa 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -72,6 +72,15 @@
   > p:last-child {
     margin-bottom: 0;
   }
+
+  .block-controls {
+    float: right;
+
+    .control {
+      float: left;
+      margin-left: 10px;
+    }
+  }
 }
 
 .cover-block {
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 879bd287470041aec774186a4f1d3f2025449e74..800df95cff306fac1c3a2c7b9b0001948131efc3 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -28,10 +28,6 @@
   }
 }
 
-.commits-feed-holder {
-  float: right;
-}
-
 li.commit {
   list-style: none;
 
@@ -126,14 +122,14 @@ li.commit {
 .divergence-graph {
   padding: 12px 12px 0 0;
   float: right;
-  
+
   .graph-side {
     position: relative;
     width: 80px;
     height: 22px;
     padding: 5px 0 13px;
     float: left;
-    
+
     .bar {
       position: absolute;
       height: 4px;
@@ -149,7 +145,7 @@ li.commit {
       left: 0;
       border-radius: 0 3px 3px 0;
     }
-    
+
     .count {
       padding-top: 6px;
       padding-bottom: 0px;
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index 04a88990bf4d68557cac40687203cdaca2188d6d..bf5b54c8cb75925b586a5edf030adeb379e97a15 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -8,10 +8,16 @@ class Projects::CommitsController < Projects::ApplicationController
   before_action :authorize_download_code!
 
   def show
-    @repo = @project.repository
     @limit, @offset = (params[:limit] || 40).to_i, (params[:offset] || 0).to_i
+    search = params[:search]
+
+    @commits =
+      if search.present?
+        @repository.find_commits_by_message(search, @ref, @path, @limit, @offset).compact
+      else
+        @repository.commits(@ref, @path, @limit, @offset)
+      end
 
-    @commits = @repo.commits(@ref, @path, @limit, @offset)
     @note_counts = project.notes.where(commit_id: @commits.map(&:id)).
       group(:commit_id).count
 
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 9deb08d93b8d4781b113f79b7f7347e0b55c2e15..d9ff71c01eddf5b7067fa9130892e0cee16b3033 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -92,9 +92,12 @@ def commits_between(from, to)
     commits
   end
 
-  def find_commits_by_message(query)
+  def find_commits_by_message(query, ref = nil, path = nil, limit = 1000, offset = 0)
+    ref ||= root_ref
+
     # Limited to 1000 commits for now, could be parameterized?
-    args = %W(#{Gitlab.config.git.bin_path} log --pretty=%H --max-count 1000 --grep=#{query})
+    args = %W(#{Gitlab.config.git.bin_path} log #{ref} --pretty=%H --skip #{offset} --max-count #{limit} --grep=#{query})
+    args = args.concat(%W(-- #{path})) if path.present?
 
     git_log_results = Gitlab::Popen.popen(args, path_to_repo).first.lines.map(&:chomp)
     commits = git_log_results.map { |c| commit(c) }
@@ -175,7 +178,7 @@ def commit_count
   def size
     cache.fetch(:size) { raw_repository.size }
   end
-  
+
   def diverging_commit_counts(branch)
     root_ref_hash = raw_repository.rev_parse_target(root_ref).oid
     cache.fetch(:"diverging_commit_counts_#{branch.name}") do
@@ -183,7 +186,7 @@ def diverging_commit_counts(branch)
       # than SHA-1 hashes
       number_commits_behind = commits_between(branch.target, root_ref_hash).size
       number_commits_ahead = commits_between(root_ref_hash, branch.target).size
-      
+
       { behind: number_commits_behind, ahead: number_commits_ahead }
     end
   end
@@ -192,7 +195,7 @@ def cache_keys
     %i(size branch_names tag_names commit_count
        readme version contribution_guide changelog license)
   end
-  
+
   def branch_cache_keys
     branches.map do |branch|
       :"diverging_commit_counts_#{branch.name}"
@@ -205,7 +208,7 @@ def build_cache
         send(key)
       end
     end
-    
+
     branches.each do |branch|
       unless cache.exist?(:"diverging_commit_counts_#{branch.name}")
         send(:diverging_commit_counts, branch)
@@ -227,10 +230,10 @@ def expire_cache
     cache_keys.each do |key|
       cache.expire(key)
     end
-    
+
     expire_branch_cache
   end
-  
+
   def expire_branch_cache
     branches.each do |branch|
       cache.expire(:"diverging_commit_counts_#{branch.name}")
@@ -242,7 +245,7 @@ def rebuild_cache
       cache.expire(key)
       send(key)
     end
-    
+
     branches.each do |branch|
       cache.expire(:"diverging_commit_counts_#{branch.name}")
       diverging_commit_counts(branch)
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index 2dd99cc821534c93d78dc5e1c62b24f3aa5391e6..034057da42eb5299618a05e74a63147d9f1287b6 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -10,26 +10,30 @@
   .tree-ref-holder
     = render 'shared/ref_switcher', destination: 'commits'
 
-  .commits-feed-holder.hidden-xs.hidden-sm
+  .block-controls.hidden-xs.hidden-sm
     - if create_mr_button?(@repository.root_ref, @ref)
-      = link_to create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success' do
-        = icon('plus')
-        Create Merge Request
+      .control
+        = link_to create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success' do
+          = icon('plus')
+          Create Merge Request
+
+    .control
+      = form_tag(namespace_project_commits_path(@project.namespace, @project, @id), method: :get, class: 'pull-left commits-search-form') do
+        = search_field_tag :search, params[:search], { placeholder: 'Filter by commit message', id: 'commits-search', class: 'form-control search-text-input', spellcheck: false }
 
     - if current_user && current_user.private_token
-      = link_to namespace_project_commits_path(@project.namespace, @project, @ref, {format: :atom, private_token: current_user.private_token}), title: "Commits Feed", class: 'prepend-left-10 btn' do
-        = icon("rss")
+      .control
+        = link_to namespace_project_commits_path(@project.namespace, @project, @ref, {format: :atom, private_token: current_user.private_token}), title: "Commits Feed", class: 'btn' do
+          = icon("rss")
 
 
   %ul.breadcrumb.repo-breadcrumb
     = commits_breadcrumbs
 
 %div{id: dom_id(@project)}
-  #commits-list= render "commits", project: @project
+  #commits-list.content_list= render "commits", project: @project
 .clear
 = spinner
 
-- if @commits.count == @limit
-  :javascript
-    CommitsList.init("#{@ref}", #{@limit});
-
+:javascript
+  CommitsList.init("#{@ref}", #{@limit});
diff --git a/features/project/commits/commits.feature b/features/project/commits/commits.feature
index 5bb2d0e976bbdcb19510454773ada7c37847d890..01c1072131219398ff2486c5999f206d409ea2b0 100644
--- a/features/project/commits/commits.feature
+++ b/features/project/commits/commits.feature
@@ -55,3 +55,8 @@ Feature: Project Commits
   Scenario: I browse a commit with an image
     Given I visit a commit with an image that changed
     Then The diff links to both the previous and current image
+
+  @javascript
+  Scenario: I filter commits by message
+    When I search "submodules" commits
+    Then I should see only "submodules" commits
diff --git a/features/steps/project/commits/commits.rb b/features/steps/project/commits/commits.rb
index a3141fe3be12cff20840f34494236090adb31857..daf6cdaaac836303df8d772f3c37558681f57ed7 100644
--- a/features/steps/project/commits/commits.rb
+++ b/features/steps/project/commits/commits.rb
@@ -124,4 +124,13 @@ class Spinach::Features::ProjectCommits < Spinach::FeatureSteps
     expect(page).to have_content "build: pending"
     expect(page).to have_content "1 build"
   end
+
+  step 'I search "submodules" commits' do
+    fill_in 'commits-search', with: 'submodules'
+  end
+
+  step 'I should see only "submodules" commits' do
+    expect(page).to have_content "More submodules"
+    expect(page).not_to have_content "Change some files"
+  end
 end