1module Gitlab
2  module Git
3    class OperationService
4      include Gitlab::Git::Popen
5
6      BranchUpdate = Struct.new(:newrev, :repo_created, :branch_created) do
7        alias_method :repo_created?, :repo_created
8        alias_method :branch_created?, :branch_created
9
10        def self.from_gitaly(branch_update)
11          return if branch_update.nil?
12
13          new(
14            branch_update.commit_id,
15            branch_update.repo_created,
16            branch_update.branch_created
17          )
18        end
19      end
20
21      attr_reader :user, :repository
22
23      def initialize(user, new_repository)
24        @user = user
25        @repository = new_repository
26      end
27
28      # Whenever `start_branch_name` or `start_sha` is passed, if `branch_name`
29      # doesn't exist, it will be created from the commit pointed to by
30      # `start_branch_name` or `start_sha`.
31      #
32      # If `start_repository` is passed, and the branch doesn't exist,
33      # it would try to find the commits from it instead of current repository.
34      def with_branch(branch_name,
35                      start_branch_name: nil,
36                      start_sha: nil,
37                      start_repository: repository,
38                      force: false,
39                      &block)
40        start_repository = RemoteRepository.new(start_repository) unless start_repository.is_a?(RemoteRepository)
41
42        start_branch_name = nil if start_repository.empty?
43
44        if start_branch_name.present? && !start_repository.branch_exists?(start_branch_name)
45          raise ArgumentError, "Cannot find branch '#{start_branch_name}'"
46        elsif start_sha.present? && !start_repository.commit_id(start_sha)
47          raise ArgumentError, "Cannot find commit '#{start_sha}'"
48        end
49
50        update_branch_with_hooks(branch_name, force) do
51          repository.with_repo_branch_commit(
52            start_repository,
53            start_sha.presence || start_branch_name.presence || branch_name,
54            &block
55          )
56        end
57      end
58
59      # Yields the given block (which should return a commit) and
60      # writes it to the ref while also executing hooks for it.
61      # The ref is _always_ overwritten (nothing is taken from its
62      # previous state).
63      #
64      # Returns the generated commit.
65      #
66      # ref - The target ref path we're committing to.
67      # from_ref - The ref we're taking the HEAD commit from.
68      def commit_ref(ref, source_sha, from_ref:)
69        update_autocrlf_option
70
71        target_sha = from_ref.target
72        repository.write_ref(ref, target_sha)
73
74        # Make commit
75        newrev = yield
76
77        unless newrev
78          error = "Failed to create merge commit for source_sha #{source_sha} and" \
79                  " target_sha #{target_sha} at #{ref}"
80
81          raise Gitlab::Git::CommitError.new(error)
82        end
83
84        oldrev = from_ref.target
85
86        update_ref(ref, newrev, oldrev)
87
88        newrev
89      end
90
91      private
92
93      # Returns [newrev, should_run_after_create, should_run_after_create_branch]
94      def update_branch_with_hooks(branch_name, force)
95        update_autocrlf_option
96
97        was_empty = repository.empty?
98
99        # Make commit
100        newrev = yield
101
102        raise Gitlab::Git::CommitError.new('Failed to create commit') unless newrev
103
104        branch = repository.find_branch(branch_name)
105        oldrev = find_oldrev_from_branch(newrev, branch, force)
106
107        ref = Gitlab::Git::BRANCH_REF_PREFIX + branch_name
108        update_ref_in_hooks(ref, newrev, oldrev)
109
110        BranchUpdate.new(newrev, was_empty, was_empty || Gitlab::Git.blank_ref?(oldrev))
111      end
112
113      def find_oldrev_from_branch(newrev, branch, force)
114        return Gitlab::Git::BLANK_SHA unless branch
115
116        oldrev = branch.target
117
118        return oldrev if force
119
120        merge_base = repository.merge_base(newrev, branch.target)
121        raise Gitlab::Git::Repository::InvalidRef unless merge_base
122
123        if oldrev == merge_base
124          oldrev
125        else
126          raise Gitlab::Git::CommitError.new('Branch diverged')
127        end
128      end
129
130      def update_ref_in_hooks(ref, newrev, oldrev, push_options: nil, transaction: nil)
131        with_hooks(ref, newrev, oldrev, push_options: push_options, transaction: transaction) do
132          update_ref(ref, newrev, oldrev)
133        end
134      end
135
136      def with_hooks(ref, newrev, oldrev, push_options: nil, transaction: nil)
137        Gitlab::Git::HooksService.new.execute(
138          user,
139          repository,
140          oldrev,
141          newrev,
142          ref,
143          push_options: push_options,
144          transaction: transaction
145        ) do |service|
146          yield(service)
147        end
148      end
149
150      def update_ref(ref, newrev, oldrev)
151        # We use 'git update-ref' because libgit2/rugged currently does not
152        # offer 'compare and swap' ref updates. Without compare-and-swap we can
153        # (and have!) accidentally reset the ref to an earlier state, clobbering
154        # commits. See also https://github.com/libgit2/libgit2/issues/1534.
155        command = %W[#{Gitlab.config.git.bin_path} update-ref --stdin -z]
156
157        output, status = popen(
158          command,
159          repository.path
160        ) do |stdin|
161          stdin.write("update #{ref}\x00#{newrev}\x00#{oldrev}\x00")
162        end
163
164        unless status.zero?
165          Gitlab::GitLogger.error("'git update-ref' in #{repository.path}: #{output}")
166          ref_name = Gitlab::Git.branch_name(ref) || ref
167
168          raise Gitlab::Git::CommitError.new(
169            "Could not update #{ref_name}." \
170            " Please refresh and try again."
171          )
172        end
173      end
174
175      def update_autocrlf_option
176        repository.autocrlf = :input if repository.autocrlf != :input
177      end
178    end
179  end
180end
181