diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 477bc9fcf759846137637b48ad6238584d244e35..17634d7089914deed9e26c757953c7138c48bed4 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -56,15 +56,24 @@ &.right { float: right; padding-right: 0; + } - a { - color: $gl-text-color; - } + .modify-merge-commit-link { + color: $gl-text-color; } - .remove_source_checkbox { + .merge-param-checkbox { margin: 0; } + + a .fa-question-circle { + color: $gl-text-color-secondary; + + &:hover, + &:focus { + color: $link-hover-color; + } + } } } diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index c3eeb16e2e6f9c76df06da9c2f1fbfa9a32f87c2..53081421a2096cd3e539dee84f438dcbb2d04a35 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -338,7 +338,7 @@ def merge return end - @merge_request.update(merge_error: nil) + @merge_request.update(merge_error: nil, squash: merge_params[:squash]) if params[:merge_when_build_succeeds].present? unless @merge_request.head_pipeline @@ -699,6 +699,7 @@ def merge_request_params_ee approvals_before_merge approver_group_ids approver_ids + squash ] end @@ -716,7 +717,7 @@ def clamp_approvals_before_merge(mr_params) end def merge_params - params.permit(:should_remove_source_branch, :commit_message) + params.permit(:should_remove_source_branch, :commit_message, :squash) end # Make sure merge requests created before 8.0 diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index a996297cfdf0ea5dd24bc53c7ca8032cf2461616..91847f58aa97c88895b2b0897e28f4d514250281 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -181,4 +181,16 @@ def version_index(merge_request_diff) def different_base?(version1, version2) version1 && version2 && version1.base_commit_sha != version2.base_commit_sha end + + def merge_params(merge_request) + { + merge_when_build_succeeds: true, + should_remove_source_branch: true, + sha: merge_request.diff_head_sha + }.merge(merge_params_ee(merge_request)) + end + + def merge_params_ee(merge_request) + { squash: merge_request.squash } + end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index b689a3dbc7a6e06c3d400993c17b614372ffd92e..b371688665570b516a0c2502e8b9f537008a8601 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -812,6 +812,10 @@ def rebase_dir_path File.join(Gitlab.config.shared.path, 'tmp/rebase', source_project.id.to_s, id.to_s).to_s end + def squash_dir_path + File.join(Gitlab.config.shared.path, 'tmp/squash', source_project.id.to_s, id.to_s).to_s + end + def rebase_in_progress? # The source project can be deleted return false unless source_project @@ -820,14 +824,26 @@ def rebase_in_progress? end def clean_stuck_rebase - expiration_time = Time.now - 15.minutes - - if File.new(rebase_dir_path).mtime < expiration_time + if File.mtime(rebase_dir_path) < 15.minutes.ago FileUtils.rm_rf(rebase_dir_path) true end end + def squash_in_progress? + # The source project can be deleted + return false unless source_project + + File.exist?(squash_dir_path) && !clean_stuck_squash + end + + def clean_stuck_squash + if File.mtime(squash_dir_path) < 15.minutes.ago + FileUtils.rm_rf(squash_dir_path) + true + end + end + def diverged_commits_count cache = Rails.cache.read(:"merge_request_#{id}_diverged_commits") diff --git a/app/models/repository.rb b/app/models/repository.rb index 56ef3d6532155cbce37aab15c75cb7d499882b4a..4c6598ffa2026014a84c8fed5e4f4be6047bdf56 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -1027,9 +1027,9 @@ def ff_merge(user, source, target_branch, merge_request: nil) end end - def merge(user, merge_request, options = {}) + def merge(user, source, merge_request, options = {}) our_commit = rugged.branches[merge_request.target_branch].target - their_commit = rugged.lookup(merge_request.diff_head_sha) + their_commit = rugged.lookup(source) raise "Invalid merge target" if our_commit.nil? raise "Invalid merge source" if their_commit.nil? diff --git a/app/services/merge_requests/ff_merge_service.rb b/app/services/merge_requests/ff_merge_service.rb index a0a95d83d586ac77c6acd089b8aad8131a526edc..9658864478487e2febf3ae47377648f6be8fa954 100644 --- a/app/services/merge_requests/ff_merge_service.rb +++ b/app/services/merge_requests/ff_merge_service.rb @@ -10,7 +10,7 @@ class FfMergeService < MergeRequests::MergeService def commit repository.ff_merge(current_user, - merge_request.diff_head_sha, + source, merge_request.target_branch, merge_request: merge_request) ensure diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index faf419e3ac03a0fe5216dceac6c23a3406245856..c8014ef1e04d5c0fca43c0d55625dbec5c669f8c 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -6,7 +6,7 @@ module MergeRequests # Executed when you do merge via GitLab UI # class MergeService < MergeRequests::BaseService - attr_reader :merge_request + attr_reader :merge_request, :source def execute(merge_request) if project.merge_requests_ff_only_enabled && !self.is_a?(FfMergeService) @@ -24,6 +24,10 @@ def execute(merge_request) return error(message) end + @source = find_merge_source + + return log_merge_error('No source for merge', true) unless @source + merge_request.in_locked_state do if commit after_merge @@ -64,7 +68,7 @@ def commit committer: committer } - commit_id = repository.merge(current_user, merge_request, options) + commit_id = repository.merge(current_user, source, merge_request, options) if commit_id merge_request.update(merge_commit_sha: commit_id) @@ -103,9 +107,21 @@ def log_merge_error(message, http_error = false) end def merge_request_info - project = merge_request.project + merge_request.to_reference(full: true) + end + + def find_merge_source + return merge_request.diff_head_sha unless merge_request.squash - "#{project.to_reference}#{merge_request.to_reference}" + squash_result = SquashService.new(project, current_user, params).execute(merge_request) + + if squash_result[:status] == :success + squash_result[:squash_sha] + else + log_merge_error("Squashing #{merge_request_info} failed") + + nil + end end end end diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb index 7297468d0cbc31bfe8fb30227fbba4555c05ff4d..2e34e45c7940b66f50393001bd4ab7bc21f8b80f 100644 --- a/app/services/merge_requests/rebase_service.rb +++ b/app/services/merge_requests/rebase_service.rb @@ -1,15 +1,5 @@ module MergeRequests - # MergeService class - # - # Do git merge and in case of success - # mark merge request as merged and execute all hooks and notifications - # Executed when you do merge via GitLab UI - # - class RebaseService < MergeRequests::BaseService - include Gitlab::Popen - - attr_reader :merge_request - + class RebaseService < MergeRequests::WorkingCopyBaseService def execute(merge_request) @merge_request = merge_request @@ -22,92 +12,53 @@ def execute(merge_request) def rebase if merge_request.rebase_in_progress? - log('Rebase task canceled: Another rebase is already in progress') + log_error('Rebase task canceled: Another rebase is already in progress') return false end - # Clone - output, status = popen( - %W(git clone -b #{merge_request.source_branch} -- #{source_project.repository.path_to_repo} #{tree_path}), + run_git_command( + %W(clone -b #{merge_request.source_branch} -- #{source_project.repository.path_to_repo} #{tree_path}), nil, - git_env + git_env, + 'clone repository for rebase' ) - unless status.zero? - log('Failed to clone repository for rebase:') - log(output) - return false - end - - # Rebase - output, status = popen( - %W(git pull --rebase #{target_project.repository.path_to_repo} #{merge_request.target_branch}), + run_git_command( + %W(pull --rebase #{target_project.repository.path_to_repo} #{merge_request.target_branch}), tree_path, - git_env + git_env, + 'rebase branch' ) - unless status.zero? - log('Failed to rebase branch:') - log(output) - return false - end - - output, status = popen( - %W(git rev-parse #{merge_request.source_branch}), + rebase_sha = run_git_command( + %W(rev-parse #{merge_request.source_branch}), tree_path, - git_env + git_env, + 'get SHA of rebased branch' ) - unless status.zero? - log('Failed to get SHA of rebased branch:') - log(output) - return false - end + merge_request.update_attributes(rebase_commit_sha: rebase_sha) - merge_request.update_attributes(rebase_commit_sha: output.chomp) - # Push - output, status = popen( - %W(git push -f origin #{merge_request.source_branch}), + run_git_command( + %W(push -f origin #{merge_request.source_branch}), tree_path, - git_env + git_env, + 'push rebased branch' ) - unless status.zero? - log('Failed to push rebased branch:') - log(output) - return false - end - true - rescue => ex - log('Failed to rebase branch:') - log(ex.message) + rescue GitCommandError + false + rescue => e + log_error('Failed to rebase branch:') + log_error(e) + false ensure clean_dir end - def source_project - @source_project ||= merge_request.source_project - end - - def target_project - @target_project ||= merge_request.target_project - end - def tree_path @tree_path ||= merge_request.rebase_dir_path end - - def log(message) - Gitlab::GitLogger.error(message) - end - - def clean_dir - FileUtils.rm_rf(tree_path) if File.exist?(tree_path) - end - - def git_env - { 'GL_ID' => Gitlab::GlId.gl_id(current_user), 'GL_PROTOCOL' => 'web' } - end end end diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..2890ac687c6c4ceb3d71ad727c9c650520ff0d71 --- /dev/null +++ b/app/services/merge_requests/squash_service.rb @@ -0,0 +1,69 @@ +require 'securerandom' + +module MergeRequests + class SquashService < MergeRequests::WorkingCopyBaseService + attr_reader :repository, :rugged + + def execute(merge_request) + @merge_request = merge_request + @repository = target_project.repository + @rugged = repository.rugged + + squash || error('Failed to squash. Should be done manually') + end + + def squash + if merge_request.commits_count <= 1 + return success(squash_sha: merge_request.diff_head_sha) + end + + if merge_request.squash_in_progress? + log_error('Squash task canceled: Another squash is already in progress') + return false + end + + run_git_command( + %W(worktree add #{tree_path} #{merge_request.target_branch} --detach), + repository.path_to_repo, + git_env, + 'add worktree for squash' + ) + + run_git_command(%w(apply --cached), tree_path, git_env, 'apply patch') do |stdin| + stdin.puts(merge_request_to_patch) + end + + run_git_command( + %W(commit -C #{merge_request.diff_head_sha}), + tree_path, + git_env.merge('GIT_COMMITTER_NAME' => current_user.name, 'GIT_COMMITTER_EMAIL' => current_user.email), + 'commit squashed changes' + ) + + squash_sha = run_git_command( + %w(rev-parse HEAD), + tree_path, + git_env, + 'get SHA of squashed commit' + ) + + success(squash_sha: squash_sha) + rescue GitCommandError + false + rescue => e + log_error("Failed to squash merge request #{merge_request.to_reference(full: true)}:") + log_error(e.message) + false + ensure + clean_dir + end + + def tree_path + @tree_path ||= merge_request.squash_dir_path + end + + def merge_request_to_patch + @merge_request_to_patch ||= rugged.diff(merge_request.diff_base_sha, merge_request.diff_head_sha).patch + end + end +end diff --git a/app/services/merge_requests/working_copy_base_service.rb b/app/services/merge_requests/working_copy_base_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..9eef390515cc72ca345bf4a658156920046c06ab --- /dev/null +++ b/app/services/merge_requests/working_copy_base_service.rb @@ -0,0 +1,53 @@ +module MergeRequests + class WorkingCopyBaseService < MergeRequests::BaseService + class GitCommandError < StandardError; end + + include Gitlab::Popen + + attr_reader :merge_request + + def run_git_command(command, path, env, message = nil, &block) + git_command = [Gitlab.config.git.bin_path] + command + output, status = popen(git_command, path, env, &block) + + unless status.zero? + if message + log_error("Failed to #{message} with `#{git_command.join(' ')}`:") + else + log_error("`#{git_command.join(' ')}` failed:") + end + + log_error(output) + + raise GitCommandError + end + + output.chomp + end + + def source_project + @source_project ||= merge_request.source_project + end + + def target_project + @target_project ||= merge_request.target_project + end + + def log_error(message) + Gitlab::GitLogger.error(message) + end + + def clean_dir + FileUtils.rm_rf(tree_path) if File.exist?(tree_path) + end + + def git_env + { 'GL_ID' => Gitlab::GlId.gl_id(current_user), 'GL_PROTOCOL' => 'web' } + end + + # Don't try to print expensive instance variables. + def inspect + "#<#{self.class} #{merge_request.to_reference(full: true)}>" + end + end +end diff --git a/app/views/projects/merge_requests/widget/open/_accept.html.haml b/app/views/projects/merge_requests/widget/open/_accept.html.haml index 5daeb6da6fdaddb910f167d5b18a68437a3116aa..eb3ed4295279155cfde63ee97d6a9e7d7bf51d62 100644 --- a/app/views/projects/merge_requests/widget/open/_accept.html.haml +++ b/app/views/projects/merge_requests/widget/open/_accept.html.haml @@ -35,11 +35,17 @@ The source branch will be removed. - elsif @merge_request.can_remove_source_branch?(current_user) .accept-control.checkbox - = label_tag :should_remove_source_branch, class: "remove_source_checkbox" do + = label_tag :should_remove_source_branch, class: "merge-param-checkbox" do = check_box_tag :should_remove_source_branch Remove source branch + .accept-control.checkbox + = label_tag :squash, class: 'merge-param-checkbox' do + = hidden_field_tag :squash, '0', id: nil + = check_box_tag :squash, '1', @merge_request.squash + Squash commits + = link_to icon('question-circle'), help_page_path('user/project/merge_requests/squash_and_merge'), title: 'About this feature', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} - .accept-control.right + .accept-control - if @project.merge_requests_ff_only_enabled Fast-forward merge without a merge commit - else diff --git a/app/views/projects/merge_requests/widget/open/_merge_when_build_succeeds.html.haml b/app/views/projects/merge_requests/widget/open/_merge_when_build_succeeds.html.haml index 9fd881766b06ff7740d47e2ec02b103c8b2234fc..23ba166f47bf747e990b72b93eff08334ba59203 100644 --- a/app/views/projects/merge_requests/widget/open/_merge_when_build_succeeds.html.haml +++ b/app/views/projects/merge_requests/widget/open/_merge_when_build_succeeds.html.haml @@ -7,10 +7,12 @@ %div %p = succeed '.' do + The changes will be + - if @merge_request.squash + squashed and - if @project.merge_requests_ff_only_enabled - The changes will be fast-forward merged into - - else - The changes will be merged into + fast-forward + merged into %span.label-branch= @merge_request.target_branch - if @merge_request.remove_source_branch? The source branch will be removed. @@ -22,7 +24,7 @@ - if remove_source_branch_button || user_can_cancel_automatic_merge .clearfix.prepend-top-10 - if remove_source_branch_button - = link_to merge_namespace_project_merge_request_path(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request, merge_when_build_succeeds: true, should_remove_source_branch: true, sha: @merge_request.diff_head_sha), remote: true, method: :post, class: "btn btn-grouped btn-primary btn-sm remove_source_branch" do + = link_to merge_namespace_project_merge_request_path(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request, merge_params(@merge_request)), remote: true, method: :post, class: "btn btn-grouped btn-primary btn-sm remove_source_branch" do = icon('times') Remove Source Branch When Merged diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 02114c82b19d31e820d12d4474d4e8574df0232f..8bc617a9c9ab1f3005a2cfb198762ac259412781 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -45,6 +45,8 @@ = render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form += render 'shared/issuable/form/merge_params', issuable: issuable + - if @merge_request_for_resolving_discussions .form-group .col-sm-10.col-sm-offset-2 diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index b757893ea049a4821faa8c2c9b947c135802c794..2793e7bcff430052a244b52a66813f0f9d51df50 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -19,12 +19,3 @@ - if issuable.new_record?   = link_to 'Change branches', mr_change_branches_path(issuable) - -- if issuable.can_remove_source_branch?(current_user) - .form-group - .col-sm-10.col-sm-offset-2 - .checkbox - = label_tag 'merge_request[force_remove_source_branch]' do - = hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil - = check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch? - Remove source branch when merge request is accepted. diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..73a306dc6a7e5ad8c26459f5c2f531563c35f51a --- /dev/null +++ b/app/views/shared/issuable/form/_merge_params.html.haml @@ -0,0 +1,20 @@ +- issuable = local_assigns.fetch(:issuable) + +- return unless issuable.is_a?(MergeRequest) +- return if issuable.closed_without_fork? + +.form-group + .col-sm-10.col-sm-offset-2 + - if issuable.can_remove_source_branch?(current_user) + .checkbox + = label_tag 'merge_request[force_remove_source_branch]' do + = hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil + = check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch? + Remove source branch when merge request is accepted. + + .checkbox + = label_tag 'merge_request[squash]' do + = hidden_field_tag 'merge_request[squash]', '0', id: nil + = check_box_tag 'merge_request[squash]', '1', issuable.squash + Squash commits when merge request is accepted. + = link_to 'About this feature', help_page_path('user/project/merge_requests/squash_and_merge') diff --git a/changelogs/unreleased-ee/squash.yml b/changelogs/unreleased-ee/squash.yml new file mode 100644 index 0000000000000000000000000000000000000000..cb73b963b0b5fecf536b0ae83051a8b12ae597fe --- /dev/null +++ b/changelogs/unreleased-ee/squash.yml @@ -0,0 +1,4 @@ +--- +title: Allow squashing merge requests into a single commit +merge_request: +author: diff --git a/db/migrate/20161230123835_add_squash_to_merge_requests.rb b/db/migrate/20161230123835_add_squash_to_merge_requests.rb new file mode 100644 index 0000000000000000000000000000000000000000..2509c11572243442d32a49d07b30f75494b72073 --- /dev/null +++ b/db/migrate/20161230123835_add_squash_to_merge_requests.rb @@ -0,0 +1,14 @@ +class AddSquashToMergeRequests < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + disable_ddl_transaction! + + DOWNTIME = false + + def up + add_column_with_default :merge_requests, :squash, :boolean, default: false, allow_null: false + end + + def down + remove_column :merge_requests, :squash + end +end diff --git a/db/schema.rb b/db/schema.rb index 9d610702b6478fc8d3c7166c3db97ffb4f680404..5d50ad38a238a87b2be5849fc971323d7f5c0e6e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -782,6 +782,7 @@ t.text "title_html" t.text "description_html" t.integer "time_estimate" + t.boolean "squash", default: false, null: false end add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index df3a23a0bdd41ce5cdfab5c6ed8d1c81967bcaf6..22f286df0bf06ea6529b1eeb9fa4f9c343f492b1 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -75,6 +75,7 @@ Parameters: "approvals_before_merge": null "should_remove_source_branch": true, "force_remove_source_branch": false, + "squash": false, "web_url": "http://example.com/example/example/merge_requests/1" } ] @@ -145,6 +146,7 @@ Parameters: "approvals_before_merge": null "should_remove_source_branch": true, "force_remove_source_branch": false, + "squash": false, "web_url": "http://example.com/example/example/merge_requests/1" } ``` @@ -251,6 +253,7 @@ Parameters: "approvals_before_merge": null, "should_remove_source_branch": true, "force_remove_source_branch": false, + "squash": false, "web_url": "http://example.com/example/example/merge_requests/1", "changes": [ { @@ -287,6 +290,7 @@ POST /projects/:id/merge_requests | `milestone_id` | integer | no | The ID of a milestone | | `remove_source_branch` | boolean | no | Flag indicating if a merge request should remove the source branch when merging | | `approvals_before_merge` | integer| no | Number of approvals required before this can be merged (see below) | +| `squash` | boolean| no | Squash commits into a single commit when merging | If `approvals_before_merge` is not provided, it inherits the value from the target project. If it is provided, then the following conditions must hold in @@ -349,6 +353,7 @@ order for it to take effect: "approvals_before_merge": null "should_remove_source_branch": true, "force_remove_source_branch": false, + "squash": false, "web_url": "http://example.com/example/example/merge_requests/1" } ``` @@ -374,6 +379,7 @@ PUT /projects/:id/merge_requests/:merge_request_id | `labels` | string | no | Labels for MR as a comma-separated list | | `milestone_id` | integer | no | The ID of a milestone | | `remove_source_branch` | boolean | no | Flag indicating if a merge request should remove the source branch when merging | +| `squash` | boolean| no | Squash commits into a single commit when merging | ```json { @@ -426,6 +432,7 @@ PUT /projects/:id/merge_requests/:merge_request_id "approvals_before_merge": null "should_remove_source_branch": true, "force_remove_source_branch": false, + "squash": false, "web_url": "http://example.com/example/example/merge_requests/1" } ``` @@ -471,7 +478,19 @@ Parameters: - `merge_commit_message` (optional) - Custom merge commit message - `should_remove_source_branch` (optional) - if `true` removes the source branch - `merge_when_build_succeeds` (optional) - if `true` the MR is merged when the build succeeds -- `sha` (optional) - if present, then this SHA must match the HEAD of the source branch, otherwise the merge will fail +- `sha` (optional) - if present, then this SHA must + + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | string | yes | The ID of a project | +| `merge_request_id` | integer | yes | The ID of the merge request | +| `merge_commit_message` | string | no | Custom merge commit message | +| `should_remove_source_branch` | boolean | no | Remove the source branch after merge | +| `merge_when_build_succeeds` | boolean | no | Merge when build succeeds, rather than immediately | +| `sha` | string | no | If present, then this SHA must match the HEAD of the source branch, otherwise the merge will fail | +| `squash` | boolean | no | Squash the merge request into a single commit | + ```json { @@ -525,6 +544,7 @@ Parameters: "approvals_before_merge": null "should_remove_source_branch": true, "force_remove_source_branch": false, + "squash": false, "web_url": "http://example.com/example/example/merge_requests/1" } ``` @@ -698,6 +718,7 @@ Parameters: "approvals_before_merge": null "should_remove_source_branch": true, "force_remove_source_branch": false, + "squash": false, "web_url": "http://example.com/example/example/merge_requests/1" } ``` @@ -1021,6 +1042,7 @@ Example response: "user_notes_count": 7, "should_remove_source_branch": true, "force_remove_source_branch": false, + "squash": true, "web_url": "http://example.com/example/example/merge_requests/1" }, "target_url": "https://gitlab.example.com/gitlab-org/gitlab-ci/merge_requests/7", diff --git a/doc/user/project/merge_requests.md b/doc/user/project/merge_requests.md index a58d6df19ef1a6051c77a42edcebc85c46e1637c..05363484412380a2d001931d285ee3794ae7944e 100644 --- a/doc/user/project/merge_requests.md +++ b/doc/user/project/merge_requests.md @@ -36,6 +36,13 @@ you hide discussions that are no longer relevant. [Read more about resolving discussion comments in merge requests reviews.](merge_requests/merge_request_discussion_resolution.md) +## Squash and merge + +GitLab allows you to squash all changes present in a merge request into a single +commit when merging, to allow for a neater commit history. + +[Learn more about squash and merge.](merge_requests/squash_and_merge) + ## Resolve conflicts When a merge request has conflicts, GitLab may provide the option to resolve diff --git a/doc/user/project/merge_requests/img/squash_edit_form.png b/doc/user/project/merge_requests/img/squash_edit_form.png new file mode 100644 index 0000000000000000000000000000000000000000..c7ba119c99861ddda73b78c35f34007dfb8df89c Binary files /dev/null and b/doc/user/project/merge_requests/img/squash_edit_form.png differ diff --git a/doc/user/project/merge_requests/img/squash_mr_commits.png b/doc/user/project/merge_requests/img/squash_mr_commits.png new file mode 100644 index 0000000000000000000000000000000000000000..b3c877b8a3474f8a73c184657415d90f95e9d4b0 Binary files /dev/null and b/doc/user/project/merge_requests/img/squash_mr_commits.png differ diff --git a/doc/user/project/merge_requests/img/squash_mr_widget.png b/doc/user/project/merge_requests/img/squash_mr_widget.png new file mode 100644 index 0000000000000000000000000000000000000000..2b65aa55217241ad20c43f7e7a5ce422478e1373 Binary files /dev/null and b/doc/user/project/merge_requests/img/squash_mr_widget.png differ diff --git a/doc/user/project/merge_requests/img/squash_squashed_commit.png b/doc/user/project/merge_requests/img/squash_squashed_commit.png new file mode 100644 index 0000000000000000000000000000000000000000..911a28d8d87711a66e0e4cb56f6b871997a9d4d4 Binary files /dev/null and b/doc/user/project/merge_requests/img/squash_squashed_commit.png differ diff --git a/doc/user/project/merge_requests/squash_and_merge.md b/doc/user/project/merge_requests/squash_and_merge.md new file mode 100644 index 0000000000000000000000000000000000000000..a8565f3c0dbb9267d14af2a65d30d3dae36b6bf5 --- /dev/null +++ b/doc/user/project/merge_requests/squash_and_merge.md @@ -0,0 +1,56 @@ +# Squash and merge + +> [Introduced][ee-1024] in GitLab Enterprise Edition 8.16. + +Squashing lets you tidy up the commit history of a branch when accepting a merge +request. It applies all of the changes in the merge request as a single commit, +and then merges that commit using the merge method set for the project. + +In other words, squashing a merge request turns a long list of commits: + +![List of commits from a merge request][mr-commits] + +Into a single commit on merge: + +![A squashed commit followed by a merge commit][squashed-commit] + +Note that the squashed commit is still followed by a merge commit, as the merge +method for this example repository uses a merge commit. Squashing also works +with the fast-forward merge strategy: see +[squashing and fast-forward merge](#squashing-and-fast-forward-merge) for more +details. + +## Enabling squash for a merge request + +Anyone who can create or edit a merge request can choose for it to be squashed +on the merge request form: + +![Squash commits checkbox on edit form][squash-edit-form] + +This can then be overridden at the time of accepting the merge request: + +![Squash commits checkbox on accept merge request form][squash-mr-widget] + +## Commit metadata for squashed commits + +The squashed commit has the following metadata: + +* Message: taken from the last commit in the source branch. +* Author: taken from the last commit in the source branch. +* Committer: the user who initiated the squash. + +## Squashing and [fast-forward merge][ff-merge] + +When a project has the fast-forward merge setting enabled, the merge request +must be able to be fast-forwarded without squashing in order to squash it. This +is because squashing is only available when accepting a merge request, so a +merge request may need to be [rebased][rebase] before squashing, even though +squashing can itself be considered equivalent to rebasing. + +[ee-1024]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/1024 +[mr-commits]: img/squash_mr_commits.png +[squashed-commit]: img/squash_squashed_commit.png +[squash-edit-form]: img/squash_edit_form.png +[squash-mr-widget]: img/squash_mr_widget.png +[ff-merge]: ./fast_forward_merge +[rebase]: ../../../workflow/rebase_before_merge diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 1d5a07284abdf0b080a184e57a47031d02b144b5..e44d0ad7b3e15e0e9a8fe13059def3c3022aa368 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -326,6 +326,7 @@ class MergeRequest < ProjectEntity expose :approvals_before_merge expose :should_remove_source_branch?, as: :should_remove_source_branch expose :force_remove_source_branch?, as: :force_remove_source_branch + expose :squash expose :web_url do |merge_request, options| Gitlab::UrlBuilder.build(merge_request) diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index b79e1b54af859c482cdc0b51ea4904c7ef54f800..906f2d01e386fd52954eb3423c524d853d182cfc 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -34,6 +34,7 @@ def handle_merge_request_errors!(errors) optional :labels, type: String, desc: 'Comma-separated list of label names' optional :approvals_before_merge, type: Integer, desc: 'Number of approvals required before this can be merged' optional :remove_source_branch, type: Boolean, desc: 'Remove source branch when merging' + optional :squash, type: Boolean, desc: 'Squash commits when merging' end end @@ -153,7 +154,7 @@ def handle_merge_request_errors!(errors) use :optional_params at_least_one_of :title, :target_branch, :description, :assignee_id, :milestone_id, :labels, :state_event, :approvals_before_merge, - :remove_source_branch + :remove_source_branch, :squash end put path do merge_request = find_merge_request_with_access(params.delete(:merge_request_id), :update_merge_request) @@ -180,6 +181,7 @@ def handle_merge_request_errors!(errors) optional :merge_when_build_succeeds, type: Boolean, desc: 'When true, this merge request will be merged when the pipeline succeeds' optional :sha, type: String, desc: 'When present, must have the HEAD SHA of the source branch' + optional :squash, type: Boolean, desc: 'When true, the commits will be squashed into a single commit on merge' end put "#{path}/merge" do merge_request = find_project_merge_request(params[:merge_request_id]) @@ -196,6 +198,10 @@ def handle_merge_request_errors!(errors) render_api_error!("SHA does not match HEAD of source branch: #{merge_request.diff_head_sha}", 409) end + if params[:squash] + merge_request.update(squash: params[:squash]) + end + merge_params = { commit_message: params[:merge_commit_message], should_remove_source_branch: params[:should_remove_source_branch] diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 7fb7aebe3b9d36c5846bf088bfa13f6d146591b8..52d3875618537c099291b0232b65e5ebef6656b5 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -492,6 +492,7 @@ def update_merge_request(params = {}) namespace_id: project.namespace.path, project_id: project.path, id: merge_request.iid, + squash: false, format: 'raw' } end @@ -529,8 +530,26 @@ def update_merge_request(params = {}) end context 'when the sha parameter matches the source SHA' do - def merge_with_sha - post :merge, base_params.merge(sha: merge_request.diff_head_sha) + def merge_with_sha(params = {}) + post :merge, base_params.merge(sha: merge_request.diff_head_sha).merge(params) + end + + context 'when squash is passed as 1' do + it 'updates the squash attribute on the MR to true' do + merge_request.update(squash: false) + merge_with_sha(squash: '1') + + expect(merge_request.reload.squash).to be_truthy + end + end + + context 'when squash is passed as 1' do + it 'updates the squash attribute on the MR to false' do + merge_request.update(squash: true) + merge_with_sha(squash: '0') + + expect(merge_request.reload.squash).to be_falsey + end end it 'returns :success' do diff --git a/spec/features/merge_requests/squash_spec.rb b/spec/features/merge_requests/squash_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3ff10463987830787de63959508648d549772d11 --- /dev/null +++ b/spec/features/merge_requests/squash_spec.rb @@ -0,0 +1,107 @@ +require 'spec_helper' + +feature 'Squashing merge requests', js: true, feature: true do + include WaitForAjax + + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:source_branch) { 'csv' } + + let!(:original_head) { project.repository.commit('master') } + + shared_examples 'squash' do + it 'squashes the commits into a single commit, and adds a merge commit' do + latest_master_commits = project.repository.commits_between(original_head.sha, 'master').map(&:raw) + last_mr_commit = project.repository.commit(source_branch) + + squash_commit = an_object_having_attributes(sha: a_string_matching(/\h{40}/), + message: "#{last_mr_commit.message}\n", + author_name: last_mr_commit.author_name, + committer_name: user.name) + + merge_commit = an_object_having_attributes(sha: a_string_matching(/\h{40}/), + message: a_string_starting_with("Merge branch 'csv' into 'master'"), + author_name: user.name, + committer_name: user.name) + + expect(project.repository).not_to be_merged_to_root_ref(source_branch) + expect(latest_master_commits).to match([squash_commit, merge_commit]) + end + end + + shared_examples 'no squash' do + it 'accepts the merge request without squashing' do + expect(project.repository).to be_merged_to_root_ref(source_branch) + end + end + + def accept_mr + click_on 'Accept Merge Request' + wait_for_ajax + end + + before do + project.team << [user, :master] + + login_as user + end + + context 'when squash is enabled on merge request creation' do + before do + visit new_namespace_project_merge_request_path(project.namespace, project, merge_request: { source_branch: source_branch }) + check 'merge_request[squash]' + click_on 'Submit merge request' + wait_for_ajax + end + + it 'shows the squash checkbox as checked' do + expect(page).to have_checked_field('squash') + end + + context 'when accepting with squash checked' do + before do + accept_mr + end + + include_examples 'squash' + end + + context 'when accepting and unchecking squash' do + before do + uncheck 'squash' + accept_mr + end + + include_examples 'no squash' + end + end + + context 'when squash is not enabled on merge request creation' do + before do + visit new_namespace_project_merge_request_path(project.namespace, project, merge_request: { source_branch: source_branch }) + click_on 'Submit merge request' + wait_for_ajax + end + + it 'shows the squash checkbox as unchecked' do + expect(page).to have_unchecked_field('squash') + end + + context 'when accepting and checking squash' do + before do + check 'squash' + accept_mr + end + + include_examples 'squash' + end + + context 'when accepting with squash unchecked' do + before do + accept_mr + end + + include_examples 'no squash' + end + end +end diff --git a/spec/lib/gitlab/diff/position_tracer_spec.rb b/spec/lib/gitlab/diff/position_tracer_spec.rb index f5822fed37cbe3161619efac0d4d42888137395b..21781535793baba6f7368007bf614cb5ee582aa4 100644 --- a/spec/lib/gitlab/diff/position_tracer_spec.rb +++ b/spec/lib/gitlab/diff/position_tracer_spec.rb @@ -1640,7 +1640,9 @@ def expect_positions(old_attrs, new_attrs) } merge_request = create(:merge_request, source_branch: second_create_file_commit.sha, target_branch: branch_name, source_project: project) - repository.merge(current_user, merge_request, options) + + repository.merge(current_user, merge_request.diff_head_sha, merge_request, options) + project.commit(branch_name) end diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index b8949b19fa6f8196998139b296a211d058a6ef67..339e6e67ff1f88c3e28ec5b1db18ecdcb6e0a96f 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -154,6 +154,7 @@ MergeRequest: - approvals_before_merge - rebase_commit_sha - time_estimate +- squash MergeRequestDiff: - id - state diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 1e2ee44e34f440af5ebb22db21ca5be2d5c362ea..a29329826b7d023faa196e90a71beac346e1a46a 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -788,36 +788,67 @@ end describe '#rebase_in_progress?' do - it 'returns true' do + it 'returns true when there is a current rebase directory' do allow(File).to receive(:exist?).and_return(true) - allow(File).to receive(:new).and_return(double(:file, mtime: Time.now)) + allow(File).to receive(:mtime).and_return(Time.now) expect(subject.rebase_in_progress?).to be_truthy end - it 'returns false' do + it 'returns false when there is no rebase directory' do allow(File).to receive(:exist?).with(subject.rebase_dir_path).and_return(false) expect(subject.rebase_in_progress?).to be_falsey end - it 'returns false if temporary file exists by is expired' do + it 'returns false when the rebase directory has expired' do allow(File).to receive(:exist?).and_return(true) - allow(File).to receive(:new).and_return(double(:file, mtime: Time.now - 2.hours)) + allow(File).to receive(:mtime).and_return(20.minutes.ago) expect(subject.rebase_in_progress?).to be_falsey end - it 'returns false if source_project is removed' do + it 'returns false when the source project has been removed' do allow(subject).to receive(:source_project).and_return(nil) allow(File).to receive(:exist?).and_return(true) - allow(File).to receive(:new).and_return(double(:file, mtime: Time.now)) + allow(File).to receive(:mtime).and_return(Time.now) expect(File).not_to have_received(:exist?) expect(subject.rebase_in_progress?).to be_falsey end end + describe '#squash_in_progress?' do + it 'returns true when there is a current squash directory' do + allow(File).to receive(:exist?).and_return(true) + allow(File).to receive(:mtime).and_return(Time.now) + + expect(subject.squash_in_progress?).to be_truthy + end + + it 'returns false when there is no squash directory' do + allow(File).to receive(:exist?).with(subject.squash_dir_path).and_return(false) + + expect(subject.squash_in_progress?).to be_falsey + end + + it 'returns false when the squash directory has expired' do + allow(File).to receive(:exist?).and_return(true) + allow(File).to receive(:mtime).and_return(20.minutes.ago) + + expect(subject.squash_in_progress?).to be_falsey + end + + it 'returns false when the source project has been removed' do + allow(subject).to receive(:source_project).and_return(nil) + allow(File).to receive(:exist?).and_return(true) + allow(File).to receive(:mtime).and_return(Time.now) + + expect(File).not_to have_received(:exist?) + expect(subject.squash_in_progress?).to be_falsey + end + end + describe '#commits_sha' do before do allow(subject.merge_request_diff).to receive(:commits_sha). diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index ebfc3b2d0c77bc94412f0d6ecd8117a051c10aab..916f5b4627485fb748239ecd717d348e2189b0c8 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -15,7 +15,12 @@ let(:merge_commit) do merge_request = create(:merge_request, source_branch: 'feature', target_branch: 'master', source_project: project) - merge_commit_id = repository.merge(user, merge_request, commit_options) + + merge_commit_id = repository.merge(user, + merge_request.diff_head_sha, + merge_request, + commit_options) + repository.commit(merge_commit_id) end @@ -1009,8 +1014,11 @@ it 'sets the `in_progress_merge_commit_sha` flag for the given merge request' do merge_request = create(:merge_request, source_branch: 'feature', target_branch: 'master', source_project: project) - merge_commit_id = repository.merge(user, merge_request, commit_options) - repository.commit(merge_commit_id) + + merge_commit_id = repository.merge(user, + merge_request.diff_head_sha, + merge_request, + commit_options) expect(merge_request.in_progress_merge_commit_sha).to eq(merge_commit_id) end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 801f1a8c427d27be08b30a3f76f9d9b48484f67e..48d1ed19e175c9dffbf23f9d41e172d26cecee8f 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -39,6 +39,7 @@ expect(json_response.first['sha']).to eq(merge_request_merged.diff_head_sha) expect(json_response.first['merge_commit_sha']).not_to be_nil expect(json_response.first['merge_commit_sha']).to eq(merge_request_merged.merge_commit_sha) + expect(json_response.first['squash']).to eq(merge_request_merged.squash) end it "returns an array of all merge_requests" do @@ -233,13 +234,15 @@ author: user, labels: 'label, label2', milestone_id: milestone.id, - remove_source_branch: true + remove_source_branch: true, + squash: true expect(response).to have_http_status(201) expect(json_response['title']).to eq('Test merge_request') expect(json_response['labels']).to eq(['label', 'label2']) expect(json_response['milestone']['id']).to eq(milestone.id) expect(json_response['force_remove_source_branch']).to be_truthy + expect(json_response['squash']).to be_truthy end it "returns 422 when source_branch equals target_branch" do @@ -526,6 +529,14 @@ def create_merge_request(approvals_before_merge) expect(response).to have_http_status(200) end + it "updates the MR's squash attribute" do + expect do + put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user), squash: true + end.to change { merge_request.reload.squash } + + expect(response).to have_http_status(200) + end + it "enables merge when pipeline succeeds if the pipeline is active" do allow_any_instance_of(MergeRequest).to receive(:head_pipeline).and_return(pipeline) allow(pipeline).to receive(:active?).and_return(true) @@ -566,6 +577,13 @@ def create_merge_request(approvals_before_merge) expect(json_response['milestone']['id']).to eq(milestone.id) end + it "updates squash and returns merge_request" do + put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), squash: true + + expect(response).to have_http_status(200) + expect(json_response['squash']).to be_truthy + end + it "returns merge_request with renamed target_branch" do put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), target_branch: "wiki" expect(response).to have_http_status(200) diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb index e04ec080be93e8f4418ce2ed97c4abd3d30eecc2..83011353d1cc5ab9b4276861b0219ff811b097c4 100644 --- a/spec/services/merge_requests/refresh_service_spec.rb +++ b/spec/services/merge_requests/refresh_service_spec.rb @@ -101,7 +101,8 @@ # Merge master -> feature branch author = { email: 'test@gitlab.com', time: Time.now, name: "Me" } commit_options = { message: 'Test message', committer: author, author: author } - @project.repository.merge(@user, @merge_request, commit_options) + @project.repository.merge(@user, @merge_request.diff_head_sha, @merge_request, commit_options) + commit = @project.repository.commit('feature') service.new(@project, @user).execute(@oldrev, commit.id, 'refs/heads/feature') reload_mrs diff --git a/spec/services/merge_requests/squash_service_spec.rb b/spec/services/merge_requests/squash_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e99ffaefee5fd7a07d413d6614ed01e2dd11626f --- /dev/null +++ b/spec/services/merge_requests/squash_service_spec.rb @@ -0,0 +1,150 @@ +require 'spec_helper' + +describe MergeRequests::SquashService do + let(:service) { described_class.new(project, user, {}) } + let(:user) { project.owner } + let(:project) { create(:project) } + + let(:merge_request) do + create(:merge_request, + source_branch: 'fix', source_project: project, + target_branch: 'master', target_project: project) + end + + let(:merge_request_with_one_commit) do + create(:merge_request, + source_branch: 'feature.custom-highlighting', source_project: project, + target_branch: 'master', target_project: project) + end + + def git_command(command) + a_collection_starting_with([Gitlab.config.git.bin_path, command]) + end + + shared_examples 'the squashed commit' do + end + + describe '#execute' do + context 'when there is only one commit in the merge request' do + it 'returns that commit SHA' do + result = service.execute(merge_request_with_one_commit) + + expect(result).to match(status: :success, squash_sha: merge_request_with_one_commit.diff_head_sha) + end + + it 'does not perform any git actions' do + expect(service).not_to receive(:run_git_command) + + service.execute(merge_request_with_one_commit) + end + end + + context 'when the squash succeeds' do + it 'returns the squashed commit SHA' do + result = service.execute(merge_request) + + expect(result).to match(status: :success, squash_sha: a_string_matching(/\h{40}/)) + expect(result[:squash_sha]).not_to eq(merge_request.diff_head_sha) + end + + it 'cleans up the temporary directory' do + expect(service).to receive(:clean_dir).and_call_original + + service.execute(merge_request) + end + + it 'does not keep the branch push event' do + expect { service.execute(merge_request) }.not_to change { Event.count } + end + + context 'the squashed commit' do + let(:squash_sha) { service.execute(merge_request)[:squash_sha] } + let(:squash_commit) { project.repository.commit(squash_sha) } + + it 'copies the author info and message from the last commit in the source branch' do + diff_head_commit = merge_request.diff_head_commit + + expect(squash_commit.author_name).to eq(diff_head_commit.author_name) + expect(squash_commit.author_email).to eq(diff_head_commit.author_email) + expect(squash_commit.message).to eq(diff_head_commit.message) + end + + it 'sets the current user as the committer' do + expect(squash_commit.committer_name).to eq(user.name.chomp('.')) + expect(squash_commit.committer_email).to eq(user.email) + end + + it 'has the same diff as the merge request, but a different SHA' do + rugged = project.repository.rugged + mr_diff = rugged.diff(merge_request.diff_base_sha, merge_request.diff_head_sha) + squash_diff = rugged.diff(merge_request.diff_start_sha, squash_sha) + + expect(squash_diff.patch).to eq(mr_diff.patch) + expect(squash_commit.sha).not_to eq(merge_request.diff_head_sha) + end + end + end + + stages = { + 'add worktree for squash' => 'worktree', + 'apply patch' => 'apply', + 'commit squashed changes' => 'commit', + 'get SHA of squashed commit' => 'rev-parse' + } + + stages.each do |stage, command| + context "when the #{stage} stage fails" do + let(:error) { 'A test error' } + + before do + allow(service).to receive(:popen).and_return(['', 0]) + allow(service).to receive(:popen).with(git_command(command), anything, anything).and_return([error, 1]) + end + + it 'logs the stage and output' do + expect(service).to receive(:log_error).with(a_string_including(stage)) + expect(service).to receive(:log_error).with(error) + + service.execute(merge_request) + end + + it 'returns an error' do + expect(service.execute(merge_request)).to match(status: :error, + message: a_string_including('squash')) + end + + it 'cleans up the temporary directory' do + expect(service).to receive(:clean_dir).and_call_original + + service.execute(merge_request) + end + end + end + + context 'when any other exception is thrown' do + let(:error) { 'A test error' } + + before do + allow(merge_request).to receive(:commits_count).and_raise(error) + end + + it 'logs the MR reference and exception' do + expect(service).to receive(:log_error).with(a_string_including("#{project.path_with_namespace}#{merge_request.to_reference}")) + expect(service).to receive(:log_error).with(error) + + service.execute(merge_request) + end + + it 'returns an error' do + expect(service.execute(merge_request)).to match(status: :error, + message: a_string_including('squash')) + end + + it 'cleans up the temporary directory' do + expect(service).to receive(:clean_dir).and_call_original + + service.execute(merge_request) + end + end + end +end diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index 69433b9776f9698142e455aefbb509977fc3a2dc..a44a1edd4ab10b7a4c7915da9919b8cbcecaf2dd 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -37,7 +37,8 @@ module TestEnv 'conflict-non-utf8' => 'd0a293c', 'conflict-too-large' => '39fa04f', 'deleted-image-test' => '6c17798', - 'wip' => 'b9238ee' + 'wip' => 'b9238ee', + 'csv' => '3dd0896' } # gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily