1module Gitlab
2  module Git
3    class RemoteMirror
4      attr_reader :repository, :remote_name, :ssh_auth, :only_branches_matching
5
6      # An Array of local refnames that have diverged on the remote
7      #
8      # Only populated when `keep_divergent_refs` is enabled
9      attr_reader :divergent_refs
10
11      def initialize(repository, remote_name, ssh_auth:, only_branches_matching:, keep_divergent_refs:)
12        @repository = repository
13        @remote_name = remote_name
14        @ssh_auth = ssh_auth
15        @only_branches_matching = only_branches_matching
16        @keep_divergent_refs = keep_divergent_refs
17
18        @divergent_refs = []
19      end
20
21      def update
22        ssh_auth.setup do |env|
23          # Retrieve the remote branches first since they may take a while to load,
24          # and the local branches may have changed during this time.
25          remote_branch_list = remote_branches(env: env)
26          updated_branches = changed_refs(local_branches, remote_branch_list)
27          push_refs(default_branch_first(updated_branches.keys), env: env)
28          delete_refs(local_branches, remote_branches(env: env), env: env)
29
30          local_tags = refs_obj(repository.tags)
31          remote_tags = refs_obj(repository.remote_tags(remote_name, env: env))
32
33          updated_tags = changed_refs(local_tags, remote_tags)
34          push_refs(updated_tags.keys, env: env)
35          delete_refs(local_tags, remote_tags, env: env)
36        end
37      end
38
39      private
40
41      def ref_matchers
42        @ref_matchers ||= only_branches_matching.map do |ref|
43          GitLab::RefMatcher.new(ref)
44        end
45      end
46
47      def local_branches
48        @local_branches ||= refs_obj(
49          repository.local_branches,
50          match_refs: true
51        )
52      end
53
54      def remote_branches(env:)
55        @remote_branches ||= refs_obj(
56          repository.remote_branches(remote_name, env: env),
57          match_refs: true
58        )
59      end
60
61      def refs_obj(refs, match_refs: false)
62        refs.each_with_object({}) do |ref, refs|
63          next if match_refs && !include_ref?(ref.name)
64
65          key = ref.is_a?(Gitlab::Git::Tag) ? ref.refname : ref.name
66          refs[key] = ref
67        end
68      end
69
70      def changed_refs(local_refs, remote_refs)
71        local_refs.select do |ref_name, ref|
72          remote_ref = remote_refs[ref_name]
73
74          # Ref doesn't exist on the remote, it should be created
75          next true if remote_ref.nil?
76
77          local_target = ref.dereferenced_target
78          remote_target = remote_ref.dereferenced_target
79
80          if local_target == remote_target
81            # Ref is identical on the remote, no point mirroring
82            false
83          elsif @keep_divergent_refs
84            # Mirror the ref if its remote counterpart hasn't diverged
85            if repository.ancestor?(remote_target&.id, local_target&.id)
86              true
87            else
88              Gitlab::GitLogger.info("Divergent ref `#{ref_name}` in #{repository.path} due to ancestry -- remote: #{remote_target&.id}, local: #{local_target&.id}")
89              @divergent_refs << ref.refname
90              false
91            end
92          else
93            # Attempt to overwrite whatever's on the remote; push rules and
94            # protected branches may still prevent this
95            true
96          end
97        end
98      end
99
100      # Put the default branch first so it works fine when remote mirror is empty.
101      def default_branch_first(branches)
102        return unless branches.present?
103
104        default_branch, branches = branches.partition do |branch|
105          repository.root_ref == branch
106        end
107
108        branches.unshift(*default_branch)
109      end
110
111      def push_refs(refs, env:)
112        return unless refs.present?
113
114        repository.push_remote_branches(remote_name, refs, env: env)
115      end
116
117      def delete_refs(local_refs, remote_refs, env:)
118        return if @keep_divergent_refs
119
120        refs = refs_to_delete(local_refs, remote_refs)
121
122        return unless refs.present?
123
124        repository.delete_remote_branches(remote_name, refs.keys, env: env)
125      end
126
127      def refs_to_delete(local_refs, remote_refs)
128        default_branch_id = repository.commit.id
129
130        remote_refs.select do |remote_ref_name, remote_ref|
131          next false if local_refs[remote_ref_name] # skip if branch or tag exist in local repo
132
133          remote_ref_id = remote_ref.dereferenced_target.try(:id)
134
135          repository.ancestor?(remote_ref_id, default_branch_id)
136        end
137      end
138
139      def include_ref?(ref_name)
140        return true unless ref_matchers.present?
141
142        ref_matchers.any? { |matcher| matcher.matches?(ref_name) }
143      end
144    end
145  end
146end
147