#!/usr/bin/env python
# py:encoding=utf-8
#
#  backport_tests.py:  Test backport.pl or backport.py
#
#  Subversion is a tool for revision control.
#  See https://subversion.apache.org for more information.
#
# ====================================================================
#    Licensed to the Apache Software Foundation (ASF) under one
#    or more contributor license agreements.  See the NOTICE file
#    distributed with this work for additional information
#    regarding copyright ownership.  The ASF licenses this file
#    to you under the Apache License, Version 2.0 (the
#    "License"); you may not use this file except in compliance
#    with the License.  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing,
#    software distributed under the License is distributed on an
#    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
#    KIND, either express or implied.  See the License for the
#    specific language governing permissions and limitations
#    under the License.
######################################################################

# We'd like to test backport.pl and backport.py the same way, and to reuse
# the svntest Python harness.  Since the latter standardizes argv parsing,
# we can't use argv to determine whether .py or .pl should be tested.  Thus,
# we implement the tests themselves in this file, while two driver files
# (backport_tests_pl.py and backport_tests_py.py) invoke this file set
# to run either backport-suite implementation.
#
# ### Note: the two driver scripts use the same repository names in
# ### svn-test-work.  This is not ideal, but hopefully acceptable
# ### temporarily until we switch over to backport.py and remove backport.pl.
# ###
# ### See svntest.testcase.FunctionTestCase.get_sandbox_name().
try:
  run_backport, run_conflicter
except NameError:
  raise Exception("Failure: %s should not be run directly, or the wrapper "
                  "does not define both run_backport() and run_conflicter()"
                  % __file__)

# General modules
import contextlib
import functools
import os
import re
import sys

@contextlib.contextmanager
def chdir(dir):
  """This is a context manager that saves the current working directory's
  pathname.  Upon entry it chdir's to the argument DIR; upon exit it chdir's
  back to the saved pathname.

  The current working directory is restored using os.chdir(), not os.fchdir().
  """
  try:
    saved_dir = os.getcwd()
    os.chdir(dir)
    yield
  finally:
    os.chdir(saved_dir)

# Our testing module
# HACK: chdir to cause svntest.main.svn_binary to be set correctly
sys.path.insert(0, os.path.abspath('../../subversion/tests/cmdline'))
with chdir('../../subversion/tests/cmdline'):
  import svntest

# (abbreviations)
Skip = svntest.testcase.Skip_deco
SkipUnless = svntest.testcase.SkipUnless_deco
XFail = svntest.testcase.XFail_deco
Issues = svntest.testcase.Issues_deco
Issue = svntest.testcase.Issue_deco
Wimp = svntest.testcase.Wimp_deco

######################################################################
# Helper functions

STATUS = 'branch/STATUS'

class BackportTest(object):
  """Decorator.  See self.__call__()."""

  def __init__(self, uuid):
    """The argument is the UUID embedded in the dump file.
    If the argument is None, then there is no dump file."""
    self.uuid = uuid

  def __call__(self, test_func):
    """Return a decorator that: builds TEST_FUNC's sbox, creates
    ^/subversion/trunk, calls TEST_FUNC, and compares the resulting history
    to the expected dump file, which is named after TEST_FUNC."""

    # .wraps() propagates the wrappee's docstring to the wrapper.
    @functools.wraps(test_func)
    def wrapped_test_func(sbox):
      expected_dump_file = './backport_tests_data/%s.dump' % (test_func.__name__,)

      sbox.build()

      # r2: prepare ^/subversion/ tree
      sbox.simple_mkdir('subversion', 'subversion/trunk')
      sbox.simple_mkdir('subversion/tags', 'subversion/branches')
      sbox.simple_move('A', 'subversion/trunk')
      sbox.simple_move('iota', 'subversion/trunk')
      sbox.simple_commit(message='Create trunk')

      # r3: branch
      sbox.simple_copy('subversion/trunk', 'branch')
      sbox.simple_append('branch/STATUS', '')
      sbox.simple_add('branch/STATUS')
      sbox.simple_commit(message='Create branch, with STATUS file')

      # r4: random change on trunk
      sbox.simple_append('subversion/trunk/iota', 'First change\n')
      sbox.simple_commit(message='First change')

      # r5: random change on trunk
      sbox.simple_append('subversion/trunk/A/mu', 'Second change\n')
      sbox.simple_commit(message='Second change')

      # Do the work.
      test_func(sbox)

      # Verify it.
      verify_backport(sbox, expected_dump_file, self.uuid)
    return wrapped_test_func

def make_entry(revisions=None, logsummary=None, notes=None, branch=None,
               depends=None, votes=None):
  assert revisions
  if logsummary is None:
    logsummary = "default logsummary"
  if votes is None:
    votes = {+1 : ['jrandom']}

  entry = {
    'revisions': revisions,
    'logsummary': logsummary,
    'notes': notes,
    'branch': branch,
    'depends': depends,
    'votes': votes,
  }

  return entry

def serialize_entry(entry):
  return ''.join([

    # revisions,
    ' * %s\n'
    % (", ".join("r%ld" % revision for revision in entry['revisions'])),

    # logsummary
    '   %s\n' % (entry['logsummary'],),

    # notes
    '   Notes: %s\n' % (entry['notes'],) if entry['notes'] else '',

    # branch
    '   Branch: %s\n' % (entry['branch'],) if entry['branch'] else '',

    # depends
    '   Depends: %s\n' % (entry['depends'],) if entry['depends'] else '',

    # votes
    '   Votes:\n',
    ''.join('     '
        '%s: %s\n' % ({1: '+1', 0: '+0', -1: '-1', -0: '-0'}[vote],
                      ", ".join(entry['votes'][vote]))
        for vote in entry['votes']),

    '\n', # empty line after entry
  ])

def serialize_STATUS(approveds,
                     candidates=[],
                     serialize_entry=serialize_entry):
  """Construct and return the contents of a STATUS file.

  APPROVEDS is an iterable of ENTRY dicts.  The dicts are defined
  to have the following keys: 'revisions', a list of revision numbers (ints);
  'logsummary'; and 'votes', a dict mapping ±1/±0 (int) to list of voters.

  CANDIDATES is like APPROVEDS, except added to a different section of the file.
  """

  strings = []
  strings.append("Status of 1.8.x:\n\n")

  strings.append("Candidate changes:\n")
  strings.append("==================\n\n")

  strings.extend(map(serialize_entry, candidates))

  strings.append("Random new subheading:\n")
  strings.append("======================\n\n")

  strings.append("Veto-blocked changes:\n")
  strings.append("=====================\n\n")

  strings.append("Approved changes:\n")
  strings.append("=================\n\n")

  strings.extend(map(serialize_entry, approveds))

  return "".join(strings)

def verify_backport(sbox, expected_dump_file, uuid):
  """Compare the contents of the SBOX repository with EXPECTED_DUMP_FILE.
  Set the UUID of SBOX to UUID beforehand.
  Based on svnsync_tests.py:verify_mirror."""

  if uuid is None:
    # There is no expected dump file.
    return

  # Prepare for dump comparison
  # We will run this over file://, let's set a known author: by default it
  # would be the developer's OS username, since svn(1) as executed by this
  # script doesn't use the _with_auth() codepath.
  # We also need to ignore the date, since it will be based on the date the
  # test is executed.
  svntest.actions.enable_revprop_changes(sbox.repo_dir)
  for revnum in range(0, 1+int(sbox.youngest())):
    svntest.actions.run_and_verify_svnadmin([], [],
      "delrevprop", "-r", revnum, sbox.repo_dir, "svn:date")
    # Assume r1:HEAD have svn:author set, and r0 hasn't
    if revnum > 0:
      svntest.actions.run_and_verify_svn([], [],
        "propset", "--revprop", "-r", revnum, "-q", "svn:author", "jrandom",
        sbox.wc_dir)

  # Create a dump file from the repository.
  dest_dump = open(expected_dump_file, 'rb').readlines()
  svntest.actions.run_and_verify_svnadmin(None, [],
                                          'setuuid', '--', sbox.repo_dir, uuid)
  src_dump = svntest.actions.run_and_verify_dump(sbox.repo_dir)

  svntest.verify.compare_dump_files(
    "Dump files", "DUMP", dest_dump, src_dump)

######################################################################
# Tests
#
#   Each test must return on success or raise on failure.

#----------------------------------------------------------------------
@BackportTest('76cee987-25c9-4d6c-ad40-000000000001')
def backport_indented_entry(sbox):
  "parsing of entries with nonstandard indentation"

  # r6: nominate r4
  approved_entries = [
    make_entry([4]),
  ]
  def reindenting_serialize_entry(*args, **kwargs):
    entry = serialize_entry(*args, **kwargs)
    return ('\n' + entry).replace('\n ', '\n')[1:]
  sbox.simple_append(STATUS, serialize_STATUS(approved_entries,
                                              serialize_entry=reindenting_serialize_entry))
  sbox.simple_commit(message='Nominate r4')

  # Run it.
  run_backport(sbox)


#----------------------------------------------------------------------
@BackportTest('76cee987-25c9-4d6c-ad40-000000000002')
def backport_two_approveds(sbox):
  "backport with two approveds"

  # r6: Enter votes
  approved_entries = [
    make_entry([4]),
    make_entry([5]),
  ]
  sbox.simple_append(STATUS, serialize_STATUS(approved_entries))
  sbox.simple_commit(message='Nominate r4.  Nominate r5.')

  # r7, r8: Run it.
  run_backport(sbox)

  # Now back up and do three entries.
  # r9: revert r7, r8
  svntest.actions.run_and_verify_svnlook(["8\n"], [],
                                         'youngest', sbox.repo_dir)
  sbox.simple_update()
  svntest.main.run_svn(None, 'merge', '-r8:6',
                       '^/branch', sbox.ospath('branch'))
  sbox.simple_commit(message='Revert the merges.')

  # r10: Another change on trunk.
  # (Note that this change must be merged after r5.)
  sbox.simple_rm('subversion/trunk/A')
  sbox.simple_commit(message='Third change on trunk.')

  # r11: Nominate r10.
  sbox.simple_append(STATUS, serialize_entry(make_entry([10])))
  sbox.simple_commit(message='Nominate r10.')

  # r12, r13, r14: Run it.
  run_backport(sbox)



#----------------------------------------------------------------------
@BackportTest('76cee987-25c9-4d6c-ad40-000000000003')
def backport_accept(sbox):
  "test --accept parsing"

  # r6: conflicting change on branch
  sbox.simple_append('branch/iota', 'Conflicts with first change\n')
  sbox.simple_commit(message="Conflicting change on iota")

  # r7: nominate r4 with --accept (because of r6)
  approved_entries = [
    make_entry([4], notes="Merge with --accept=theirs-conflict."),
  ]
  def reindenting_serialize_entry(*args, **kwargs):
    entry = serialize_entry(*args, **kwargs)
    return ('\n' + entry).replace('\n ', '\n')[1:]
  sbox.simple_append(STATUS, serialize_STATUS(approved_entries,
                                              serialize_entry=reindenting_serialize_entry))
  sbox.simple_commit(message='Nominate r4')

  # Run it.
  run_backport(sbox)


#----------------------------------------------------------------------
@BackportTest('76cee987-25c9-4d6c-ad40-000000000004')
def backport_branches(sbox):
  "test branches"

  # r6: conflicting change on branch
  sbox.simple_append('branch/iota', 'Conflicts with first change')
  sbox.simple_commit(message="Conflicting change on iota")

  # r7: backport branch
  sbox.simple_update()
  sbox.simple_copy('branch', 'subversion/branches/r4')
  sbox.simple_commit(message='Create a backport branch')

  # r8: merge into backport branch
  sbox.simple_update()
  svntest.main.run_svn(None, 'merge', '--record-only', '-c4',
                       '^/subversion/trunk', sbox.ospath('subversion/branches/r4'))
  sbox.simple_mkdir('subversion/branches/r4/A_resolved')
  sbox.simple_append('subversion/branches/r4/iota', "resolved\n", truncate=1)
  sbox.simple_commit(message='Conflict resolution via mkdir')

  # r9: nominate r4 with branch
  approved_entries = [
    make_entry([4], branch="r4")
  ]
  sbox.simple_append(STATUS, serialize_STATUS(approved_entries))
  sbox.simple_commit(message='Nominate r4')

  # Run it.
  run_backport(sbox)

  # This also serves as the 'success mode' part of backport_branch_contains().


#----------------------------------------------------------------------
@BackportTest('76cee987-25c9-4d6c-ad40-000000000005')
def backport_multirevisions(sbox):
  "test multirevision entries"

  # r6: nominate r4,r5
  approved_entries = [
    make_entry([4,5])
  ]
  sbox.simple_append(STATUS, serialize_STATUS(approved_entries))
  sbox.simple_commit(message='Nominate a group.')

  # Run it.
  run_backport(sbox)


#----------------------------------------------------------------------
@BackportTest(None) # would be 000000000006
def backport_conflicts_detection(sbox):
  "test the conflicts detector"

  # r6: conflicting change on branch
  sbox.simple_append('branch/iota', 'Conflicts with first change\n')
  sbox.simple_commit(message="Conflicting change on iota")

  # r7: nominate r4, but without the requisite --accept
  candidate_entries = [
    make_entry([4], notes="This will conflict."),
  ]
  sbox.simple_append(STATUS, serialize_STATUS([], candidate_entries))
  sbox.simple_commit(message='Nominate r4')

  # Run it.
  exit_code, output, errput = run_conflicter(sbox, True)

  # Verify the conflict is detected.
  expected_output = svntest.verify.RegexOutput(
    'Index: iota',
    match_all=False,
  )
  expected_errput = (
    r'(?ms)' # re.MULTILINE | re.DOTALL
    r'.*Warning summary.*'
    r'^r4 [(]default logsummary[)]: Conflicts on iota.*'
  )
  expected_errput = svntest.verify.RegexListOutput(
                      [
                        r'Warning summary',
                        r'===============',
                        r'r4 [(]default logsummary[)]: Conflicts on iota',
                      ],
                      match_all=False)
  svntest.verify.verify_outputs(None, output, errput,
                                expected_output, expected_errput)
  svntest.verify.verify_exit_code(None, exit_code, 1)

  ## Now, let's test the "Depends:" annotation silences the error.

  # Re-nominate.
  approved_entries = [
    make_entry([4], depends="World peace."),
  ]
  sbox.simple_append(STATUS, serialize_STATUS(approved_entries), truncate=True)
  sbox.simple_commit(message='Re-nominate r4')

  # Detect conflicts.
  exit_code, output, errput = run_conflicter(sbox)

  # Verify stdout.  (exit_code and errput were verified by run_conflicter().)
  svntest.verify.verify_outputs(None, output, errput,
                                "Conflicts found.*, as expected.", [])


#----------------------------------------------------------------------
@BackportTest(None) # would be 000000000007
def backport_branch_contains(sbox):
  "branch must contain the revisions"

  # r6: conflicting change on branch
  sbox.simple_append('branch/iota', 'Conflicts with first change')
  sbox.simple_commit(message="Conflicting change on iota")

  # r7: backport branch
  sbox.simple_update()
  sbox.simple_copy('branch', 'subversion/branches/r4')
  sbox.simple_commit(message='Create a backport branch')

  # r8: merge into backport branch
  sbox.simple_update()
  svntest.main.run_svn(None, 'merge', '--record-only', '-c4',
                       '^/subversion/trunk', sbox.ospath('subversion/branches/r4'))
  sbox.simple_mkdir('subversion/branches/r4/A_resolved')
  sbox.simple_append('subversion/branches/r4/iota', "resolved\n", truncate=1)
  sbox.simple_commit(message='Conflict resolution via mkdir')

  # r9: nominate r4,r5 with branch that contains not all of them
  approved_entries = [
    make_entry([4,5], branch="r4")
  ]
  sbox.simple_append(STATUS, serialize_STATUS(approved_entries))
  sbox.simple_commit(message='Nominate r4')

  # Run it.
  exit_code, output, errput = run_backport(sbox, error_expected=True)

  # Verify the error message.
  expected_errput = svntest.verify.RegexOutput(
    ".*Revisions 'r5' nominated but not included in branch",
    match_all=False,
  )
  svntest.verify.verify_outputs(None, output, errput,
                                [], expected_errput)
  svntest.verify.verify_exit_code(None, exit_code, 1)

  # Verify no commit occurred.
  svntest.actions.run_and_verify_svnlook(["9\n"], [],
                                         'youngest', sbox.repo_dir)

  # Verify the working copy has been reverted.
  svntest.actions.run_and_verify_svn([], [], 'status', '-q',
                                     sbox.repo_dir)

  # The sibling test backport_branches() verifies the success mode.




#----------------------------------------------------------------------
@BackportTest(None) # would be 000000000008
def backport_double_conflict(sbox):
  "two-revisioned entry with two conflicts"

  # r6: conflicting change on branch
  sbox.simple_append('branch/iota', 'Conflicts with first change')
  sbox.simple_commit(message="Conflicting change on iota")

  # r7: further conflicting change to same file
  sbox.simple_update()
  sbox.simple_append('subversion/trunk/iota', 'Third line\n')
  sbox.simple_commit(message="iota's third line")

  # r8: nominate
  approved_entries = [
    make_entry([4,7], depends="World peace.")
  ]
  sbox.simple_append(STATUS, serialize_STATUS(approved_entries))
  sbox.simple_commit(message='Nominate the r4 group')

  # Run it, in conflicts mode.
  exit_code, output, errput = run_conflicter(sbox, True)

  # Verify the failure mode: "merge conflict" error on stderr, but backport.pl
  # itself exits with code 0, since conflicts were confined to Depends:-ed
  # entries.
  #
  # The error only happens with multi-pass merges where the first pass
  # conflicts and the second pass touches the conflict victim.
  #
  # The error would be:
  #    subversion/libsvn_client/merge.c:5499: (apr_err=SVN_ERR_WC_FOUND_CONFLICT)
  #    svn: E155015: One or more conflicts were produced while merging r3:4
  #    into '/tmp/stw/working_copies/backport_tests-8/branch' -- resolve all
  #    conflicts and rerun the merge to apply the remaining unmerged revisions
  #    ...
  #    Warning summary
  #    ===============
  #
  #    r4 (default logsummary): subshell exited with code 256
  # And backport.pl would exit with exit code 1.

  expected_output = 'Conflicts found.*, as expected.'
  expected_errput = svntest.verify.RegexOutput(
      ".*svn: E155015:.*", # SVN_ERR_WC_FOUND_CONFLICT
      match_all=False,
  )
  svntest.verify.verify_outputs(None, output, errput,
                                expected_output, expected_errput)
  svntest.verify.verify_exit_code(None, exit_code, 0)
  if any("Warning summary" in line for line in errput):
    raise svntest.verify.SVNUnexpectedStderr(errput)

  ## Now, let's ensure this does get detected if not silenced.
  # r9: Re-nominate
  approved_entries = [
    make_entry([4,7]) # no depends=
  ]
  sbox.simple_append(STATUS, serialize_STATUS(approved_entries), truncate=True)
  sbox.simple_commit(message='Re-nominate the r4 group')

  exit_code, output, errput = run_conflicter(sbox, True)

  ## An unexpected non-zero exit code is treated as a fatal error.
  # [1-9]\d+ matches non-zero exit codes
  expected_stdout = None
  expected_errput = r'r4 .*: subshell exited with code (?:[1-9]\d+)' \
                   r"|.*subprocess.CalledProcessError.*'merge'.*exit status 1"
  svntest.verify.verify_exit_code(None, exit_code, 1)
  svntest.verify.verify_outputs(None, output, errput,
                                expected_stdout, expected_errput)



#----------------------------------------------------------------------
@BackportTest('76cee987-25c9-4d6c-ad40-000000000009')
def backport_branch_with_original_revision(sbox):
  "branch with original revision"

  # r6: conflicting change on branch
  sbox.simple_append('branch/iota', 'Conflicts with first change')
  sbox.simple_commit(message="Conflicting change on iota")

  # r7: backport branch
  sbox.simple_update()
  sbox.simple_copy('branch', 'subversion/branches/r4')
  sbox.simple_commit(message='Create a backport branch')

  # r8: merge into backport branch
  sbox.simple_update()
  svntest.main.run_svn(None, 'merge', '--record-only', '-c4',
                       '^/subversion/trunk', sbox.ospath('subversion/branches/r4'))
  sbox.simple_mkdir('subversion/branches/r4/A_resolved')
  sbox.simple_append('subversion/branches/r4/iota', "resolved\n", truncate=1)
  sbox.simple_commit(message='Conflict resolution via mkdir')

  # r9: original revision on branch
  sbox.simple_update()
  sbox.simple_mkdir('subversion/branches/r4/dir-created-on-backport-branch')
  sbox.simple_commit(message='An original revision on the backport branch')

  # r10: nominate the branch with r9 listed
  approved_entries = [
    make_entry([4, 9], branch="r4")
  ]
  sbox.simple_append(STATUS, serialize_STATUS(approved_entries))
  sbox.simple_commit(message='Nominate r4+r9')

  # r11, r12: Run it.
  run_backport(sbox)


#----------------------------------------------------------------------
@BackportTest(None)
def backport_otherproject_change(sbox):
  "inoperative revision"

  # r6: a change outside ^/subversion
  sbox.simple_mkdir('elsewhere')
  sbox.simple_commit()

  # r7: Nominate r6 by mistake
  approved_entries = [
    make_entry([6])
  ]
  sbox.simple_append(STATUS, serialize_STATUS(approved_entries))
  sbox.simple_commit(message='Nominate r6 by mistake')

  # Run it.
  exit_code, output, errput = run_backport(sbox, error_expected=True)

  # Verify no commit occurred.
  svntest.actions.run_and_verify_svnlook(["7\n"], [],
                                         'youngest', sbox.repo_dir)

  # Verify the failure mode.
  expected_stdout = None
  expected_stderr = ".*only svn:mergeinfo changes.*"
  if exit_code == 0:
    # Can't use verify_exit_code() since the exact code used varies.
    raise svntest.Failure("exit_code should be non-zero")
  svntest.verify.verify_outputs(None, output, errput,
                                expected_stdout, expected_stderr)

#----------------------------------------------------------------------
@BackportTest(None)
def backport_STATUS_mods(sbox):
  "local mods to STATUS"

  # Introduce a local mod.
  sbox.simple_append(STATUS, "\n")

  exit_code, output, errput = run_backport(sbox, error_expected=True)
  expected_stdout = None
  expected_stderr = ".*Local mods.*STATUS.*"
  if exit_code == 0:
    # Can't use verify_exit_code() since the exact code used varies.
    raise svntest.Failure("exit_code should be non-zero")
  svntest.verify.verify_outputs(None, output, errput,
                                expected_stdout, expected_stderr)

#----------------------------------------------------------------------
@BackportTest('76cee987-25c9-4d6c-ad40-000000000012')
def backport_unicode_entry(sbox):
  "an entry containing literal UTF-8"

  # r6: nominate r4
  approved_entries = [
    make_entry([4], notes="Hello 🗺"),
  ]
  sbox.simple_append(STATUS, serialize_STATUS(approved_entries))
  sbox.simple_commit(message='Nominate r4')

  # Run it.
  run_backport(sbox)

#----------------------------------------------------------------------
@BackportTest('76cee987-25c9-4d6c-ad40-000000000013')
def backport_logsummary_colon(sbox):
  "a logsummary that looks like a header"

  # r6: nominate r4
  approved_entries = [
    make_entry([4], logsummary="HTTPv2: Add comments."),
  ]
  sbox.simple_append(STATUS, serialize_STATUS(approved_entries))
  sbox.simple_commit(message='Nominate r4')

  # Run it.
  run_backport(sbox)


#----------------------------------------------------------------------

########################################################################
# Run the tests

# list all tests here, starting with None:
test_list = [ None,
              backport_indented_entry,
              backport_two_approveds,
              backport_accept,
              backport_branches,
              backport_multirevisions,
              backport_conflicts_detection,
              backport_branch_contains,
              backport_double_conflict,
              backport_branch_with_original_revision,
              backport_otherproject_change,
              backport_STATUS_mods,
              backport_unicode_entry,
              backport_logsummary_colon,
              # When adding a new test, include the test number in the last
              # 6 bytes of the UUID, in decimal.
             ]

if __name__ == '__main__':
  # Using putenv() here is fine because this file is never run as a module.
  os.putenv('SVN_BACKPORT_DONT_SLEEP', '1')
  svntest.main.run_tests(test_list)
  # NOTREACHED


### End of file.
