# Copyright (C) 2006-2010 Canonical Ltd
# Authors:  Robert Collins <robert.collins@canonical.com>
#
# 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

import os

from breezy import branch, conflicts, controldir, errors, mutabletree, osutils
from breezy import revision as _mod_revision
from breezy import tests
from breezy import transport as _mod_transport
from breezy import ui
from breezy.commit import CannotCommitSelectedFileMerge, PointlessCommit
from breezy.tests.matchers import HasPathRelations
from breezy.tests.per_workingtree import TestCaseWithWorkingTree
from breezy.tests.testui import ProgressRecordingUIFactory


class TestCommit(TestCaseWithWorkingTree):

    def test_autodelete_renamed(self):
        tree_a = self.make_branch_and_tree('a')
        self.build_tree(['a/dir/', 'a/dir/f1', 'a/dir/f2'])
        tree_a.add(['dir', 'dir/f1', 'dir/f2'])
        rev_id1 = tree_a.commit('init')
        # Start off by renaming entries,
        # but then actually auto delete the whole tree
        # https://bugs.launchpad.net/bzr/+bug/114615
        tree_a.rename_one('dir/f1', 'dir/a')
        tree_a.rename_one('dir/f2', 'dir/z')
        osutils.rmtree('a/dir')
        tree_a.commit('autoremoved')

        with tree_a.lock_read():
            paths = [(path, ie.file_id)
                     for path, ie in tree_a.iter_entries_by_dir()]
        # The only paths left should be the root
        if tree_a.supports_file_ids:
            self.assertEqual([('', tree_a.path2id(''))], paths)

    def test_no_autodelete_renamed_away(self):
        tree_a = self.make_branch_and_tree('a')
        self.build_tree(['a/dir/', 'a/dir/f1', 'a/dir/f2', 'a/dir2/'])
        tree_a.add(['dir', 'dir/f1', 'dir/f2', 'dir2'])
        rev_id1 = tree_a.commit('init')
        revtree = tree_a.branch.repository.revision_tree(rev_id1)
        # Rename one entry out of this directory
        tree_a.rename_one('dir/f1', 'dir2/a')
        osutils.rmtree('a/dir')
        tree_a.commit('autoremoved')

        # The only paths left should be the root
        self.assertThat(
            tree_a, HasPathRelations(
                revtree,
                [('', ''), ('dir2/', 'dir2/'), ('dir2/a', 'dir/f1')]))

    def test_no_autodelete_alternate_renamed(self):
        # Test for bug #114615
        tree_a = self.make_branch_and_tree('A')
        self.build_tree(['A/a/', 'A/a/m', 'A/a/n'])
        tree_a.add(['a', 'a/m', 'a/n'])
        tree_a.commit('init')

        tree_b = tree_a.controldir.sprout('B').open_workingtree()
        self.build_tree(['B/xyz/'])
        tree_b.add(['xyz'])
        tree_b.rename_one('a/m', 'xyz/m')
        osutils.rmtree('B/a')
        tree_b.commit('delete in B')

        self.assertThat(
            tree_b,
            HasPathRelations(
                tree_a, [('', ''), ('xyz/', None), ('xyz/m', 'a/m')]))

        self.build_tree_contents([('A/a/n', b'new contents for n\n')])
        tree_a.commit('change n in A')

        # Merging from A should introduce conflicts because 'n' was modified
        # (in A) and removed (in B), so 'a' needs to be restored.
        conflicts = tree_b.merge_from_branch(tree_a.branch)
        if tree_b.has_versioned_directories():
            self.assertEqual(3, len(conflicts))
        else:
            self.assertEqual(1, len(conflicts))

        self.assertThat(
            tree_b, HasPathRelations(
                tree_a,
                [('', ''), ('a/', 'a/'), ('xyz/', None),
                 ('a/n.OTHER', 'a/n'), ('xyz/m', 'a/m')]))

        osutils.rmtree('B/a')
        try:
            # bzr resolve --all
            tree_b.set_conflicts([])
        except errors.UnsupportedOperation:
            # On WT2, set_conflicts is unsupported, but the rmtree has the same
            # effect.
            pass
        tree_b.commit('autoremove a, without touching xyz/m')

        self.assertThat(
            tree_b, HasPathRelations(
                tree_a,
                [('', ''), ('xyz/', None), ('xyz/m', 'a/m')]))

    def test_commit_exclude_pending_merge_fails(self):
        """Excludes are a form of partial commit."""
        wt = self.make_branch_and_tree('.')
        self.build_tree(['foo'])
        wt.add('foo')
        wt.commit('commit one')
        wt2 = wt.controldir.sprout('to').open_workingtree()
        wt2.commit('change_right')
        wt.merge_from_branch(wt2.branch)
        self.assertRaises(CannotCommitSelectedFileMerge,
                          wt.commit, 'test', exclude=['foo'])

    def test_commit_exclude_exclude_changed_is_pointless(self):
        tree = self.make_branch_and_tree('.')
        self.build_tree(['a'])
        tree.smart_add(['.'])
        tree.commit('setup test')
        self.build_tree_contents([('a', b'new contents for "a"\n')])
        self.assertRaises(PointlessCommit, tree.commit, 'test',
                          exclude=['a'], allow_pointless=False)

    def test_commit_exclude_excludes_modified_files(self):
        tree = self.make_branch_and_tree('.')
        self.build_tree(['a', 'b', 'c'])
        tree.smart_add(['.'])
        tree.commit('test', exclude=['b', 'c'])
        # If b was excluded it will still be 'added' in status.
        tree.lock_read()
        self.addCleanup(tree.unlock)
        changes = list(tree.iter_changes(tree.basis_tree()))
        self.assertEqual([(None, 'b'), (None, 'c')], [c.path for c in changes])

    def test_commit_exclude_subtree_of_selected(self):
        tree = self.make_branch_and_tree('.')
        self.build_tree(['a/', 'a/b', 'a/c'])
        tree.smart_add(['.'])
        tree.commit('test', specific_files=['a', 'a/c'], exclude=['a/b'])
        # If a/b was excluded it will still be 'added' in status.
        tree.lock_read()
        self.addCleanup(tree.unlock)
        changes = list(tree.iter_changes(tree.basis_tree()))
        self.assertEqual(1, len(changes), changes)
        self.assertEqual((None, 'a/b'), changes[0].path)

    def test_commit_sets_last_revision(self):
        tree = self.make_branch_and_tree('tree')
        if tree.branch.repository._format.supports_setting_revision_ids:
            committed_id = tree.commit('foo', rev_id=b'foo')
            # the commit should have returned the same id we asked for.
            self.assertEqual(b'foo', committed_id)
        else:
            committed_id = tree.commit('foo')
        self.assertEqual([committed_id], tree.get_parent_ids())

    def test_commit_returns_revision_id(self):
        tree = self.make_branch_and_tree('.')
        committed_id = tree.commit('message')
        self.assertTrue(tree.branch.repository.has_revision(committed_id))
        self.assertNotEqual(None, committed_id)

    def test_commit_local_unbound(self):
        # using the library api to do a local commit on unbound branches is
        # also an error
        tree = self.make_branch_and_tree('tree')
        self.assertRaises(errors.LocalRequiresBoundBranch,
                          tree.commit,
                          'foo',
                          local=True)

    def test_commit_merged_kind_change(self):
        """Test merging a kind change.

        Test making a kind change in a working tree, and then merging that
        from another. When committed it should commit the new kind.
        """
        wt = self.make_branch_and_tree('.')
        self.build_tree(['a'])
        wt.add(['a'])
        wt.commit('commit one')
        wt2 = wt.controldir.sprout('to').open_workingtree()
        os.remove('a')
        os.mkdir('a')
        wt.commit('changed kind')
        wt2.merge_from_branch(wt.branch)
        wt2.commit('merged kind change')

    def test_commit_aborted_does_not_apply_automatic_changes_bug_282402(self):
        wt = self.make_branch_and_tree('.')
        wt.add(['a'], ['file'])
        self.assertTrue(wt.is_versioned('a'))
        if wt.supports_setting_file_ids():
            a_id = wt.path2id('a')
            self.assertEqual('a', wt.id2path(a_id))

        def fail_message(obj):
            raise errors.CommandError("empty commit message")
        self.assertRaises(errors.CommandError, wt.commit,
                          message_callback=fail_message)
        self.assertTrue(wt.is_versioned('a'))
        if wt.supports_setting_file_ids():
            self.assertEqual('a', wt.id2path(a_id))

    def test_local_commit_ignores_master(self):
        # a --local commit does not require access to the master branch
        # at all, or even for it to exist.
        # we test this by setting up a bound branch and then corrupting
        # the master.
        master = self.make_branch('master')
        tree = self.make_branch_and_tree('tree')
        try:
            tree.branch.bind(master)
        except branch.BindingUnsupported:
            # older format.
            return
        master.controldir.transport.put_bytes('branch-format', b'garbage')
        del master
        # check its corrupted.
        self.assertRaises(errors.UnknownFormatError,
                          controldir.ControlDir.open,
                          'master')
        tree.commit('foo', local=True)

    def test_local_commit_does_not_push_to_master(self):
        # a --local commit does not require access to the master branch
        # at all, or even for it to exist.
        # we test that even when its available it does not push to it.
        master = self.make_branch('master')
        tree = self.make_branch_and_tree('tree')
        try:
            tree.branch.bind(master)
        except branch.BindingUnsupported:
            # older format.
            return
        committed_id = tree.commit('foo', local=True)
        self.assertFalse(master.repository.has_revision(committed_id))
        self.assertEqual(_mod_revision.NULL_REVISION, master.last_revision())

    def test_record_initial_ghost(self):
        """The working tree needs to record ghosts during commit."""
        wt = self.make_branch_and_tree('.')
        if not wt.branch.repository._format.supports_ghosts:
            raise tests.TestNotApplicable(
                'format does not support ghosts')
        wt.set_parent_ids([b'non:existent@rev--ision--0--2'],
                          allow_leftmost_as_ghost=True)
        rev_id = wt.commit('commit against a ghost first parent.')
        rev = wt.branch.repository.get_revision(rev_id)
        self.assertEqual(rev.parent_ids, [b'non:existent@rev--ision--0--2'])
        # parent_sha1s is not populated now, WTF. rbc 20051003
        self.assertEqual(len(rev.parent_sha1s), 0)

    def test_record_two_ghosts(self):
        """The working tree should preserve all the parents during commit."""
        wt = self.make_branch_and_tree('.')
        if not wt.branch.repository._format.supports_ghosts:
            raise tests.TestNotApplicable(
                'format does not support ghosts')
        wt.set_parent_ids([
            b'foo@azkhazan-123123-abcabc',
            b'wibble@fofof--20050401--1928390812',
            ],
            allow_leftmost_as_ghost=True)
        rev_id = wt.commit("commit from ghost base with one merge")
        # the revision should have been committed with two parents
        rev = wt.branch.repository.get_revision(rev_id)
        self.assertEqual([b'foo@azkhazan-123123-abcabc',
                          b'wibble@fofof--20050401--1928390812'],
                         rev.parent_ids)

    def test_commit_deleted_subtree_and_files_updates_workingtree(self):
        """The working trees inventory may be adjusted by commit."""
        wt = self.make_branch_and_tree('.')
        wt.lock_write()
        self.build_tree(['a', 'b/', 'b/c', 'd'])
        wt.add(['a', 'b', 'b/c', 'd'])
        this_dir = wt.controldir.root_transport
        this_dir.delete_tree('b')
        this_dir.delete('d')
        # now we have a tree with a through d in the inventory, but only
        # a present on disk. After commit b-id, c-id and d-id should be
        # missing from the inventory, within the same tree transaction.
        wt.commit('commit stuff')
        self.assertTrue(wt.has_filename('a'))
        self.assertFalse(wt.has_filename('b'))
        self.assertFalse(wt.has_filename('b/c'))
        self.assertFalse(wt.has_filename('d'))
        wt.unlock()
        # the changes should have persisted to disk - reopen the workingtree
        # to be sure.
        wt = wt.controldir.open_workingtree()
        with wt.lock_read():
            self.assertTrue(wt.has_filename('a'))
            self.assertFalse(wt.has_filename('b'))
            self.assertFalse(wt.has_filename('b/c'))
            self.assertFalse(wt.has_filename('d'))

    def test_commit_deleted_subtree_with_removed(self):
        wt = self.make_branch_and_tree('.')
        self.build_tree(['a', 'b/', 'b/c', 'd'])
        wt.add(['a', 'b', 'b/c'])
        wt.commit('first')
        wt.remove('b/c')
        this_dir = wt.controldir.root_transport
        this_dir.delete_tree('b')
        with wt.lock_write():
            wt.commit('commit deleted rename')
            self.assertTrue(wt.is_versioned('a'))
            self.assertTrue(wt.has_filename('a'))
            self.assertFalse(wt.has_filename('b'))
            self.assertFalse(wt.has_filename('b/c'))

    def test_commit_move_new(self):
        wt = self.make_branch_and_tree('first')
        wt.commit('first')
        wt2 = wt.controldir.sprout('second').open_workingtree()
        self.build_tree(['second/name1'])
        wt2.add('name1')
        wt2.commit('second')
        wt.merge_from_branch(wt2.branch)
        wt.rename_one('name1', 'name2')
        wt.commit('third')
        self.assertFalse(wt.is_versioned('name1'))

    def test_nested_commit(self):
        """Commit in multiply-nested trees"""
        tree = self.make_branch_and_tree('.')
        if not tree.supports_tree_reference():
            # inapplicable test.
            return
        subtree = self.make_branch_and_tree('subtree')
        subsubtree = self.make_branch_and_tree('subtree/subtree')
        subsub_revid = subsubtree.commit('subsubtree')
        subtree.commit('subtree')
        subtree.add(['subtree'])
        tree.add(['subtree'])
        # use allow_pointless=False to ensure that the deepest tree, which
        # has no commits made to it, does not get a pointless commit.
        rev_id = tree.commit('added reference', allow_pointless=False)
        tree.lock_read()
        self.addCleanup(tree.unlock)
        # the deepest subtree has not changed, so no commit should take place.
        self.assertEqual(subsub_revid, subsubtree.last_revision())
        # the intermediate tree should have committed a pointer to the current
        # subtree revision.
        sub_basis = subtree.basis_tree()
        sub_basis.lock_read()
        self.addCleanup(sub_basis.unlock)
        self.assertEqual(
            subsubtree.last_revision(),
            sub_basis.get_reference_revision('subtree'))
        # the intermediate tree has changed, so should have had a commit
        # take place.
        self.assertNotEqual(None, subtree.last_revision())
        # the outer tree should have committed a pointer to the current
        # subtree revision.
        basis = tree.basis_tree()
        basis.lock_read()
        self.addCleanup(basis.unlock)
        self.assertEqual(subtree.last_revision(),
                         basis.get_reference_revision('subtree'))
        # the outer tree must have have changed too.
        self.assertNotEqual(None, rev_id)

    def test_nested_commit_second_commit_detects_changes(self):
        """Commit with a nested tree picks up the correct child revid."""
        tree = self.make_branch_and_tree('.')
        if not tree.supports_tree_reference():
            # inapplicable test.
            return
        self.knownFailure('nested trees don\'t work well with iter_changes')
        subtree = self.make_branch_and_tree('subtree')
        tree.add(['subtree'])
        self.build_tree(['subtree/file'])
        subtree.add(['file'])
        rev_id = tree.commit('added reference', allow_pointless=False)
        tree.get_reference_revision('subtree')
        child_revid = subtree.last_revision()
        # now change the child tree
        self.build_tree_contents([('subtree/file', b'new-content')])
        # and commit in the parent should commit the child and grab its revid,
        # we test with allow_pointless=False here so that we are simulating
        # what users will see.
        rev_id2 = tree.commit('changed subtree only', allow_pointless=False)
        # the child tree has changed, so should have had a commit
        # take place.
        self.assertNotEqual(None, subtree.last_revision())
        self.assertNotEqual(child_revid, subtree.last_revision())
        # the outer tree should have committed a pointer to the current
        # subtree revision.
        basis = tree.basis_tree()
        basis.lock_read()
        self.addCleanup(basis.unlock)
        self.assertEqual(subtree.last_revision(),
                         basis.get_reference_revision('subtree'))
        self.assertNotEqual(rev_id, rev_id2)

    def test_nested_pointless_commits_are_pointless(self):
        tree = self.make_branch_and_tree('.')
        if not tree.supports_tree_reference():
            # inapplicable test.
            return
        subtree = self.make_branch_and_tree('subtree')
        subtree.commit('')
        tree.add(['subtree'])
        # record the reference.
        rev_id = tree.commit('added reference')
        child_revid = subtree.last_revision()
        # now do a no-op commit with allow_pointless=False
        self.assertRaises(PointlessCommit, tree.commit, '',
                          allow_pointless=False)
        self.assertEqual(child_revid, subtree.last_revision())
        self.assertEqual(rev_id, tree.last_revision())


class TestCommitProgress(TestCaseWithWorkingTree):

    def setUp(self):
        super().setUp()
        ui.ui_factory = ProgressRecordingUIFactory()

    def test_commit_progress_steps(self):
        # during commit we one progress update for every entry in the
        # inventory, and then one for the inventory, and one for the
        # inventory, and one for the revision insertions.
        # first we need a test commit to do. Lets setup a branch with
        # 3 files, and alter one in a selected-file commit. This exercises
        # a number of cases quickly. We should also test things like
        # selective commits which excludes newly added files.
        tree = self.make_branch_and_tree('.')
        self.build_tree(['a', 'b', 'c'])
        tree.add(['a', 'b', 'c'])
        tree.commit('first post')
        with open('b', 'w') as f:
            f.write('new content')
        # set a progress bar that captures the calls so we can see what is
        # emitted
        factory = ProgressRecordingUIFactory()
        ui.ui_factory = factory
        # TODO RBC 20060421 it would be nice to merge the reporter output
        # into the factory for this test - just make the test ui factory
        # pun as a reporter. Then we can check the ordering is right.
        tree.commit('second post', specific_files=['b'])
        # 5 steps, the first of which is reported 2 times, once per dir
        self.assertEqual(
            [('update', 1, 5, 'Collecting changes [0] - Stage'),
             ('update', 1, 5, 'Collecting changes [1] - Stage'),
             ('update', 2, 5, 'Saving data locally - Stage'),
             ('update', 3, 5, 'Running pre_commit hooks - Stage'),
             ('update', 4, 5, 'Updating the working tree - Stage'),
             ('update', 5, 5, 'Running post_commit hooks - Stage')],
            factory._calls
            )

    def test_commit_progress_shows_post_hook_names(self):
        tree = self.make_branch_and_tree('.')
        # set a progress bar that captures the calls so we can see what is
        # emitted
        factory = ProgressRecordingUIFactory()
        ui.ui_factory = factory

        def a_hook(_, _2, _3, _4, _5, _6):
            pass
        branch.Branch.hooks.install_named_hook('post_commit', a_hook,
                                               'hook name')
        tree.commit('first post')
        self.assertEqual(
            [('update', 1, 5, 'Collecting changes [0] - Stage'),
             ('update', 1, 5, 'Collecting changes [1] - Stage'),
             ('update', 2, 5, 'Saving data locally - Stage'),
             ('update', 3, 5, 'Running pre_commit hooks - Stage'),
             ('update', 4, 5, 'Updating the working tree - Stage'),
             ('update', 5, 5, 'Running post_commit hooks - Stage'),
             ('update', 5, 5, 'Running post_commit hooks [hook name] - Stage'),
             ],
            factory._calls
            )

    def test_commit_progress_shows_pre_hook_names(self):
        tree = self.make_branch_and_tree('.')
        # set a progress bar that captures the calls so we can see what is
        # emitted
        factory = ProgressRecordingUIFactory()
        ui.ui_factory = factory

        def a_hook(_, _2, _3, _4, _5, _6, _7, _8):
            pass
        branch.Branch.hooks.install_named_hook('pre_commit', a_hook,
                                               'hook name')
        tree.commit('first post')
        self.assertEqual(
            [('update', 1, 5, 'Collecting changes [0] - Stage'),
             ('update', 1, 5, 'Collecting changes [1] - Stage'),
             ('update', 2, 5, 'Saving data locally - Stage'),
             ('update', 3, 5, 'Running pre_commit hooks - Stage'),
             ('update', 3, 5, 'Running pre_commit hooks [hook name] - Stage'),
             ('update', 4, 5, 'Updating the working tree - Stage'),
             ('update', 5, 5, 'Running post_commit hooks - Stage'),
             ],
            factory._calls
            )

    def test_start_commit_hook(self):
        """Make sure a start commit hook can modify the tree that is
        committed."""
        def start_commit_hook_adds_file(tree):
            with open(tree.abspath("newfile"), 'w') as f:
                f.write("data")
            tree.add(["newfile"])

        def restoreDefaults():
            mutabletree.MutableTree.hooks['start_commit'] = []
        self.addCleanup(restoreDefaults)
        tree = self.make_branch_and_tree('.')
        mutabletree.MutableTree.hooks.install_named_hook(
            'start_commit',
            start_commit_hook_adds_file,
            None)
        revid = tree.commit('first post')
        committed_tree = tree.basis_tree()
        self.assertTrue(committed_tree.has_filename("newfile"))

    def test_post_commit_hook(self):
        """Make sure a post_commit hook is called after a commit."""
        def post_commit_hook_test_params(params):
            self.assertTrue(isinstance(params,
                                       mutabletree.PostCommitHookParams))
            self.assertTrue(isinstance(params.mutable_tree,
                                       mutabletree.MutableTree))
            with open(tree.abspath("newfile"), 'w') as f:
                f.write("data")
            params.mutable_tree.add(["newfile"])
        tree = self.make_branch_and_tree('.')
        mutabletree.MutableTree.hooks.install_named_hook(
            'post_commit',
            post_commit_hook_test_params,
            None)
        self.assertFalse(tree.has_filename("newfile"))
        revid = tree.commit('first post')
        self.assertTrue(tree.has_filename("newfile"))
        committed_tree = tree.basis_tree()
        self.assertFalse(committed_tree.has_filename("newfile"))
