1#!/usr/bin/env python2
2#
3# Copyright 2010 Google Inc. All Rights Reserved.
4"""Module for transferring files between various types of repositories."""
5
6from __future__ import print_function
7
8__author__ = 'asharif@google.com (Ahmad Sharif)'
9
10import argparse
11import datetime
12import json
13import os
14import re
15import socket
16import sys
17import tempfile
18
19from automation.clients.helper import perforce
20from cros_utils import command_executer
21from cros_utils import logger
22from cros_utils import misc
23
24# pylint: disable=anomalous-backslash-in-string
25
26
27def GetCanonicalMappings(mappings):
28  canonical_mappings = []
29  for mapping in mappings:
30    remote_path, local_path = mapping.split()
31    if local_path.endswith('/') and not remote_path.endswith('/'):
32      local_path = os.path.join(local_path, os.path.basename(remote_path))
33    remote_path = remote_path.lstrip('/').split('/', 1)[1]
34    canonical_mappings.append(perforce.PathMapping(remote_path, local_path))
35  return canonical_mappings
36
37
38def SplitMapping(mapping):
39  parts = mapping.split()
40  assert len(parts) <= 2, 'Mapping %s invalid' % mapping
41  remote_path = parts[0]
42  if len(parts) == 2:
43    local_path = parts[1]
44  else:
45    local_path = '.'
46  return remote_path, local_path
47
48
49class Repo(object):
50  """Basic repository base class."""
51
52  def __init__(self, no_create_tmp_dir=False):
53    self.repo_type = None
54    self.address = None
55    self.mappings = None
56    self.revision = None
57    self.ignores = ['.gitignore', '.p4config', 'README.google']
58    if no_create_tmp_dir:
59      self._root_dir = None
60    else:
61      self._root_dir = tempfile.mkdtemp()
62    self._ce = command_executer.GetCommandExecuter()
63    self._logger = logger.GetLogger()
64
65  def PullSources(self):
66    """Pull all sources into an internal dir."""
67    pass
68
69  def SetupForPush(self):
70    """Setup a repository for pushing later."""
71    pass
72
73  def PushSources(self, commit_message=None, dry_run=False, message_file=None):
74    """Push to the external repo with the commit message."""
75    pass
76
77  def _RsyncExcludingRepoDirs(self, source_dir, dest_dir):
78    for f in os.listdir(source_dir):
79      if f in ['.git', '.svn', '.p4config']:
80        continue
81      dest_file = os.path.join(dest_dir, f)
82      source_file = os.path.join(source_dir, f)
83      if os.path.exists(dest_file):
84        command = 'rm -rf %s' % dest_file
85        self._ce.RunCommand(command)
86      command = 'rsync -a %s %s' % (source_file, dest_dir)
87      self._ce.RunCommand(command)
88    return 0
89
90  def MapSources(self, dest_dir):
91    """Copy sources from the internal dir to root_dir."""
92    return self._RsyncExcludingRepoDirs(self._root_dir, dest_dir)
93
94  def GetRoot(self):
95    return self._root_dir
96
97  def SetRoot(self, directory):
98    self._root_dir = directory
99
100  def CleanupRoot(self):
101    command = 'rm -rf %s' % self._root_dir
102    return self._ce.RunCommand(command)
103
104  def __str__(self):
105    return '\n'.join(
106        str(s) for s in [self.repo_type, self.address, self.mappings])
107
108
109# Note - this type of repo is used only for "readonly", in other words, this
110# only serves as a incoming repo.
111class FileRepo(Repo):
112  """Class for file repositories."""
113
114  def __init__(self, address, ignores=None):
115    Repo.__init__(self, no_create_tmp_dir=True)
116    self.repo_type = 'file'
117    self.address = address
118    self.mappings = None
119    self.branch = None
120    self.revision = '{0} (as of "{1}")'.format(address, datetime.datetime.now())
121    self.gerrit = None
122    self._root_dir = self.address
123    if ignores:
124      self.ignores += ignores
125
126  def CleanupRoot(self):
127    """Override to prevent deletion."""
128    pass
129
130
131class P4Repo(Repo):
132  """Class for P4 repositories."""
133
134  def __init__(self, address, mappings, revision=None):
135    Repo.__init__(self)
136    self.repo_type = 'p4'
137    self.address = address
138    self.mappings = mappings
139    self.revision = revision
140
141  def PullSources(self):
142    client_name = socket.gethostname()
143    client_name += tempfile.mkstemp()[1].replace('/', '-')
144    mappings = self.mappings
145    p4view = perforce.View('depot2', GetCanonicalMappings(mappings))
146    p4client = perforce.CommandsFactory(
147        self._root_dir, p4view, name=client_name)
148    command = p4client.SetupAndDo(p4client.Sync(self.revision))
149    ret = self._ce.RunCommand(command)
150    assert ret == 0, 'Could not setup client.'
151    command = p4client.InCheckoutDir(p4client.SaveCurrentCLNumber())
152    ret, o, _ = self._ce.RunCommandWOutput(command)
153    assert ret == 0, 'Could not get version from client.'
154    self.revision = re.search('^\d+$', o.strip(), re.MULTILINE).group(0)
155    command = p4client.InCheckoutDir(p4client.Remove())
156    ret = self._ce.RunCommand(command)
157    assert ret == 0, 'Could not delete client.'
158    return 0
159
160
161class SvnRepo(Repo):
162  """Class for svn repositories."""
163
164  def __init__(self, address, mappings):
165    Repo.__init__(self)
166    self.repo_type = 'svn'
167    self.address = address
168    self.mappings = mappings
169
170  def PullSources(self):
171    with misc.WorkingDirectory(self._root_dir):
172      for mapping in self.mappings:
173        remote_path, local_path = SplitMapping(mapping)
174        command = 'svn co %s/%s %s' % (self.address, remote_path, local_path)
175      ret = self._ce.RunCommand(command)
176      if ret:
177        return ret
178
179      self.revision = ''
180      for mapping in self.mappings:
181        remote_path, local_path = SplitMapping(mapping)
182        command = 'cd %s && svnversion -c .' % (local_path)
183        ret, o, _ = self._ce.RunCommandWOutput(command)
184        self.revision += o.strip().split(':')[-1]
185        if ret:
186          return ret
187    return 0
188
189
190class GitRepo(Repo):
191  """Class for git repositories."""
192
193  def __init__(self, address, branch, mappings=None, ignores=None, gerrit=None):
194    Repo.__init__(self)
195    self.repo_type = 'git'
196    self.address = address
197    self.branch = branch or 'master'
198    if ignores:
199      self.ignores += ignores
200    self.mappings = mappings
201    self.gerrit = gerrit
202
203  def _CloneSources(self):
204    with misc.WorkingDirectory(self._root_dir):
205      command = 'git clone %s .' % (self.address)
206      return self._ce.RunCommand(command)
207
208  def PullSources(self):
209    with misc.WorkingDirectory(self._root_dir):
210      ret = self._CloneSources()
211      if ret:
212        return ret
213
214      command = 'git checkout %s' % self.branch
215      ret = self._ce.RunCommand(command)
216      if ret:
217        return ret
218
219      command = 'git describe --always'
220      ret, o, _ = self._ce.RunCommandWOutput(command)
221      self.revision = o.strip()
222      return ret
223
224  def SetupForPush(self):
225    with misc.WorkingDirectory(self._root_dir):
226      ret = self._CloneSources()
227      logger.GetLogger().LogFatalIf(
228          ret, 'Could not clone git repo %s.' % self.address)
229
230      command = 'git branch -a | grep -wq %s' % self.branch
231      ret = self._ce.RunCommand(command)
232
233      if ret == 0:
234        if self.branch != 'master':
235          command = ('git branch --track %s remotes/origin/%s' % (self.branch,
236                                                                  self.branch))
237        else:
238          command = 'pwd'
239        command += '&& git checkout %s' % self.branch
240      else:
241        command = 'git symbolic-ref HEAD refs/heads/%s' % self.branch
242      command += '&& rm -rf *'
243      ret = self._ce.RunCommand(command)
244      return ret
245
246  def CommitLocally(self, commit_message=None, message_file=None):
247    with misc.WorkingDirectory(self._root_dir):
248      command = 'pwd'
249      for ignore in self.ignores:
250        command += '&& echo \'%s\' >> .git/info/exclude' % ignore
251      command += '&& git add -Av .'
252      if message_file:
253        message_arg = '-F %s' % message_file
254      elif commit_message:
255        message_arg = '-m \'%s\'' % commit_message
256      else:
257        raise RuntimeError('No commit message given!')
258      command += '&& git commit -v %s' % message_arg
259      return self._ce.RunCommand(command)
260
261  def PushSources(self, commit_message=None, dry_run=False, message_file=None):
262    ret = self.CommitLocally(commit_message, message_file)
263    if ret:
264      return ret
265    push_args = ''
266    if dry_run:
267      push_args += ' -n '
268    with misc.WorkingDirectory(self._root_dir):
269      if self.gerrit:
270        label = 'somelabel'
271        command = 'git remote add %s %s' % (label, self.address)
272        command += ('&& git push %s %s HEAD:refs/for/master' % (push_args,
273                                                                label))
274      else:
275        command = 'git push -v %s origin %s:%s' % (push_args, self.branch,
276                                                   self.branch)
277      ret = self._ce.RunCommand(command)
278    return ret
279
280  def MapSources(self, root_dir):
281    if not self.mappings:
282      self._RsyncExcludingRepoDirs(self._root_dir, root_dir)
283      return
284    with misc.WorkingDirectory(self._root_dir):
285      for mapping in self.mappings:
286        remote_path, local_path = SplitMapping(mapping)
287        remote_path.rstrip('...')
288        local_path.rstrip('...')
289        full_local_path = os.path.join(root_dir, local_path)
290        ret = self._RsyncExcludingRepoDirs(remote_path, full_local_path)
291        if ret:
292          return ret
293    return 0
294
295
296class RepoReader(object):
297  """Class for reading repositories."""
298
299  def __init__(self, filename):
300    self.filename = filename
301    self.main_dict = {}
302    self.input_repos = []
303    self.output_repos = []
304
305  def ParseFile(self):
306    with open(self.filename) as f:
307      self.main_dict = json.load(f)
308      self.CreateReposFromDict(self.main_dict)
309    return [self.input_repos, self.output_repos]
310
311  def CreateReposFromDict(self, main_dict):
312    for key, repo_list in main_dict.items():
313      for repo_dict in repo_list:
314        repo = self.CreateRepoFromDict(repo_dict)
315        if key == 'input':
316          self.input_repos.append(repo)
317        elif key == 'output':
318          self.output_repos.append(repo)
319        else:
320          logger.GetLogger().LogFatal('Unknown key: %s found' % key)
321
322  def CreateRepoFromDict(self, repo_dict):
323    repo_type = repo_dict.get('type', None)
324    repo_address = repo_dict.get('address', None)
325    repo_mappings = repo_dict.get('mappings', None)
326    repo_ignores = repo_dict.get('ignores', None)
327    repo_branch = repo_dict.get('branch', None)
328    gerrit = repo_dict.get('gerrit', None)
329    revision = repo_dict.get('revision', None)
330
331    if repo_type == 'p4':
332      repo = P4Repo(repo_address, repo_mappings, revision=revision)
333    elif repo_type == 'svn':
334      repo = SvnRepo(repo_address, repo_mappings)
335    elif repo_type == 'git':
336      repo = GitRepo(
337          repo_address,
338          repo_branch,
339          mappings=repo_mappings,
340          ignores=repo_ignores,
341          gerrit=gerrit)
342    elif repo_type == 'file':
343      repo = FileRepo(repo_address)
344    else:
345      logger.GetLogger().LogFatal('Unknown repo type: %s' % repo_type)
346    return repo
347
348
349@logger.HandleUncaughtExceptions
350def Main(argv):
351  parser = argparse.ArgumentParser()
352  parser.add_argument(
353      '-i',
354      '--input_file',
355      dest='input_file',
356      help='The input file that contains repo descriptions.')
357
358  parser.add_argument(
359      '-n',
360      '--dry_run',
361      dest='dry_run',
362      action='store_true',
363      default=False,
364      help='Do a dry run of the push.')
365
366  parser.add_argument(
367      '-F',
368      '--message_file',
369      dest='message_file',
370      default=None,
371      help=('Use contents of the log file as the commit '
372            'message.'))
373
374  options = parser.parse_args(argv)
375  if not options.input_file:
376    parser.print_help()
377    return 1
378  rr = RepoReader(options.input_file)
379  [input_repos, output_repos] = rr.ParseFile()
380
381  # Make sure FileRepo is not used as output destination.
382  for output_repo in output_repos:
383    if output_repo.repo_type == 'file':
384      logger.GetLogger().LogFatal(
385          'FileRepo is only supported as an input repo.')
386
387  for output_repo in output_repos:
388    ret = output_repo.SetupForPush()
389    if ret:
390      return ret
391
392  input_revisions = []
393  for input_repo in input_repos:
394    ret = input_repo.PullSources()
395    if ret:
396      return ret
397    input_revisions.append(input_repo.revision)
398
399  for input_repo in input_repos:
400    for output_repo in output_repos:
401      ret = input_repo.MapSources(output_repo.GetRoot())
402      if ret:
403        return ret
404
405  commit_message = 'Synced repos to: %s' % ','.join(input_revisions)
406  for output_repo in output_repos:
407    ret = output_repo.PushSources(
408        commit_message=commit_message,
409        dry_run=options.dry_run,
410        message_file=options.message_file)
411    if ret:
412      return ret
413
414  if not options.dry_run:
415    for output_repo in output_repos:
416      output_repo.CleanupRoot()
417    for input_repo in input_repos:
418      input_repo.CleanupRoot()
419
420  return ret
421
422
423if __name__ == '__main__':
424  retval = Main(sys.argv[1:])
425  sys.exit(retval)
426