#!/usr/bin/python

# Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.


"""
Functionality for mangling repository checkouts that are shared

In particular, this in combination w/ enter_chroot's mount binding, allows
us to access the same repo from inside and outside a chroot at the same time
"""

__all__ = ('RebuildRepoCheckout',)

import sys
import os
import shutil
import errno

_path = os.path.realpath(__file__)
_path = os.path.normpath(os.path.join(os.path.dirname(_path), '..', '..'))
sys.path.insert(0, _path)
del _path

from chromite.lib import cros_build_lib
from chromite.lib import git
from chromite.lib import osutils


_CACHE_NAME = '.cros_projects.list'


def _FilterNonExistentProjects(project_dir, projects):
  for project in projects:
    if os.path.exists(os.path.join(project_dir, project)):
      yield project


def _CleanAlternates(projects, alt_root):
  alt_root = os.path.normpath(alt_root)

  projects = set(projects)
  # Ignore our cache.
  projects.add(_CACHE_NAME)
  required_directories = set(os.path.dirname(x) for x in projects)

  for abs_root, dirs, files in os.walk(alt_root):
    rel_root = abs_root[len(alt_root):].strip('/')

    if rel_root not in required_directories:
      shutil.rmtree(abs_root)
      dirs[:] = []
      continue

    if rel_root:
      for filename in files:
        if os.path.join(rel_root, filename) not in projects:
          os.unlink(os.path.join(abs_root, filename))


def _UpdateAlternatesDir(alternates_root, reference_maps, projects):
  for project in projects:
    alt_path = os.path.join(alternates_root, project)
    paths = []
    for k, v in reference_maps.iteritems():
      k = os.path.join(k, '.repo', 'projects', project, 'objects')
      if os.path.exists(k):
        paths.append(os.path.join(v, '.repo', 'projects', project, 'objects'))

    osutils.SafeMakedirs(os.path.dirname(alt_path))

    osutils.WriteFile(alt_path, '%s\n' % ('\n'.join(paths),), atomic=True)


def _UpdateGitAlternates(proj_root, projects):
  for project in projects:

    alt_path = os.path.join(proj_root, project, 'objects', 'info',
                            'alternates')
    tmp_path = '%s.tmp' % alt_path

    # Clean out any tmp files that may have existed prior.
    osutils.SafeUnlink(tmp_path)

    # The pathway is written relative to the alternates files absolute path;
    # literally, .repo/projects/chromite.git/objects/info/alternates.
    relpath = '../' * (project.count('/') + 4)
    relpath = os.path.join(relpath, 'alternates', project)

    osutils.SafeMakedirs(os.path.dirname(tmp_path))
    os.symlink(relpath , tmp_path)
    os.rename(tmp_path, alt_path)


def _GetProjects(repo_root):
  # Note that we cannot rely upon projects.list, nor repo list, nor repo forall
  # here to be authoritive.
  # if we rely on the manifest contents, the local tree may not yet be
  # updated- thus if we drop the alternate for that project, that project is no
  # longer usable (which can tick off repo sync).
  # Thus, we just iterate over the raw underlying projects store, and generate
  # alternates for that; we regenerate based on either the manifest changing,
  # local_manifest having changed, or projects.list having changed (which
  # occurs during partial local repo syncs; aka repo sync chromite for
  # example).
  # Finally, note we have to truncate our mtime awareness to just integers;
  # this is required since utime isn't guaranteed to set floats, despite our
  # being able to get a float back from stat'ing.

  manifest_xml = os.path.join(repo_root, 'manifest.xml')
  times = [os.lstat(manifest_xml).st_mtime,
           os.stat(manifest_xml).st_mtime]
  for path in ('local_manifest.xml', 'project.list'):
    path = os.path.join(repo_root, path)
    if os.path.exists(path):
      times.append(os.stat(path).st_mtime)

  manifest_time = long(max(times))

  cache_path = os.path.join(repo_root, _CACHE_NAME)

  try:
    if long(os.stat(cache_path).st_mtime) == manifest_time:
      return osutils.ReadFile(cache_path).split()
  except EnvironmentError, e:
    if e.errno != errno.ENOENT:
      raise

  # The -a ! section of this find invocation is to block descent
  # into the actual git repository; for IO constrained systems,
  # this avoids a fairly large amount of inode/dentry load up.
  # TLDR; It's faster, don't remove it ;)
  data = cros_build_lib.RunCommandCaptureOutput(
      ['find', './', '-type', 'd', '-name', '*.git', '-a',
       '!', '-wholename', '*/*.git/*', '-prune'],
      cwd=os.path.join(repo_root, 'projects'))

  # Drop the leading ./ and the trailing .git
  data = [x[2:-4] for x in data.output.splitlines() if x]

  with open(cache_path, 'w') as f:
    f.write('\n'.join(sorted(data)))

  # Finally, mark the cache with the time of the manifest.xml we examined.
  os.utime(cache_path, (manifest_time, manifest_time))
  return data


class Failed(Exception):
  pass


def _RebuildRepoCheckout(target_root, reference_map,
                         alternates_dir):
  repo_root = os.path.join(target_root, '.repo')
  proj_root = os.path.join(repo_root, 'projects')

  manifest_path = os.path.join(repo_root, 'manifest.xml')
  if not os.path.exists(manifest_path):
    raise Failed('%r does not exist, thus cannot be a repo checkout' %
                 manifest_path)

  projects = ['%s.git' % x for x in _GetProjects(repo_root)]
  projects = _FilterNonExistentProjects(proj_root, projects)
  projects = list(sorted(projects))

  if not osutils.SafeMakedirs(alternates_dir, 0775):
    # We know the directory exists; thus cleanse out
    # dead alternates.
    _CleanAlternates(projects, alternates_dir)

  _UpdateAlternatesDir(alternates_dir, reference_map, projects)
  _UpdateGitAlternates(proj_root, projects)


def WalkReferences(repo_root, max_depth=5, suppress=()):
  """
  Given a repo checkout root, find the repo's it references up to max_depth

  Args:
    repo_root: The root of a repo checkout to start from
    max_depth: Git internally limits the max alternates depth to 5;
      this option exists to adjust how deep we're willing to look.

  Returns:
    List of repository roots required for this repo_root.
  """

  original_root = repo_root
  seen = set(os.path.abspath(x) for x in suppress)

  for _x in xrange(0, max_depth):
    repo_root = os.path.abspath(repo_root)

    if repo_root in seen:
      # Cyclic reference graph; break out of it, if someone induced this the
      # necessary objects should be in place.  If they aren't, really isn't
      # much that can be done.
      return

    yield repo_root
    seen.add(repo_root)
    base = os.path.join(repo_root, '.repo', 'manifests.git')
    result = git.RunGit(
        base, ['config', 'repo.reference'], error_code_ok=True)

    if result.returncode not in (0, 1):
      raise Failed('Unexpected returncode %i from examining %s git '
                   'repo.reference configuration' %
                   (result.returncode, base))

    repo_root = result.output.strip()
    if not repo_root:
      break

  else:
    raise Failed('While tracing out the references of %s, we recursed more '
                 'than the allowed %i times ending at %s'
                 % (original_root, max_depth, repo_root))


def RebuildRepoCheckout(repo_root, initial_reference,
                        chroot_reference_root=None):
  """
  Rebuild a repo checkouts ondisk 'alternate tree' rewriting the repo to use it

  Args:
    repo_root: absolute path to the root of a repository checkout
    reference_root: absolute path to the root of the repository that is
      shared
    chroot_reference_root: if given, repo_root will have it's chroot
      alternates tree configured with this pathway, enabling repo access to
      work from within the chroot.
  """

  reference_roots = list(WalkReferences(initial_reference,
                                        suppress=[repo_root]))

  # Always rebuild the external alternates for any operation; 1) we don't want
  # external out of sync from chroot, 2) if this is the first conversion, if
  # we only update chroot it'll break external access to the repo.
  reference_map = dict((x, x) for x in reference_roots)
  rebuilds = [('alternates', reference_map)]
  if chroot_reference_root:
    alternates_dir = 'chroot/alternates'
    base = os.path.join(chroot_reference_root, '.repo', 'chroot', 'external')
    reference_map = dict((x, '%s%i' % (base, idx + 1))
                         for idx, x in enumerate(reference_roots))
    rebuilds += [('chroot/alternates', reference_map)]

  for alternates_dir, reference_map in rebuilds:
    alternates_dir = os.path.join(repo_root, '.repo', alternates_dir)
    _RebuildRepoCheckout(repo_root,
                         reference_map,
                         alternates_dir)
  return reference_roots


if __name__ == '__main__':
  chroot_root = None
  if len(sys.argv) not in (3, 4):
    sys.stderr.write('Usage: %s <repository_root> <referenced_repository> '
                     '[path_from_within_the_chroot]\n' % (sys.argv[0],))
    sys.exit(1)
  if len(sys.argv) == 4:
    chroot_root = sys.argv[3]
  ret = RebuildRepoCheckout(sys.argv[1], sys.argv[2],
                            chroot_reference_root=chroot_root)
  print '\n'.join(ret)
