diff options
Diffstat (limited to 'bzrlib/tests/per_repository_vf/test_check_reconcile.py')
-rw-r--r-- | bzrlib/tests/per_repository_vf/test_check_reconcile.py | 978 |
1 files changed, 978 insertions, 0 deletions
diff --git a/bzrlib/tests/per_repository_vf/test_check_reconcile.py b/bzrlib/tests/per_repository_vf/test_check_reconcile.py new file mode 100644 index 0000000..8afc8bf --- /dev/null +++ b/bzrlib/tests/per_repository_vf/test_check_reconcile.py @@ -0,0 +1,978 @@ +# Copyright (C) 2007-2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Tests that use BrokenRepoScenario objects. + +That is, tests for reconcile and check. +""" + +from bzrlib import osutils + +from bzrlib.inventory import ( + Inventory, + InventoryFile, + ) +from bzrlib.revision import ( + NULL_REVISION, + Revision, + ) +from bzrlib.tests import ( + TestNotApplicable, + multiply_scenarios, + ) +from bzrlib.tests.per_repository_vf import ( + TestCaseWithRepository, + all_repository_vf_format_scenarios, + ) +from bzrlib.tests.scenarios import load_tests_apply_scenarios + + +load_tests = load_tests_apply_scenarios + + +class BrokenRepoScenario(object): + """Base class for defining scenarios for testing check and reconcile. + + A subclass needs to define the following methods: + :populate_repository: a method to use to populate a repository with + sample revisions, inventories and file versions. + :all_versions_after_reconcile: all the versions in repository after + reconcile. run_test verifies that the text of each of these + versions of the file is unchanged by the reconcile. + :populated_parents: a list of (parents list, revision). Each version + of the file is verified to have the given parents before running + the reconcile. i.e. this is used to assert that the repo from the + factory is what we expect. + :corrected_parents: a list of (parents list, revision). Each version + of the file is verified to have the given parents after the + reconcile. i.e. this is used to assert that reconcile made the + changes we expect it to make. + + A subclass may define the following optional method as well: + :corrected_fulltexts: a list of file versions that should be stored as + fulltexts (not deltas) after reconcile. run_test will verify that + this occurs. + """ + + def __init__(self, test_case): + self.test_case = test_case + + def make_one_file_inventory(self, repo, revision, parents, + inv_revision=None, root_revision=None, + file_contents=None, make_file_version=True): + return self.test_case.make_one_file_inventory( + repo, revision, parents, inv_revision=inv_revision, + root_revision=root_revision, file_contents=file_contents, + make_file_version=make_file_version) + + def add_revision(self, repo, revision_id, inv, parent_ids): + return self.test_case.add_revision(repo, revision_id, inv, parent_ids) + + def corrected_fulltexts(self): + return [] + + def repository_text_key_index(self): + result = {} + if self.versioned_root: + result.update(self.versioned_repository_text_keys()) + result.update(self.repository_text_keys()) + return result + + +class UndamagedRepositoryScenario(BrokenRepoScenario): + """A scenario where the repository has no damage. + + It has a single revision, 'rev1a', with a single file. + """ + + def all_versions_after_reconcile(self): + return ('rev1a', ) + + def populated_parents(self): + return (((), 'rev1a'), ) + + def corrected_parents(self): + # Same as the populated parents, because there was nothing wrong. + return self.populated_parents() + + def check_regexes(self, repo): + return ["0 unreferenced text versions"] + + def populate_repository(self, repo): + # make rev1a: A well-formed revision, containing 'a-file' + inv = self.make_one_file_inventory( + repo, 'rev1a', [], root_revision='rev1a') + self.add_revision(repo, 'rev1a', inv, []) + self.versioned_root = repo.supports_rich_root() + + def repository_text_key_references(self): + result = {} + if self.versioned_root: + result.update({('TREE_ROOT', 'rev1a'): True}) + result.update({('a-file-id', 'rev1a'): True}) + return result + + def repository_text_keys(self): + return {('a-file-id', 'rev1a'):[NULL_REVISION]} + + def versioned_repository_text_keys(self): + return {('TREE_ROOT', 'rev1a'):[NULL_REVISION]} + + +class FileParentIsNotInRevisionAncestryScenario(BrokenRepoScenario): + """A scenario where a revision 'rev2' has 'a-file' with a + parent 'rev1b' that is not in the revision ancestry. + + Reconcile should remove 'rev1b' from the parents list of 'a-file' in + 'rev2', preserving 'rev1a' as a parent. + """ + + def all_versions_after_reconcile(self): + return ('rev1a', 'rev2') + + def populated_parents(self): + return ( + ((), 'rev1a'), + ((), 'rev1b'), # Will be gc'd + (('rev1a', 'rev1b'), 'rev2')) # Will have parents trimmed + + def corrected_parents(self): + return ( + ((), 'rev1a'), + (None, 'rev1b'), + (('rev1a',), 'rev2')) + + def check_regexes(self, repo): + return [r"\* a-file-id version rev2 has parents \('rev1a', 'rev1b'\) " + r"but should have \('rev1a',\)", + "1 unreferenced text versions", + ] + + def populate_repository(self, repo): + # make rev1a: A well-formed revision, containing 'a-file' + inv = self.make_one_file_inventory( + repo, 'rev1a', [], root_revision='rev1a') + self.add_revision(repo, 'rev1a', inv, []) + + # make rev1b, which has no Revision, but has an Inventory, and + # a-file + inv = self.make_one_file_inventory( + repo, 'rev1b', [], root_revision='rev1b') + repo.add_inventory('rev1b', inv, []) + + # make rev2, with a-file. + # a-file has 'rev1b' as an ancestor, even though this is not + # mentioned by 'rev1a', making it an unreferenced ancestor + inv = self.make_one_file_inventory( + repo, 'rev2', ['rev1a', 'rev1b']) + self.add_revision(repo, 'rev2', inv, ['rev1a']) + self.versioned_root = repo.supports_rich_root() + + def repository_text_key_references(self): + result = {} + if self.versioned_root: + result.update({('TREE_ROOT', 'rev1a'): True, + ('TREE_ROOT', 'rev2'): True}) + result.update({('a-file-id', 'rev1a'): True, + ('a-file-id', 'rev2'): True}) + return result + + def repository_text_keys(self): + return {('a-file-id', 'rev1a'):[NULL_REVISION], + ('a-file-id', 'rev2'):[('a-file-id', 'rev1a')]} + + def versioned_repository_text_keys(self): + return {('TREE_ROOT', 'rev1a'):[NULL_REVISION], + ('TREE_ROOT', 'rev2'):[('TREE_ROOT', 'rev1a')]} + + +class FileParentHasInaccessibleInventoryScenario(BrokenRepoScenario): + """A scenario where a revision 'rev3' containing 'a-file' modified in + 'rev3', and with a parent which is in the revision ancestory, but whose + inventory cannot be accessed at all. + + Reconcile should remove the file version parent whose inventory is + inaccessbile (i.e. remove 'rev1c' from the parents of a-file's rev3). + """ + + def all_versions_after_reconcile(self): + return ('rev2', 'rev3') + + def populated_parents(self): + return ( + ((), 'rev2'), + (('rev1c',), 'rev3')) + + def corrected_parents(self): + return ( + ((), 'rev2'), + ((), 'rev3')) + + def check_regexes(self, repo): + return [r"\* a-file-id version rev3 has parents " + r"\('rev1c',\) but should have \(\)", + ] + + def populate_repository(self, repo): + # make rev2, with a-file + # a-file is sane + inv = self.make_one_file_inventory(repo, 'rev2', []) + self.add_revision(repo, 'rev2', inv, []) + + # make ghost revision rev1c, with a version of a-file present so + # that we generate a knit delta against this version. In real life + # the ghost might never have been present or rev3 might have been + # generated against a revision that was present at the time. So + # currently we have the full history of a-file present even though + # the inventory and revision objects are not. + self.make_one_file_inventory(repo, 'rev1c', []) + + # make rev3 with a-file + # a-file refers to 'rev1c', which is a ghost in this repository, so + # a-file cannot have rev1c as its ancestor. + inv = self.make_one_file_inventory(repo, 'rev3', ['rev1c']) + self.add_revision(repo, 'rev3', inv, ['rev1c', 'rev1a']) + self.versioned_root = repo.supports_rich_root() + + def repository_text_key_references(self): + result = {} + if self.versioned_root: + result.update({('TREE_ROOT', 'rev2'): True, + ('TREE_ROOT', 'rev3'): True}) + result.update({('a-file-id', 'rev2'): True, + ('a-file-id', 'rev3'): True}) + return result + + def repository_text_keys(self): + return {('a-file-id', 'rev2'):[NULL_REVISION], + ('a-file-id', 'rev3'):[NULL_REVISION]} + + def versioned_repository_text_keys(self): + return {('TREE_ROOT', 'rev2'):[NULL_REVISION], + ('TREE_ROOT', 'rev3'):[NULL_REVISION]} + + +class FileParentsNotReferencedByAnyInventoryScenario(BrokenRepoScenario): + """A scenario where a repository with file 'a-file' which has extra + per-file versions that are not referenced by any inventory (even though + they have the same ID as actual revisions). The inventory of 'rev2' + references 'rev1a' of 'a-file', but there is a 'rev2' of 'some-file' stored + and erroneously referenced by later per-file versions (revisions 'rev4' and + 'rev5'). + + Reconcile should remove the file parents that are not referenced by any + inventory. + """ + + def all_versions_after_reconcile(self): + return ('rev1a', 'rev2c', 'rev4', 'rev5') + + def populated_parents(self): + return [ + (('rev1a',), 'rev2'), + (('rev1a',), 'rev2b'), + (('rev2',), 'rev3'), + (('rev2',), 'rev4'), + (('rev2', 'rev2c'), 'rev5')] + + def corrected_parents(self): + return ( + # rev2 and rev2b have been removed. + (None, 'rev2'), + (None, 'rev2b'), + # rev3's accessible parent inventories all have rev1a as the last + # modifier. + (('rev1a',), 'rev3'), + # rev1a features in both rev4's parents but should only appear once + # in the result + (('rev1a',), 'rev4'), + # rev2c is the head of rev1a and rev2c, the inventory provided + # per-file last-modified revisions. + (('rev2c',), 'rev5')) + + def check_regexes(self, repo): + if repo.supports_rich_root(): + # TREE_ROOT will be wrong; but we're not testing it. so just adjust + # the expected count of errors. + count = 9 + else: + count = 3 + return [ + # will be gc'd + r"unreferenced version: {rev2} in a-file-id", + r"unreferenced version: {rev2b} in a-file-id", + # will be corrected + r"a-file-id version rev3 has parents \('rev2',\) " + r"but should have \('rev1a',\)", + r"a-file-id version rev5 has parents \('rev2', 'rev2c'\) " + r"but should have \('rev2c',\)", + r"a-file-id version rev4 has parents \('rev2',\) " + r"but should have \('rev1a',\)", + "%d inconsistent parents" % count, + ] + + def populate_repository(self, repo): + # make rev1a: A well-formed revision, containing 'a-file' + inv = self.make_one_file_inventory( + repo, 'rev1a', [], root_revision='rev1a') + self.add_revision(repo, 'rev1a', inv, []) + + # make rev2, with a-file. + # a-file is unmodified from rev1a, and an unreferenced rev2 file + # version is present in the repository. + self.make_one_file_inventory( + repo, 'rev2', ['rev1a'], inv_revision='rev1a') + self.add_revision(repo, 'rev2', inv, ['rev1a']) + + # make rev3 with a-file + # a-file has 'rev2' as its ancestor, but the revision in 'rev2' was + # rev1a so this is inconsistent with rev2's inventory - it should + # be rev1a, and at the revision level 1c is not present - it is a + # ghost, so only the details from rev1a are available for + # determining whether a delta is acceptable, or a full is needed, + # and what the correct parents are. + inv = self.make_one_file_inventory(repo, 'rev3', ['rev2']) + self.add_revision(repo, 'rev3', inv, ['rev1c', 'rev1a']) + + # In rev2b, the true last-modifying-revision of a-file is rev1a, + # inherited from rev2, but there is a version rev2b of the file, which + # reconcile could remove, leaving no rev2b. Most importantly, + # revisions descending from rev2b should not have per-file parents of + # a-file-rev2b. + # ??? This is to test deduplication in fixing rev4 + inv = self.make_one_file_inventory( + repo, 'rev2b', ['rev1a'], inv_revision='rev1a') + self.add_revision(repo, 'rev2b', inv, ['rev1a']) + + # rev4 is for testing that when the last modified of a file in + # multiple parent revisions is the same, that it only appears once + # in the generated per file parents list: rev2 and rev2b both + # descend from 1a and do not change the file a-file, so there should + # be no version of a-file 'rev2' or 'rev2b', but rev4 does change + # a-file, and is a merge of rev2 and rev2b, so it should end up with + # a parent of just rev1a - the starting file parents list is simply + # completely wrong. + inv = self.make_one_file_inventory(repo, 'rev4', ['rev2']) + self.add_revision(repo, 'rev4', inv, ['rev2', 'rev2b']) + + # rev2c changes a-file from rev1a, so the version it of a-file it + # introduces is a head revision when rev5 is checked. + inv = self.make_one_file_inventory(repo, 'rev2c', ['rev1a']) + self.add_revision(repo, 'rev2c', inv, ['rev1a']) + + # rev5 descends from rev2 and rev2c; as rev2 does not alter a-file, + # but rev2c does, this should use rev2c as the parent for the per + # file history, even though more than one per-file parent is + # available, because we use the heads of the revision parents for + # the inventory modification revisions of the file to determine the + # parents for the per file graph. + inv = self.make_one_file_inventory(repo, 'rev5', ['rev2', 'rev2c']) + self.add_revision(repo, 'rev5', inv, ['rev2', 'rev2c']) + self.versioned_root = repo.supports_rich_root() + + def repository_text_key_references(self): + result = {} + if self.versioned_root: + result.update({('TREE_ROOT', 'rev1a'): True, + ('TREE_ROOT', 'rev2'): True, + ('TREE_ROOT', 'rev2b'): True, + ('TREE_ROOT', 'rev2c'): True, + ('TREE_ROOT', 'rev3'): True, + ('TREE_ROOT', 'rev4'): True, + ('TREE_ROOT', 'rev5'): True}) + result.update({('a-file-id', 'rev1a'): True, + ('a-file-id', 'rev2c'): True, + ('a-file-id', 'rev3'): True, + ('a-file-id', 'rev4'): True, + ('a-file-id', 'rev5'): True}) + return result + + def repository_text_keys(self): + return {('a-file-id', 'rev1a'): [NULL_REVISION], + ('a-file-id', 'rev2c'): [('a-file-id', 'rev1a')], + ('a-file-id', 'rev3'): [('a-file-id', 'rev1a')], + ('a-file-id', 'rev4'): [('a-file-id', 'rev1a')], + ('a-file-id', 'rev5'): [('a-file-id', 'rev2c')]} + + def versioned_repository_text_keys(self): + return {('TREE_ROOT', 'rev1a'): [NULL_REVISION], + ('TREE_ROOT', 'rev2'): [('TREE_ROOT', 'rev1a')], + ('TREE_ROOT', 'rev2b'): [('TREE_ROOT', 'rev1a')], + ('TREE_ROOT', 'rev2c'): [('TREE_ROOT', 'rev1a')], + ('TREE_ROOT', 'rev3'): [('TREE_ROOT', 'rev1a')], + ('TREE_ROOT', 'rev4'): + [('TREE_ROOT', 'rev2'), ('TREE_ROOT', 'rev2b')], + ('TREE_ROOT', 'rev5'): + [('TREE_ROOT', 'rev2'), ('TREE_ROOT', 'rev2c')]} + + +class UnreferencedFileParentsFromNoOpMergeScenario(BrokenRepoScenario): + """ + rev1a and rev1b with identical contents + rev2 revision has parents of [rev1a, rev1b] + There is a a-file:rev2 file version, not referenced by the inventory. + """ + + def all_versions_after_reconcile(self): + return ('rev1a', 'rev1b', 'rev2', 'rev4') + + def populated_parents(self): + return ( + ((), 'rev1a'), + ((), 'rev1b'), + (('rev1a', 'rev1b'), 'rev2'), + (None, 'rev3'), + (('rev2',), 'rev4'), + ) + + def corrected_parents(self): + return ( + ((), 'rev1a'), + ((), 'rev1b'), + ((), 'rev2'), + (None, 'rev3'), + (('rev2',), 'rev4'), + ) + + def corrected_fulltexts(self): + return ['rev2'] + + def check_regexes(self, repo): + return [] + + def populate_repository(self, repo): + # make rev1a: A well-formed revision, containing 'a-file' + inv1a = self.make_one_file_inventory( + repo, 'rev1a', [], root_revision='rev1a') + self.add_revision(repo, 'rev1a', inv1a, []) + + # make rev1b: A well-formed revision, containing 'a-file' + # rev1b of a-file has the exact same contents as rev1a. + file_contents = repo.texts.get_record_stream([('a-file-id', 'rev1a')], + "unordered", False).next().get_bytes_as('fulltext') + inv = self.make_one_file_inventory( + repo, 'rev1b', [], root_revision='rev1b', + file_contents=file_contents) + self.add_revision(repo, 'rev1b', inv, []) + + # make rev2, a merge of rev1a and rev1b, with a-file. + # a-file is unmodified from rev1a and rev1b, but a new version is + # wrongly present anyway. + inv = self.make_one_file_inventory( + repo, 'rev2', ['rev1a', 'rev1b'], inv_revision='rev1a', + file_contents=file_contents) + self.add_revision(repo, 'rev2', inv, ['rev1a', 'rev1b']) + + # rev3: a-file unchanged from rev2, but wrongly referencing rev2 of the + # file in its inventory. + inv = self.make_one_file_inventory( + repo, 'rev3', ['rev2'], inv_revision='rev2', + file_contents=file_contents, make_file_version=False) + self.add_revision(repo, 'rev3', inv, ['rev2']) + + # rev4: a modification of a-file on top of rev3. + inv = self.make_one_file_inventory(repo, 'rev4', ['rev2']) + self.add_revision(repo, 'rev4', inv, ['rev3']) + self.versioned_root = repo.supports_rich_root() + + def repository_text_key_references(self): + result = {} + if self.versioned_root: + result.update({('TREE_ROOT', 'rev1a'): True, + ('TREE_ROOT', 'rev1b'): True, + ('TREE_ROOT', 'rev2'): True, + ('TREE_ROOT', 'rev3'): True, + ('TREE_ROOT', 'rev4'): True}) + result.update({('a-file-id', 'rev1a'): True, + ('a-file-id', 'rev1b'): True, + ('a-file-id', 'rev2'): False, + ('a-file-id', 'rev4'): True}) + return result + + def repository_text_keys(self): + return {('a-file-id', 'rev1a'): [NULL_REVISION], + ('a-file-id', 'rev1b'): [NULL_REVISION], + ('a-file-id', 'rev2'): [NULL_REVISION], + ('a-file-id', 'rev4'): [('a-file-id', 'rev2')]} + + def versioned_repository_text_keys(self): + return {('TREE_ROOT', 'rev1a'): [NULL_REVISION], + ('TREE_ROOT', 'rev1b'): [NULL_REVISION], + ('TREE_ROOT', 'rev2'): + [('TREE_ROOT', 'rev1a'), ('TREE_ROOT', 'rev1b')], + ('TREE_ROOT', 'rev3'): [('TREE_ROOT', 'rev2')], + ('TREE_ROOT', 'rev4'): [('TREE_ROOT', 'rev3')]} + + +class TooManyParentsScenario(BrokenRepoScenario): + """A scenario where 'broken-revision' of 'a-file' claims to have parents + ['good-parent', 'bad-parent']. However 'bad-parent' is in the ancestry of + 'good-parent', so the correct parent list for that file version are is just + ['good-parent']. + """ + + def all_versions_after_reconcile(self): + return ('bad-parent', 'good-parent', 'broken-revision') + + def populated_parents(self): + return ( + ((), 'bad-parent'), + (('bad-parent',), 'good-parent'), + (('good-parent', 'bad-parent'), 'broken-revision')) + + def corrected_parents(self): + return ( + ((), 'bad-parent'), + (('bad-parent',), 'good-parent'), + (('good-parent',), 'broken-revision')) + + def check_regexes(self, repo): + if repo.supports_rich_root(): + # TREE_ROOT will be wrong; but we're not testing it. so just adjust + # the expected count of errors. + count = 3 + else: + count = 1 + return ( + ' %d inconsistent parents' % count, + (r" \* a-file-id version broken-revision has parents " + r"\('good-parent', 'bad-parent'\) but " + r"should have \('good-parent',\)")) + + def populate_repository(self, repo): + inv = self.make_one_file_inventory( + repo, 'bad-parent', (), root_revision='bad-parent') + self.add_revision(repo, 'bad-parent', inv, ()) + + inv = self.make_one_file_inventory( + repo, 'good-parent', ('bad-parent',)) + self.add_revision(repo, 'good-parent', inv, ('bad-parent',)) + + inv = self.make_one_file_inventory( + repo, 'broken-revision', ('good-parent', 'bad-parent')) + self.add_revision(repo, 'broken-revision', inv, ('good-parent',)) + self.versioned_root = repo.supports_rich_root() + + def repository_text_key_references(self): + result = {} + if self.versioned_root: + result.update({('TREE_ROOT', 'bad-parent'): True, + ('TREE_ROOT', 'broken-revision'): True, + ('TREE_ROOT', 'good-parent'): True}) + result.update({('a-file-id', 'bad-parent'): True, + ('a-file-id', 'broken-revision'): True, + ('a-file-id', 'good-parent'): True}) + return result + + def repository_text_keys(self): + return {('a-file-id', 'bad-parent'): [NULL_REVISION], + ('a-file-id', 'broken-revision'): + [('a-file-id', 'good-parent')], + ('a-file-id', 'good-parent'): [('a-file-id', 'bad-parent')]} + + def versioned_repository_text_keys(self): + return {('TREE_ROOT', 'bad-parent'): [NULL_REVISION], + ('TREE_ROOT', 'broken-revision'): + [('TREE_ROOT', 'good-parent')], + ('TREE_ROOT', 'good-parent'): [('TREE_ROOT', 'bad-parent')]} + + +class ClaimedFileParentDidNotModifyFileScenario(BrokenRepoScenario): + """A scenario where the file parent is the same as the revision parent, but + should not be because that revision did not modify the file. + + Specifically, the parent revision of 'current' is + 'modified-something-else', which does not modify 'a-file', but the + 'current' version of 'a-file' erroneously claims that + 'modified-something-else' is the parent file version. + """ + + def all_versions_after_reconcile(self): + return ('basis', 'current') + + def populated_parents(self): + return ( + ((), 'basis'), + (('basis',), 'modified-something-else'), + (('modified-something-else',), 'current')) + + def corrected_parents(self): + return ( + ((), 'basis'), + (None, 'modified-something-else'), + (('basis',), 'current')) + + def check_regexes(self, repo): + if repo.supports_rich_root(): + # TREE_ROOT will be wrong; but we're not testing it. so just adjust + # the expected count of errors. + count = 3 + else: + count = 1 + return ( + "%d inconsistent parents" % count, + r"\* a-file-id version current has parents " + r"\('modified-something-else',\) but should have \('basis',\)", + ) + + def populate_repository(self, repo): + inv = self.make_one_file_inventory(repo, 'basis', ()) + self.add_revision(repo, 'basis', inv, ()) + + # 'modified-something-else' is a correctly recorded revision, but it + # does not modify the file we are looking at, so the inventory for that + # file in this revision points to 'basis'. + inv = self.make_one_file_inventory( + repo, 'modified-something-else', ('basis',), inv_revision='basis') + self.add_revision(repo, 'modified-something-else', inv, ('basis',)) + + # The 'current' revision has 'modified-something-else' as its parent, + # but the 'current' version of 'a-file' should have 'basis' as its + # parent. + inv = self.make_one_file_inventory( + repo, 'current', ('modified-something-else',)) + self.add_revision(repo, 'current', inv, ('modified-something-else',)) + self.versioned_root = repo.supports_rich_root() + + def repository_text_key_references(self): + result = {} + if self.versioned_root: + result.update({('TREE_ROOT', 'basis'): True, + ('TREE_ROOT', 'current'): True, + ('TREE_ROOT', 'modified-something-else'): True}) + result.update({('a-file-id', 'basis'): True, + ('a-file-id', 'current'): True}) + return result + + def repository_text_keys(self): + return {('a-file-id', 'basis'): [NULL_REVISION], + ('a-file-id', 'current'): [('a-file-id', 'basis')]} + + def versioned_repository_text_keys(self): + return {('TREE_ROOT', 'basis'): ['null:'], + ('TREE_ROOT', 'current'): + [('TREE_ROOT', 'modified-something-else')], + ('TREE_ROOT', 'modified-something-else'): + [('TREE_ROOT', 'basis')]} + + +class IncorrectlyOrderedParentsScenario(BrokenRepoScenario): + """A scenario where the set parents of a version of a file are correct, but + the order of those parents is incorrect. + + This defines a 'broken-revision-1-2' and a 'broken-revision-2-1' which both + have their file version parents reversed compared to the revision parents, + which is invalid. (We use two revisions with opposite orderings of the + same parents to make sure that accidentally relying on dictionary/set + ordering cannot make the test pass; the assumption is that while dict/set + iteration order is arbitrary, it is also consistent within a single test). + """ + + def all_versions_after_reconcile(self): + return ['parent-1', 'parent-2', 'broken-revision-1-2', + 'broken-revision-2-1'] + + def populated_parents(self): + return ( + ((), 'parent-1'), + ((), 'parent-2'), + (('parent-2', 'parent-1'), 'broken-revision-1-2'), + (('parent-1', 'parent-2'), 'broken-revision-2-1')) + + def corrected_parents(self): + return ( + ((), 'parent-1'), + ((), 'parent-2'), + (('parent-1', 'parent-2'), 'broken-revision-1-2'), + (('parent-2', 'parent-1'), 'broken-revision-2-1')) + + def check_regexes(self, repo): + if repo.supports_rich_root(): + # TREE_ROOT will be wrong; but we're not testing it. so just adjust + # the expected count of errors. + count = 4 + else: + count = 2 + return ( + "%d inconsistent parents" % count, + r"\* a-file-id version broken-revision-1-2 has parents " + r"\('parent-2', 'parent-1'\) but should have " + r"\('parent-1', 'parent-2'\)", + r"\* a-file-id version broken-revision-2-1 has parents " + r"\('parent-1', 'parent-2'\) but should have " + r"\('parent-2', 'parent-1'\)") + + def populate_repository(self, repo): + inv = self.make_one_file_inventory(repo, 'parent-1', []) + self.add_revision(repo, 'parent-1', inv, []) + + inv = self.make_one_file_inventory(repo, 'parent-2', []) + self.add_revision(repo, 'parent-2', inv, []) + + inv = self.make_one_file_inventory( + repo, 'broken-revision-1-2', ['parent-2', 'parent-1']) + self.add_revision( + repo, 'broken-revision-1-2', inv, ['parent-1', 'parent-2']) + + inv = self.make_one_file_inventory( + repo, 'broken-revision-2-1', ['parent-1', 'parent-2']) + self.add_revision( + repo, 'broken-revision-2-1', inv, ['parent-2', 'parent-1']) + self.versioned_root = repo.supports_rich_root() + + def repository_text_key_references(self): + result = {} + if self.versioned_root: + result.update({('TREE_ROOT', 'broken-revision-1-2'): True, + ('TREE_ROOT', 'broken-revision-2-1'): True, + ('TREE_ROOT', 'parent-1'): True, + ('TREE_ROOT', 'parent-2'): True}) + result.update({('a-file-id', 'broken-revision-1-2'): True, + ('a-file-id', 'broken-revision-2-1'): True, + ('a-file-id', 'parent-1'): True, + ('a-file-id', 'parent-2'): True}) + return result + + def repository_text_keys(self): + return {('a-file-id', 'broken-revision-1-2'): + [('a-file-id', 'parent-1'), ('a-file-id', 'parent-2')], + ('a-file-id', 'broken-revision-2-1'): + [('a-file-id', 'parent-2'), ('a-file-id', 'parent-1')], + ('a-file-id', 'parent-1'): [NULL_REVISION], + ('a-file-id', 'parent-2'): [NULL_REVISION]} + + def versioned_repository_text_keys(self): + return {('TREE_ROOT', 'broken-revision-1-2'): + [('TREE_ROOT', 'parent-1'), ('TREE_ROOT', 'parent-2')], + ('TREE_ROOT', 'broken-revision-2-1'): + [('TREE_ROOT', 'parent-2'), ('TREE_ROOT', 'parent-1')], + ('TREE_ROOT', 'parent-1'): [NULL_REVISION], + ('TREE_ROOT', 'parent-2'): [NULL_REVISION]} + + +all_broken_scenario_classes = [ + UndamagedRepositoryScenario, + FileParentIsNotInRevisionAncestryScenario, + FileParentHasInaccessibleInventoryScenario, + FileParentsNotReferencedByAnyInventoryScenario, + TooManyParentsScenario, + ClaimedFileParentDidNotModifyFileScenario, + IncorrectlyOrderedParentsScenario, + UnreferencedFileParentsFromNoOpMergeScenario, + ] + + +def broken_scenarios_for_all_formats(): + format_scenarios = all_repository_vf_format_scenarios() + # test_check_reconcile needs to be parameterized by format *and* by broken + # repository scenario. + broken_scenarios = [(s.__name__, {'scenario_class': s}) + for s in all_broken_scenario_classes] + return multiply_scenarios(format_scenarios, broken_scenarios) + + +class TestFileParentReconciliation(TestCaseWithRepository): + """Tests for how reconcile corrects errors in parents of file versions.""" + + scenarios = broken_scenarios_for_all_formats() + + def make_populated_repository(self, factory): + """Create a new repository populated by the given factory.""" + repo = self.make_repository('broken-repo') + repo.lock_write() + try: + repo.start_write_group() + try: + factory(repo) + repo.commit_write_group() + return repo + except: + repo.abort_write_group() + raise + finally: + repo.unlock() + + def add_revision(self, repo, revision_id, inv, parent_ids): + """Add a revision with a given inventory and parents to a repository. + + :param repo: a repository. + :param revision_id: the revision ID for the new revision. + :param inv: an inventory (such as created by + `make_one_file_inventory`). + :param parent_ids: the parents for the new revision. + """ + inv.revision_id = revision_id + inv.root.revision = revision_id + if repo.supports_rich_root(): + root_id = inv.root.file_id + repo.texts.add_lines((root_id, revision_id), [], []) + repo.add_inventory(revision_id, inv, parent_ids) + revision = Revision(revision_id, committer='jrandom@example.com', + timestamp=0, inventory_sha1='', timezone=0, message='foo', + parent_ids=parent_ids) + repo.add_revision(revision_id, revision, inv) + + def make_one_file_inventory(self, repo, revision, parents, + inv_revision=None, root_revision=None, + file_contents=None, make_file_version=True): + """Make an inventory containing a version of a file with ID 'a-file'. + + The file's ID will be 'a-file', and its filename will be 'a file name', + stored at the tree root. + + :param repo: a repository to add the new file version to. + :param revision: the revision ID of the new inventory. + :param parents: the parents for this revision of 'a-file'. + :param inv_revision: if not None, the revision ID to store in the + inventory entry. Otherwise, this defaults to revision. + :param root_revision: if not None, the inventory's root.revision will + be set to this. + :param file_contents: if not None, the contents of this file version. + Otherwise a unique default (based on revision ID) will be + generated. + """ + inv = Inventory(revision_id=revision) + if root_revision is not None: + inv.root.revision = root_revision + file_id = 'a-file-id' + entry = InventoryFile(file_id, 'a file name', 'TREE_ROOT') + if inv_revision is not None: + entry.revision = inv_revision + else: + entry.revision = revision + entry.text_size = 0 + if file_contents is None: + file_contents = '%sline\n' % entry.revision + entry.text_sha1 = osutils.sha_string(file_contents) + inv.add(entry) + if make_file_version: + repo.texts.add_lines((file_id, revision), + [(file_id, parent) for parent in parents], [file_contents]) + return inv + + def require_repo_suffers_text_parent_corruption(self, repo): + if not repo._reconcile_fixes_text_parents: + raise TestNotApplicable( + "Format does not support text parent reconciliation") + + def file_parents(self, repo, revision_id): + key = ('a-file-id', revision_id) + parent_map = repo.texts.get_parent_map([key]) + return tuple(parent[-1] for parent in parent_map[key]) + + def assertFileVersionAbsent(self, repo, revision_id): + self.assertEqual({}, + repo.texts.get_parent_map([('a-file-id', revision_id)])) + + def assertParentsMatch(self, expected_parents_for_versions, repo, + when_description): + for expected_parents, version in expected_parents_for_versions: + if expected_parents is None: + self.assertFileVersionAbsent(repo, version) + else: + found_parents = self.file_parents(repo, version) + self.assertEqual(expected_parents, found_parents, + "%s reconcile %s has parents %s, should have %s." + % (when_description, version, found_parents, + expected_parents)) + + def prepare_test_repository(self): + """Prepare a repository to test with from the test scenario. + + :return: A repository, and the scenario instance. + """ + scenario = self.scenario_class(self) + repo = self.make_populated_repository(scenario.populate_repository) + self.require_repo_suffers_text_parent_corruption(repo) + return repo, scenario + + def shas_for_versions_of_file(self, repo, versions): + """Get the SHA-1 hashes of the versions of 'a-file' in the repository. + + :param repo: the repository to get the hashes from. + :param versions: a list of versions to get hashes for. + + :returns: A dict of `{version: hash}`. + """ + keys = [('a-file-id', version) for version in versions] + return repo.texts.get_sha1s(keys) + + def test_reconcile_behaviour(self): + """Populate a repository and reconcile it, verifying the state before + and after. + """ + repo, scenario = self.prepare_test_repository() + repo.lock_read() + try: + self.assertParentsMatch(scenario.populated_parents(), repo, + 'before') + vf_shas = self.shas_for_versions_of_file( + repo, scenario.all_versions_after_reconcile()) + finally: + repo.unlock() + result = repo.reconcile(thorough=True) + repo.lock_read() + try: + self.assertParentsMatch(scenario.corrected_parents(), repo, + 'after') + # The contents of the versions in the versionedfile should be the + # same after the reconcile. + self.assertEqual( + vf_shas, + self.shas_for_versions_of_file( + repo, scenario.all_versions_after_reconcile())) + + # Scenario.corrected_fulltexts contains texts which the test wants + # to assert are now fulltexts. However this is an abstraction + # violation; really we care that: + # - the text is reconstructable + # - it has an empty parents list + # (we specify it this way because a store can use arbitrary + # compression pointers in principle. + for file_version in scenario.corrected_fulltexts(): + key = ('a-file-id', file_version) + self.assertEqual({key:()}, repo.texts.get_parent_map([key])) + self.assertIsInstance( + repo.texts.get_record_stream([key], 'unordered', + True).next().get_bytes_as('fulltext'), + str) + finally: + repo.unlock() + + def test_check_behaviour(self): + """Populate a repository and check it, and verify the output.""" + repo, scenario = self.prepare_test_repository() + check_result = repo.check() + check_result.report_results(verbose=True) + log = self.get_log() + for pattern in scenario.check_regexes(repo): + self.assertContainsRe(log, pattern) + + def test_find_text_key_references(self): + """Test that find_text_key_references finds erroneous references.""" + repo, scenario = self.prepare_test_repository() + repo.lock_read() + self.addCleanup(repo.unlock) + self.assertEqual(scenario.repository_text_key_references(), + repo.find_text_key_references()) + + def test__generate_text_key_index(self): + """Test that the generated text key index has all entries.""" + repo, scenario = self.prepare_test_repository() + repo.lock_read() + self.addCleanup(repo.unlock) + self.assertEqual(scenario.repository_text_key_index(), + repo._generate_text_key_index()) |