diff --git a/CHANGELOG b/CHANGELOG
index 1318d834a124cf8e4d20265e9d51df32f167cba4..3d79afbc39e18d0ac911a9c231271f4c7a2ee140 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -24,6 +24,7 @@ v 8.10.0 (unreleased)
   - Exclude email check from the standard health check
   - Fix changing issue state columns in milestone view
   - Add notification settings dropdown for groups
+  - Wildcards for protected branches. !4665
   - Allow importing from Github using Personal Access Tokens. (Eric K Idema)
   - API: Todos !3188 (Robert Schilling)
   - Add "Enabled Git access protocols" to Application Settings
diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee
index ed9dfcc917e9f1dbfe71c3e713404aa8866167ff..1c65e833d47bce60e2bdcd27c006b1b441f96e8c 100644
--- a/app/assets/javascripts/gl_dropdown.js.coffee
+++ b/app/assets/javascripts/gl_dropdown.js.coffee
@@ -56,6 +56,7 @@ class GitLabDropdownFilter
     return BLUR_KEYCODES.indexOf(keyCode) >= 0
 
   filter: (search_text) ->
+    @options.onFilter(search_text) if @options.onFilter
     data = @options.data()
 
     if data? and not @options.filterByText
@@ -195,6 +196,7 @@ class GitLabDropdown
       @filter = new GitLabDropdownFilter @filterInput,
         filterInputBlur: @filterInputBlur
         filterByText: @options.filterByText
+        onFilter: @options.onFilter
         remote: @options.filterRemote
         query: @options.data
         keys: searchFields
@@ -530,7 +532,7 @@ class GitLabDropdown
     if $el.length
       e.preventDefault()
       e.stopImmediatePropagation()
-      $(selector, @dropdown)[0].click()
+      $el.first().trigger('click')
 
   addArrowKeyEvent: ->
     ARROW_KEY_CODES = [38, 40]
diff --git a/app/assets/javascripts/protected_branch_select.js.coffee b/app/assets/javascripts/protected_branch_select.js.coffee
new file mode 100644
index 0000000000000000000000000000000000000000..6d45770ace9c6e4692f9965c29f2b20572976dea
--- /dev/null
+++ b/app/assets/javascripts/protected_branch_select.js.coffee
@@ -0,0 +1,40 @@
+class @ProtectedBranchSelect
+  constructor: (currentProject) ->
+    $('.dropdown-footer').hide();
+    @dropdown = $('.js-protected-branch-select').glDropdown(
+      data: @getProtectedBranches
+      filterable: true
+      remote: false
+      search:
+        fields: ['title']
+      selectable: true
+      toggleLabel: (selected) -> if (selected and 'id' of selected) then selected.title else 'Protected Branch'
+      fieldName: 'protected_branch[name]'
+      text: (protected_branch) -> _.escape(protected_branch.title)
+      id: (protected_branch) -> _.escape(protected_branch.id)
+      onFilter: @toggleCreateNewButton
+      clicked: () -> $('.protect-branch-btn').attr('disabled', false)
+    )
+
+    $('.create-new-protected-branch').on 'click', (event) =>
+      # Refresh the dropdown's data, which ends up calling `getProtectedBranches`
+      @dropdown.data('glDropdown').remote.execute()
+      @dropdown.data('glDropdown').selectRowAtIndex(event, 0)
+
+  getProtectedBranches: (term, callback) =>
+    if @selectedBranch
+      callback(gon.open_branches.concat(@selectedBranch))
+    else
+      callback(gon.open_branches)
+
+  toggleCreateNewButton: (branchName) =>
+    @selectedBranch = { title: branchName, id: branchName, text: branchName }
+
+    if branchName is ''
+      $('.protected-branch-select-footer-list').addClass('hidden')
+      $('.dropdown-footer').hide();
+    else
+      $('.create-new-protected-branch').text("Create Protected Branch: #{branchName}")
+      $('.protected-branch-select-footer-list').removeClass('hidden')
+      $('.dropdown-footer').show();
+
diff --git a/app/assets/javascripts/protected_branches.js.coffee b/app/assets/javascripts/protected_branches.js.coffee
index 5753c9d4e72140aca1c96772ffab88d36501dec4..79c2306e4d25de078caaa95f8d001708fac40d1a 100644
--- a/app/assets/javascripts/protected_branches.js.coffee
+++ b/app/assets/javascripts/protected_branches.js.coffee
@@ -11,7 +11,8 @@ $ ->
         dataType: "json"
         data:
           id: id
-          developers_can_push: checked
+          protected_branch:
+            developers_can_push: checked
 
         success: ->
           row = $(e.target)
diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb
index efa7bf14d0f5c9997c3d5be603cd98e8476653e3..80dad758afa298d229abad2aaafc3a33b251b39c 100644
--- a/app/controllers/projects/protected_branches_controller.rb
+++ b/app/controllers/projects/protected_branches_controller.rb
@@ -2,12 +2,14 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController
   # Authorize
   before_action :require_non_empty_project
   before_action :authorize_admin_project!
+  before_action :load_protected_branch, only: [:show, :update, :destroy]
 
   layout "project_settings"
 
   def index
-    @branches = @project.protected_branches.to_a
+    @protected_branches = @project.protected_branches.order(:name).page(params[:page])
     @protected_branch = @project.protected_branches.new
+    gon.push({ open_branches: @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } } })
   end
 
   def create
@@ -16,26 +18,24 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController
                                                           @project)
   end
 
-  def update
-    protected_branch = @project.protected_branches.find(params[:id])
-
-    if protected_branch &&
-       protected_branch.update_attributes(
-         developers_can_push: params[:developers_can_push]
-       )
+  def show
+    @matching_branches = @protected_branch.matching(@project.repository.branches)
+  end
 
+  def update
+    if @protected_branch && @protected_branch.update_attributes(protected_branch_params)
       respond_to do |format|
-        format.json { render json: protected_branch, status: :ok }
+        format.json { render json: @protected_branch, status: :ok }
       end
     else
       respond_to do |format|
-        format.json { render json: protected_branch.errors, status: :unprocessable_entity }
+        format.json { render json: @protected_branch.errors, status: :unprocessable_entity }
       end
     end
   end
 
   def destroy
-    @project.protected_branches.find(params[:id]).destroy
+    @protected_branch.destroy
 
     respond_to do |format|
       format.html { redirect_to namespace_project_protected_branches_path }
@@ -45,6 +45,10 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController
 
   private
 
+  def load_protected_branch
+    @protected_branch = @project.protected_branches.find(params[:id])
+  end
+
   def protected_branch_params
     params.require(:protected_branch).permit(:name, :developers_can_push)
   end
diff --git a/app/models/project.rb b/app/models/project.rb
index 029026a4e56aba0aa70b79213049d287492ee6ed..5005ee7cd323d6a2bb64181fc810aa31ce618d14 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -802,18 +802,12 @@ class Project < ActiveRecord::Base
     @repo_exists = false
   end
 
+  # Branches that are not _exactly_ matched by a protected branch.
   def open_branches
-    # We're using a Set here as checking values in a large Set is faster than
-    # checking values in a large Array.
-    protected_set = Set.new(protected_branch_names)
-
-    repository.branches.reject do |branch|
-      protected_set.include?(branch.name)
-    end
-  end
-
-  def protected_branch_names
-    @protected_branch_names ||= protected_branches.pluck(:name)
+    exact_protected_branch_names = protected_branches.reject(&:wildcard?).map(&:name)
+    branch_names = repository.branches.map(&:name)
+    non_open_branch_names = Set.new(exact_protected_branch_names).intersection(Set.new(branch_names))
+    repository.branches.reject { |branch| non_open_branch_names.include? branch.name }
   end
 
   def root_ref?(branch)
@@ -830,11 +824,12 @@ class Project < ActiveRecord::Base
 
   # Check if current branch name is marked as protected in the system
   def protected_branch?(branch_name)
-    protected_branch_names.include?(branch_name)
+    @protected_branches ||= self.protected_branches.to_a
+    ProtectedBranch.matching(branch_name, protected_branches: @protected_branches).present?
   end
 
   def developers_can_push_to_protected_branch?(branch_name)
-    protected_branches.any? { |pb| pb.name == branch_name && pb.developers_can_push }
+    protected_branches.matching(branch_name).any?(&:developers_can_push)
   end
 
   def forked?
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 33cf046fa75c47134518a7f05bbdad9ed0b083c1..b7011d7afdfcd827fc52761992ded119421309fc 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -8,4 +8,51 @@ class ProtectedBranch < ActiveRecord::Base
   def commit
     project.commit(self.name)
   end
+
+  # Returns all protected branches that match the given branch name.
+  # This realizes all records from the scope built up so far, and does
+  # _not_ return a relation.
+  #
+  # This method optionally takes in a list of `protected_branches` to search
+  # through, to avoid calling out to the database.
+  def self.matching(branch_name, protected_branches: nil)
+    (protected_branches || all).select { |protected_branch| protected_branch.matches?(branch_name) }
+  end
+
+  # Returns all branches (among the given list of branches [`Gitlab::Git::Branch`])
+  # that match the current protected branch.
+  def matching(branches)
+    branches.select { |branch| self.matches?(branch.name) }
+  end
+
+  # Checks if the protected branch matches the given branch name.
+  def matches?(branch_name)
+    return false if self.name.blank?
+
+    exact_match?(branch_name) || wildcard_match?(branch_name)
+  end
+
+  # Checks if this protected branch contains a wildcard
+  def wildcard?
+    self.name && self.name.include?('*')
+  end
+
+  protected
+
+  def exact_match?(branch_name)
+    self.name == branch_name
+  end
+
+  def wildcard_match?(branch_name)
+    wildcard_regex === branch_name
+  end
+
+  def wildcard_regex
+    @wildcard_regex ||= begin
+      name = self.name.gsub('*', 'STAR_DONT_ESCAPE')
+      quoted_name = Regexp.quote(name)
+      regex_string = quoted_name.gsub('STAR_DONT_ESCAPE', '.*?')
+      /\A#{regex_string}\z/
+    end
+  end
 end
diff --git a/app/views/projects/protected_branches/_branches_list.html.haml b/app/views/projects/protected_branches/_branches_list.html.haml
index 565905cbe7b9d5b2b76f715488037faf8ea893a0..97cb1a9052b497b1c9ede989fbbb9e54307fee5a 100644
--- a/app/views/projects/protected_branches/_branches_list.html.haml
+++ b/app/views/projects/protected_branches/_branches_list.html.haml
@@ -1,6 +1,6 @@
 %h5.prepend-top-0
-  Already Protected (#{@branches.size})
-- if @branches.empty?
+  Already Protected (#{@protected_branches.size})
+- if @protected_branches.empty?
   %p.settings-message.text-center
     No branches are protected, protect a branch with the form above.
 - else
@@ -9,33 +9,18 @@
     %table.table.protected-branches-list
       %colgroup
         %col{ width: "30%" }
-        %col{ width: "30%" }
+        %col{ width: "25%" }
         %col{ width: "25%" }
         - if can_admin_project
           %col
       %thead
         %tr
-          %th Branch
-          %th Last commit
-          %th Developers can push
+          %th Protected Branch
+          %th Commit
+          %th Developers Can Push
           - if can_admin_project
             %th
       %tbody
-        - @branches.each do |branch|
-          - @url = namespace_project_protected_branch_path(@project.namespace, @project, branch)
-          %tr
-            %td
-              = link_to(branch.name, namespace_project_commits_path(@project.namespace, @project, branch.name))
-              - if @project.root_ref?(branch.name)
-                %span.label.label-info.prepend-left-5 default
-            %td
-              - if commit = branch.commit
-                = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id')
-                #{time_ago_with_tooltip(commit.committed_date)}
-              - else
-                (branch was removed from repository)
-            %td
-              = check_box_tag("developers_can_push", branch.id, branch.developers_can_push, data: { url: @url })
-            - if can_admin_project
-              %td
-                = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: "btn btn-warning btn-sm"
+        = render partial: @protected_branches, locals: { can_admin_project: can_admin_project }
+
+  = paginate @protected_branches, theme: 'gitlab'
diff --git a/app/views/projects/protected_branches/_dropdown.html.haml b/app/views/projects/protected_branches/_dropdown.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..b803d932e676057b5607e35646233c372240daa3
--- /dev/null
+++ b/app/views/projects/protected_branches/_dropdown.html.haml
@@ -0,0 +1,17 @@
+= f.hidden_field(:name)
+
+= dropdown_tag("Protected Branch",
+               options: { title: "Pick protected branch", toggle_class: 'js-protected-branch-select js-filter-submit',
+                          filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search protected branches",
+                          footer_content: true,
+                          data: { show_no: true, show_any: true, show_upcoming: true,
+                                  selected: params[:protected_branch_name],
+                                  project_id: @project.try(:id) } }) do
+
+  %ul.dropdown-footer-list.hidden.protected-branch-select-footer-list
+    %li
+      = link_to '#', title: "New Protected Branch", class: "create-new-protected-branch" do
+        Create new
+
+:javascript
+  new ProtectedBranchSelect();
diff --git a/app/views/projects/protected_branches/_matching_branch.html.haml b/app/views/projects/protected_branches/_matching_branch.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..8a5332ca5bb61146a26b73ec95cc7b2372ee1fd8
--- /dev/null
+++ b/app/views/projects/protected_branches/_matching_branch.html.haml
@@ -0,0 +1,9 @@
+%tr
+  %td
+    = link_to matching_branch.name, namespace_project_tree_path(@project.namespace, @project, matching_branch.name)
+    - if @project.root_ref?(matching_branch.name)
+      %span.label.label-info.prepend-left-5 default
+  %td
+    - commit = @project.commit(matching_branch.name)
+    = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id')
+    = time_ago_with_tooltip(commit.committed_date)
diff --git a/app/views/projects/protected_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..474aec3a97cfa19474fbb242c1fcd588c4680c95
--- /dev/null
+++ b/app/views/projects/protected_branches/_protected_branch.html.haml
@@ -0,0 +1,21 @@
+- url = namespace_project_protected_branch_path(@project.namespace, @project, protected_branch)
+%tr
+  %td
+    = protected_branch.name
+    - if @project.root_ref?(protected_branch.name)
+      %span.label.label-info.prepend-left-5 default
+  %td
+    - if protected_branch.wildcard?
+      - matching_branches = protected_branch.matching(repository.branches)
+      = link_to pluralize(matching_branches.count, "matching branch"), namespace_project_protected_branch_path(@project.namespace, @project, protected_branch)
+    - else
+      - if commit = protected_branch.commit
+        = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id')
+        = time_ago_with_tooltip(commit.committed_date)
+      - else
+        (branch was removed from repository)
+  %td
+    = check_box_tag("developers_can_push", protected_branch.id, protected_branch.developers_can_push, data: { url: url })
+  - if can_admin_project
+    %td
+      = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: "btn btn-warning btn-sm pull-right"
diff --git a/app/views/projects/protected_branches/index.html.haml b/app/views/projects/protected_branches/index.html.haml
index c7d317dbaee51b94eb899a976a9a2867c6a179ff..5669713d9a15038f12fa078910b0021ac269dcec 100644
--- a/app/views/projects/protected_branches/index.html.haml
+++ b/app/views/projects/protected_branches/index.html.haml
@@ -4,30 +4,38 @@
   .col-lg-3
     %h4.prepend-top-0
       = page_title
-    %p Keep stable branches secure and force developers to use Merge Requests
-  .col-lg-9
-    %h5.prepend-top-0
-      Protect a branch
-    .account-well.append-bottom-default
-      %p.light-header.append-bottom-0 Protected branches are designed to
+    %p Keep stable branches secure and force developers to use merge requests.
+    %p.prepend-top-20
+      Protected branches are designed to:
       %ul
         %li prevent pushes from everybody except #{link_to "masters", help_page_path("permissions", "permissions"), class: "vlink"}
         %li prevent anyone from force pushing to the branch
         %li prevent anyone from deleting the branch
       %p.append-bottom-0 Read more about #{link_to "project permissions", help_page_path("permissions", "permissions"), class: "underlined-link"}
+  .col-lg-9
+    %h5.prepend-top-0
+      Protect a branch
     - if can? current_user, :admin_project, @project
       = form_for [@project.namespace.becomes(Namespace), @project, @protected_branch] do |f|
         = form_errors(@protected_branch)
 
         .form-group
           = f.label :name, "Branch", class: "label-light"
-          = f.select(:name, @project.open_branches.map { |br| [br.name, br.name] } , {include_blank: true}, {class: "select2", data: {placeholder: "Select branch"}})
+          = render partial: "dropdown", locals: { f: f }
+          %p.help-block
+            = link_to "Wildcards", help_page_path(category: 'workflow', file: 'protected_branches', format: 'md', anchor: "wildcard-protected-branches")
+            such as
+            %code *-stable
+            or
+            %code production/*
+            are supported.
+
         .form-group
           = f.check_box :developers_can_push, class: "pull-left"
           .prepend-left-20
             = f.label :developers_can_push, "Developers can push", class: "label-light append-bottom-0"
             %p.light.append-bottom-0
               Allow developers to push to this branch
-        = f.submit "Protect", class: "btn-create btn"
+        = f.submit "Protect", class: "btn-create btn protect-branch-btn", disabled: true
     %hr
     = render "branches_list"
diff --git a/app/views/projects/protected_branches/show.html.haml b/app/views/projects/protected_branches/show.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..4d8169815b3916564fdd0655a97db1f50aef34b3
--- /dev/null
+++ b/app/views/projects/protected_branches/show.html.haml
@@ -0,0 +1,25 @@
+- page_title @protected_branch.name, "Protected Branches"
+
+.row.prepend-top-default.append-bottom-default
+  .col-lg-3
+    %h4.prepend-top-0
+      = @protected_branch.name
+
+  .col-lg-9
+    %h5 Matching Branches
+    - if @matching_branches.present?
+      .table-responsive
+        %table.table.protected-branches-list
+          %colgroup
+            %col{ width: "30%" }
+            %col{ width: "30%" }
+          %thead
+            %tr
+              %th Branch
+              %th Last commit
+          %tbody
+            - @matching_branches.each do |matching_branch|
+              = render partial: "matching_branch", object: matching_branch
+    - else
+      %p.settings-message.text-center
+        Couldn't find any matching branches.
diff --git a/config/routes.rb b/config/routes.rb
index 1572656b8c5f3eb1d57926e23bfde6c06616dd49..18a4ead2b377e9dcb9de0b95b60545f1233a817a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -720,7 +720,7 @@ Rails.application.routes.draw do
           resource :release, only: [:edit, :update]
         end
 
-        resources :protected_branches, only: [:index, :create, :update, :destroy], constraints: { id: Gitlab::Regex.git_reference_regex }
+        resources :protected_branches, only: [:index, :show, :create, :update, :destroy], constraints: { id: Gitlab::Regex.git_reference_regex }
         resources :variables, only: [:index, :show, :update, :create, :destroy]
         resources :triggers, only: [:index, :create, :destroy]
 
diff --git a/doc/workflow/protected_branches.md b/doc/workflow/protected_branches.md
index d854ec1e0250643b27ea072c0d530cbb063694da..67adfc2f43aff731517a9898ea701d55e32bdd71 100644
--- a/doc/workflow/protected_branches.md
+++ b/doc/workflow/protected_branches.md
@@ -1,4 +1,4 @@
-# Protected branches
+# Protected Branches
 
 Permissions in GitLab are fundamentally defined around the idea of having read or write permission to the repository and branches.
 
@@ -28,4 +28,28 @@ For those workflows, you can allow everyone with write access to push to a prote
 
 On already protected branches you can also allow developers to push to the repository by selecting the `Developers can push` check box.
 
-![Developers can push](protected_branches/protected_branches2.png)
\ No newline at end of file
+![Developers can push](protected_branches/protected_branches2.png)
+
+## Wildcard Protected Branches
+
+>**Note:**
+This feature was added in GitLab 8.10.
+
+1. You can specify a wildcard protected branch, which will protect all branches matching the wildcard. For example:
+
+    | Wildcard Protected Branch | Matching Branches                                      |
+    |---------------------------+--------------------------------------------------------|
+    | `*-stable`                | `production-stable`, `staging-stable`                  |
+    | `production/*`            | `production/app-server`, `production/load-balancer`    |
+    | `*gitlab*`                | `gitlab`, `gitlab/staging`, `master/gitlab/production` |
+
+1. Protected branch settings (like "Developers Can Push") apply to all matching branches.
+
+1. Two different wildcards can potentially match the same branch. For example, `*-stable` and `production-*` would both match a `production-stable` branch.
+   >**Note:**
+   If _any_ of these protected branches have "Developers Can Push" set to true, then `production-stable` has it set to true.
+
+1. If you click on a protected branch's name, you will be presented with a list of all matching branches:
+
+    ![protected branch matches](protected_branches/protected_branches3.png)
+
diff --git a/doc/workflow/protected_branches/protected_branches1.png b/doc/workflow/protected_branches/protected_branches1.png
index bb3ab7d791322e94a439a02a495ee322f03a3bf3..c00443803de5e8601137ffcf6597622822b43f26 100644
Binary files a/doc/workflow/protected_branches/protected_branches1.png and b/doc/workflow/protected_branches/protected_branches1.png differ
diff --git a/doc/workflow/protected_branches/protected_branches2.png b/doc/workflow/protected_branches/protected_branches2.png
index 58ace31ac571e4d5e72d4dcd3378a17f2c486875..a4f664d3b212e340605ce160f014ede14b4ce843 100644
Binary files a/doc/workflow/protected_branches/protected_branches2.png and b/doc/workflow/protected_branches/protected_branches2.png differ
diff --git a/doc/workflow/protected_branches/protected_branches3.png b/doc/workflow/protected_branches/protected_branches3.png
new file mode 100644
index 0000000000000000000000000000000000000000..2a50cb174bb7e8dd72f9f02990fdea674d542678
Binary files /dev/null and b/doc/workflow/protected_branches/protected_branches3.png differ
diff --git a/lib/gitlab/git/hook.rb b/lib/gitlab/git/hook.rb
index 57c41b4129835d9735bfa6eb22cd85ea8c357125..9b681e636c7fcc68171e4624a4b2f49d7d64639c 100644
--- a/lib/gitlab/git/hook.rb
+++ b/lib/gitlab/git/hook.rb
@@ -15,7 +15,7 @@ module Gitlab
       end
 
       def trigger(gl_id, oldrev, newrev, ref)
-        return true unless exists?
+        return [true, nil] unless exists?
 
         case name
         when "pre-receive", "post-receive"
@@ -70,13 +70,10 @@ module Gitlab
       end
 
       def call_update_hook(gl_id, oldrev, newrev, ref)
-        status = nil
-
         Dir.chdir(repo_path) do
-          status = system({ 'GL_ID' => gl_id }, path, ref, oldrev, newrev)
+          stdout, stderr, status = Open3.capture3({ 'GL_ID' => gl_id }, path, ref, oldrev, newrev)
+          [status.success?, stderr.presence || stdout]
         end
-
-        [status, nil]
       end
 
       def retrieve_error_message(stderr, stdout)
diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d94dee0c797628df3652597e98a97d0b4b93e61d
--- /dev/null
+++ b/spec/features/protected_branches_spec.rb
@@ -0,0 +1,84 @@
+require 'spec_helper'
+
+feature 'Projected Branches', feature: true, js: true do
+  let(:user) { create(:user, :admin) }
+  let(:project) { create(:project) }
+
+  before { login_as(user) }
+
+  def set_protected_branch_name(branch_name)
+    find(".js-protected-branch-select").click
+    find(".dropdown-input-field").set(branch_name)
+    click_on "Create Protected Branch: #{branch_name}"
+  end
+
+  describe "explicit protected branches" do
+    it "allows creating explicit protected branches" do
+      visit namespace_project_protected_branches_path(project.namespace, project)
+      set_protected_branch_name('some-branch')
+      click_on "Protect"
+
+      within(".protected-branches-list") { expect(page).to have_content('some-branch') }
+      expect(ProtectedBranch.count).to eq(1)
+      expect(ProtectedBranch.last.name).to eq('some-branch')
+    end
+
+    it "displays the last commit on the matching branch if it exists" do
+      commit = create(:commit, project: project)
+      project.repository.add_branch(user, 'some-branch', commit.id)
+
+      visit namespace_project_protected_branches_path(project.namespace, project)
+      set_protected_branch_name('some-branch')
+      click_on "Protect"
+
+      within(".protected-branches-list") { expect(page).to have_content(commit.id[0..7]) }
+    end
+
+    it "displays an error message if the named branch does not exist" do
+      visit namespace_project_protected_branches_path(project.namespace, project)
+      set_protected_branch_name('some-branch')
+      click_on "Protect"
+
+      within(".protected-branches-list") { expect(page).to have_content('branch was removed') }
+    end
+  end
+
+  describe "wildcard protected branches" do
+    it "allows creating protected branches with a wildcard" do
+      visit namespace_project_protected_branches_path(project.namespace, project)
+      set_protected_branch_name('*-stable')
+      click_on "Protect"
+
+      within(".protected-branches-list") { expect(page).to have_content('*-stable') }
+      expect(ProtectedBranch.count).to eq(1)
+      expect(ProtectedBranch.last.name).to eq('*-stable')
+    end
+
+    it "displays the number of matching branches" do
+      project.repository.add_branch(user, 'production-stable', 'master')
+      project.repository.add_branch(user, 'staging-stable', 'master')
+
+      visit namespace_project_protected_branches_path(project.namespace, project)
+      set_protected_branch_name('*-stable')
+      click_on "Protect"
+
+      within(".protected-branches-list") { expect(page).to have_content("2 matching branches") }
+    end
+
+    it "displays all the branches matching the wildcard" do
+      project.repository.add_branch(user, 'production-stable', 'master')
+      project.repository.add_branch(user, 'staging-stable', 'master')
+      project.repository.add_branch(user, 'development', 'master')
+      create(:protected_branch, project: project, name: "*-stable")
+
+      visit namespace_project_protected_branches_path(project.namespace, project)
+      click_on "2 matching branches"
+
+      within(".protected-branches-list") do
+        expect(page).to have_content("production-stable")
+        expect(page).to have_content("staging-stable")
+        expect(page).not_to have_content("development")
+      end
+    end
+  end
+end
diff --git a/spec/lib/gitlab/git/hook_spec.rb b/spec/lib/gitlab/git/hook_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a15aa173fbd314baf0435024a1ef9e6cfabcc12e
--- /dev/null
+++ b/spec/lib/gitlab/git/hook_spec.rb
@@ -0,0 +1,70 @@
+require 'spec_helper'
+require 'fileutils'
+
+describe Gitlab::Git::Hook, lib: true do
+  describe "#trigger" do
+    let(:project) { create(:project) }
+    let(:user) { create(:user) }
+
+    def create_hook(name)
+      FileUtils.mkdir_p(File.join(project.repository.path, 'hooks'))
+      File.open(File.join(project.repository.path, 'hooks', name), 'w', 0755) do |f|
+        f.write('exit 0')
+      end
+    end
+
+    def create_failing_hook(name)
+      FileUtils.mkdir_p(File.join(project.repository.path, 'hooks'))
+      File.open(File.join(project.repository.path, 'hooks', name), 'w', 0755) do |f|
+        f.write(<<-HOOK)
+          echo 'regular message from the hook'
+          echo 'error message from the hook' 1>&2
+          exit 1
+        HOOK
+      end
+    end
+
+    ['pre-receive', 'post-receive', 'update'].each do |hook_name|
+
+      context "when triggering a #{hook_name} hook" do
+        context "when the hook is successful" do
+          it "returns success with no errors" do
+            create_hook(hook_name)
+            hook = Gitlab::Git::Hook.new(hook_name, project.repository.path)
+            blank = Gitlab::Git::BLANK_SHA
+            ref = Gitlab::Git::BRANCH_REF_PREFIX + 'new_branch'
+
+            status, errors = hook.trigger(Gitlab::GlId.gl_id(user), blank, blank, ref)
+            expect(status).to be true
+            expect(errors).to be_blank
+          end
+        end
+
+        context "when the hook is unsuccessful" do
+          it "returns failure with errors" do
+            create_failing_hook(hook_name)
+            hook = Gitlab::Git::Hook.new(hook_name, project.repository.path)
+            blank = Gitlab::Git::BLANK_SHA
+            ref = Gitlab::Git::BRANCH_REF_PREFIX + 'new_branch'
+
+            status, errors = hook.trigger(Gitlab::GlId.gl_id(user), blank, blank, ref)
+            expect(status).to be false
+            expect(errors).to eq("error message from the hook\n")
+          end
+        end
+      end
+    end
+
+    context "when the hook doesn't exist" do
+      it "returns success with no errors" do
+        hook = Gitlab::Git::Hook.new('unknown_hook', project.repository.path)
+        blank = Gitlab::Git::BLANK_SHA
+        ref = Gitlab::Git::BRANCH_REF_PREFIX + 'new_branch'
+
+        status, errors = hook.trigger(Gitlab::GlId.gl_id(user), blank, blank, ref)
+        expect(status).to be true
+        expect(errors).to be_nil
+      end
+    end
+  end
+end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 1b434a726dcd077dd3ffd4c1cca0234aa1cac997..5a27ccbab0a1780bcc1c6c63e27ed0751ad6bf1a 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -449,6 +449,14 @@ describe Project, models: true do
 
     it { expect(project.open_branches.map(&:name)).to include('feature') }
     it { expect(project.open_branches.map(&:name)).not_to include('master') }
+
+    it "includes branches matching a protected branch wildcard" do
+      expect(project.open_branches.map(&:name)).to include('feature')
+
+      create(:protected_branch, name: 'feat*', project: project)
+
+      expect(Project.find(project.id).open_branches.map(&:name)).to include('feature')
+    end
   end
 
   describe '#star_count' do
@@ -949,15 +957,67 @@ describe Project, models: true do
   describe '#protected_branch?' do
     let(:project) { create(:empty_project) }
 
-    it 'returns true when a branch is a protected branch' do
+    it 'returns true when the branch matches a protected branch via direct match' do
       project.protected_branches.create!(name: 'foo')
 
       expect(project.protected_branch?('foo')).to eq(true)
     end
 
-    it 'returns false when a branch is not a protected branch' do
+    it 'returns true when the branch matches a protected branch via wildcard match' do
+      project.protected_branches.create!(name: 'production/*')
+
+      expect(project.protected_branch?('production/some-branch')).to eq(true)
+    end
+
+    it 'returns false when the branch does not match a protected branch via direct match' do
       expect(project.protected_branch?('foo')).to eq(false)
     end
+
+    it 'returns false when the branch does not match a protected branch via wildcard match' do
+      project.protected_branches.create!(name: 'production/*')
+
+      expect(project.protected_branch?('staging/some-branch')).to eq(false)
+    end
+  end
+
+  describe "#developers_can_push_to_protected_branch?" do
+    let(:project) { create(:empty_project) }
+
+    context "when the branch matches a protected branch via direct match" do
+      it "returns true if 'Developers can Push' is turned on" do
+        create(:protected_branch, name: "production", project: project, developers_can_push: true)
+
+        expect(project.developers_can_push_to_protected_branch?('production')).to be true
+      end
+
+      it "returns false if 'Developers can Push' is turned off" do
+        create(:protected_branch, name: "production", project: project, developers_can_push: false)
+
+        expect(project.developers_can_push_to_protected_branch?('production')).to be false
+      end
+    end
+
+    context "when the branch matches a protected branch via wilcard match" do
+      it "returns true if 'Developers can Push' is turned on" do
+        create(:protected_branch, name: "production/*", project: project, developers_can_push: true)
+
+        expect(project.developers_can_push_to_protected_branch?('production/some-branch')).to be true
+      end
+
+      it "returns false if 'Developers can Push' is turned off" do
+        create(:protected_branch, name: "production/*", project: project, developers_can_push: false)
+
+        expect(project.developers_can_push_to_protected_branch?('production/some-branch')).to be false
+      end
+    end
+
+    context "when the branch does not match a protected branch" do
+      it "returns false" do
+        create(:protected_branch, name: "production/*", project: project, developers_can_push: true)
+
+        expect(project.developers_can_push_to_protected_branch?('staging/some-branch')).to be false
+      end
+    end
   end
 
   describe '#container_registry_path_with_namespace' do
diff --git a/spec/models/protected_branch_spec.rb b/spec/models/protected_branch_spec.rb
index b523834c6e93ca0e5d075ce731624c7cbdba6975..8bf0d24a12817ad79bccef97dfb2677efdc2a8f3 100644
--- a/spec/models/protected_branch_spec.rb
+++ b/spec/models/protected_branch_spec.rb
@@ -1,6 +1,8 @@
 require 'spec_helper'
 
 describe ProtectedBranch, models: true do
+  subject { build_stubbed(:protected_branch) }
+
   describe 'Associations' do
     it { is_expected.to belong_to(:project) }
   end
@@ -12,4 +14,127 @@ describe ProtectedBranch, models: true do
     it { is_expected.to validate_presence_of(:project) }
     it { is_expected.to validate_presence_of(:name) }
   end
+
+  describe "#matches?" do
+    context "when the protected branch setting is not a wildcard" do
+      let(:protected_branch) { build(:protected_branch, name: "production/some-branch") }
+
+      it "returns true for branch names that are an exact match" do
+        expect(protected_branch.matches?("production/some-branch")).to be true
+      end
+
+      it "returns false for branch names that are not an exact match" do
+        expect(protected_branch.matches?("staging/some-branch")).to be false
+      end
+    end
+
+    context "when the protected branch name contains wildcard(s)" do
+      context "when there is a single '*'" do
+        let(:protected_branch) { build(:protected_branch, name: "production/*") }
+
+        it "returns true for branch names matching the wildcard" do
+          expect(protected_branch.matches?("production/some-branch")).to be true
+          expect(protected_branch.matches?("production/")).to be true
+        end
+
+        it "returns false for branch names not matching the wildcard" do
+          expect(protected_branch.matches?("staging/some-branch")).to be false
+          expect(protected_branch.matches?("production")).to be false
+        end
+      end
+
+      context "when the wildcard contains regex symbols other than a '*'" do
+        let(:protected_branch) { build(:protected_branch, name: "pro.duc.tion/*") }
+
+        it "returns true for branch names matching the wildcard" do
+          expect(protected_branch.matches?("pro.duc.tion/some-branch")).to be true
+        end
+
+        it "returns false for branch names not matching the wildcard" do
+          expect(protected_branch.matches?("production/some-branch")).to be false
+          expect(protected_branch.matches?("proXducYtion/some-branch")).to be false
+        end
+      end
+
+      context "when there are '*'s at either end" do
+        let(:protected_branch) { build(:protected_branch, name: "*/production/*") }
+
+        it "returns true for branch names matching the wildcard" do
+          expect(protected_branch.matches?("gitlab/production/some-branch")).to be true
+          expect(protected_branch.matches?("/production/some-branch")).to be true
+          expect(protected_branch.matches?("gitlab/production/")).to be true
+          expect(protected_branch.matches?("/production/")).to be true
+        end
+
+        it "returns false for branch names not matching the wildcard" do
+          expect(protected_branch.matches?("gitlabproductionsome-branch")).to be false
+          expect(protected_branch.matches?("production/some-branch")).to be false
+          expect(protected_branch.matches?("gitlab/production")).to be false
+          expect(protected_branch.matches?("production")).to be false
+        end
+      end
+
+      context "when there are arbitrarily placed '*'s" do
+        let(:protected_branch) { build(:protected_branch, name: "pro*duction/*/gitlab/*") }
+
+        it "returns true for branch names matching the wildcard" do
+          expect(protected_branch.matches?("production/some-branch/gitlab/second-branch")).to be true
+          expect(protected_branch.matches?("proXYZduction/some-branch/gitlab/second-branch")).to be true
+          expect(protected_branch.matches?("proXYZduction/gitlab/gitlab/gitlab")).to be true
+          expect(protected_branch.matches?("proXYZduction//gitlab/")).to be true
+          expect(protected_branch.matches?("proXYZduction/some-branch/gitlab/")).to be true
+          expect(protected_branch.matches?("proXYZduction//gitlab/some-branch")).to be true
+        end
+
+        it "returns false for branch names not matching the wildcard" do
+          expect(protected_branch.matches?("production/some-branch/not-gitlab/second-branch")).to be false
+          expect(protected_branch.matches?("prodXYZuction/some-branch/gitlab/second-branch")).to be false
+          expect(protected_branch.matches?("proXYZduction/gitlab/some-branch/gitlab")).to be false
+          expect(protected_branch.matches?("proXYZduction/gitlab//")).to be false
+          expect(protected_branch.matches?("proXYZduction/gitlab/")).to be false
+          expect(protected_branch.matches?("proXYZduction//some-branch/gitlab")).to be false
+        end
+      end
+    end
+  end
+
+  describe "#matching" do
+    context "for direct matches" do
+      it "returns a list of protected branches matching the given branch name" do
+        production = create(:protected_branch, name: "production")
+        staging = create(:protected_branch, name: "staging")
+
+        expect(ProtectedBranch.matching("production")).to include(production)
+        expect(ProtectedBranch.matching("production")).not_to include(staging)
+      end
+
+      it "accepts a list of protected branches to search from, so as to avoid a DB call" do
+        production = build(:protected_branch, name: "production")
+        staging = build(:protected_branch, name: "staging")
+
+        expect(ProtectedBranch.matching("production")).to be_empty
+        expect(ProtectedBranch.matching("production", protected_branches: [production, staging])).to include(production)
+        expect(ProtectedBranch.matching("production", protected_branches: [production, staging])).not_to include(staging)
+      end
+    end
+
+    context "for wildcard matches" do
+      it "returns a list of protected branches matching the given branch name" do
+        production = create(:protected_branch, name: "production/*")
+        staging = create(:protected_branch, name: "staging/*")
+
+        expect(ProtectedBranch.matching("production/some-branch")).to include(production)
+        expect(ProtectedBranch.matching("production/some-branch")).not_to include(staging)
+      end
+
+      it "accepts a list of protected branches to search from, so as to avoid a DB call" do
+        production = build(:protected_branch, name: "production/*")
+        staging = build(:protected_branch, name: "staging/*")
+
+        expect(ProtectedBranch.matching("production/some-branch")).to be_empty
+        expect(ProtectedBranch.matching("production/some-branch", protected_branches: [production, staging])).to include(production)
+        expect(ProtectedBranch.matching("production/some-branch", protected_branches: [production, staging])).not_to include(staging)
+      end
+    end
+  end
 end
diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb
index 9f9ef20f99b55015692d58f5bd0e92b5d53a2ee3..6b99b0f24cb0cb669c696a10875956007bd53791 100644
--- a/spec/support/test_env.rb
+++ b/spec/support/test_env.rb
@@ -63,7 +63,7 @@ module TestEnv
   end
 
   def disable_pre_receive
-    allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return(true)
+    allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([true, nil])
   end
 
   # Clean /tmp/tests