1
# Copyright (C) 2005, 2006 Canonical Ltd
 
 
3
# This program is free software; you can redistribute it and/or modify
 
 
4
# it under the terms of the GNU General Public License as published by
 
 
5
# the Free Software Foundation; either version 2 of the License, or
 
 
6
# (at your option) any later version.
 
 
8
# This program is distributed in the hope that it will be useful,
 
 
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
 
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
 
11
# GNU General Public License for more details.
 
 
13
# You should have received a copy of the GNU General Public License
 
 
14
# along with this program; if not, write to the Free Software
 
 
15
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 
 
25
from bzrlib.branch import Branch
 
 
26
from bzrlib.bzrdir import BzrDir, BzrDirMetaFormat1
 
 
27
from bzrlib.commit import Commit, NullCommitReporter
 
 
28
from bzrlib.config import BranchConfig
 
 
29
from bzrlib.errors import (PointlessCommit, BzrError, SigningFailed, 
 
 
31
from bzrlib.tests import TestCaseWithTransport
 
 
32
from bzrlib.workingtree import WorkingTree
 
 
35
# TODO: Test commit with some added, and added-but-missing files
 
 
37
class MustSignConfig(BranchConfig):
 
 
39
    def signature_needed(self):
 
 
42
    def gpg_signing_command(self):
 
 
46
class BranchWithHooks(BranchConfig):
 
 
48
    def post_commit(self):
 
 
49
        return "bzrlib.ahook bzrlib.ahook"
 
 
52
class CapturingReporter(NullCommitReporter):
 
 
53
    """This reporter captures the calls made to it for evaluation later."""
 
 
56
        # a list of the calls this received
 
 
59
    def snapshot_change(self, change, path):
 
 
60
        self.calls.append(('change', change, path))
 
 
62
    def deleted(self, file_id):
 
 
63
        self.calls.append(('deleted', file_id))
 
 
65
    def missing(self, path):
 
 
66
        self.calls.append(('missing', path))
 
 
68
    def renamed(self, change, old_path, new_path):
 
 
69
        self.calls.append(('renamed', change, old_path, new_path))
 
 
72
class TestCommit(TestCaseWithTransport):
 
 
74
    def test_simple_commit(self):
 
 
75
        """Commit and check two versions of a single file."""
 
 
76
        wt = self.make_branch_and_tree('.')
 
 
78
        file('hello', 'w').write('hello world')
 
 
80
        wt.commit(message='add hello')
 
 
81
        file_id = wt.path2id('hello')
 
 
83
        file('hello', 'w').write('version 2')
 
 
84
        wt.commit(message='commit 2')
 
 
86
        eq = self.assertEquals
 
 
88
        rh = b.revision_history()
 
 
89
        rev = b.repository.get_revision(rh[0])
 
 
90
        eq(rev.message, 'add hello')
 
 
92
        tree1 = b.repository.revision_tree(rh[0])
 
 
93
        text = tree1.get_file_text(file_id)
 
 
94
        eq(text, 'hello world')
 
 
96
        tree2 = b.repository.revision_tree(rh[1])
 
 
97
        eq(tree2.get_file_text(file_id), 'version 2')
 
 
99
    def test_delete_commit(self):
 
 
100
        """Test a commit with a deleted file"""
 
 
101
        wt = self.make_branch_and_tree('.')
 
 
103
        file('hello', 'w').write('hello world')
 
 
104
        wt.add(['hello'], ['hello-id'])
 
 
105
        wt.commit(message='add hello')
 
 
108
        wt.commit('removed hello', rev_id='rev2')
 
 
110
        tree = b.repository.revision_tree('rev2')
 
 
111
        self.assertFalse(tree.has_id('hello-id'))
 
 
113
    def test_pointless_commit(self):
 
 
114
        """Commit refuses unless there are changes or it's forced."""
 
 
115
        wt = self.make_branch_and_tree('.')
 
 
117
        file('hello', 'w').write('hello')
 
 
119
        wt.commit(message='add hello')
 
 
120
        self.assertEquals(b.revno(), 1)
 
 
121
        self.assertRaises(PointlessCommit,
 
 
124
                          allow_pointless=False)
 
 
125
        self.assertEquals(b.revno(), 1)
 
 
127
    def test_commit_empty(self):
 
 
128
        """Commiting an empty tree works."""
 
 
129
        wt = self.make_branch_and_tree('.')
 
 
131
        wt.commit(message='empty tree', allow_pointless=True)
 
 
132
        self.assertRaises(PointlessCommit,
 
 
134
                          message='empty tree',
 
 
135
                          allow_pointless=False)
 
 
136
        wt.commit(message='empty tree', allow_pointless=True)
 
 
137
        self.assertEquals(b.revno(), 2)
 
 
139
    def test_selective_delete(self):
 
 
140
        """Selective commit in tree with deletions"""
 
 
141
        wt = self.make_branch_and_tree('.')
 
 
143
        file('hello', 'w').write('hello')
 
 
144
        file('buongia', 'w').write('buongia')
 
 
145
        wt.add(['hello', 'buongia'],
 
 
146
              ['hello-id', 'buongia-id'])
 
 
147
        wt.commit(message='add files',
 
 
151
        file('buongia', 'w').write('new text')
 
 
152
        wt.commit(message='update text',
 
 
153
                 specific_files=['buongia'],
 
 
154
                 allow_pointless=False,
 
 
157
        wt.commit(message='remove hello',
 
 
158
                 specific_files=['hello'],
 
 
159
                 allow_pointless=False,
 
 
162
        eq = self.assertEquals
 
 
165
        tree2 = b.repository.revision_tree('test@rev-2')
 
 
166
        self.assertTrue(tree2.has_filename('hello'))
 
 
167
        self.assertEquals(tree2.get_file_text('hello-id'), 'hello')
 
 
168
        self.assertEquals(tree2.get_file_text('buongia-id'), 'new text')
 
 
170
        tree3 = b.repository.revision_tree('test@rev-3')
 
 
171
        self.assertFalse(tree3.has_filename('hello'))
 
 
172
        self.assertEquals(tree3.get_file_text('buongia-id'), 'new text')
 
 
174
    def test_commit_rename(self):
 
 
175
        """Test commit of a revision where a file is renamed."""
 
 
176
        tree = self.make_branch_and_tree('.')
 
 
178
        self.build_tree(['hello'], line_endings='binary')
 
 
179
        tree.add(['hello'], ['hello-id'])
 
 
180
        tree.commit(message='one', rev_id='test@rev-1', allow_pointless=False)
 
 
182
        tree.rename_one('hello', 'fruity')
 
 
183
        tree.commit(message='renamed', rev_id='test@rev-2', allow_pointless=False)
 
 
185
        eq = self.assertEquals
 
 
186
        tree1 = b.repository.revision_tree('test@rev-1')
 
 
187
        eq(tree1.id2path('hello-id'), 'hello')
 
 
188
        eq(tree1.get_file_text('hello-id'), 'contents of hello\n')
 
 
189
        self.assertFalse(tree1.has_filename('fruity'))
 
 
190
        self.check_inventory_shape(tree1.inventory, ['hello'])
 
 
191
        ie = tree1.inventory['hello-id']
 
 
192
        eq(ie.revision, 'test@rev-1')
 
 
194
        tree2 = b.repository.revision_tree('test@rev-2')
 
 
195
        eq(tree2.id2path('hello-id'), 'fruity')
 
 
196
        eq(tree2.get_file_text('hello-id'), 'contents of hello\n')
 
 
197
        self.check_inventory_shape(tree2.inventory, ['fruity'])
 
 
198
        ie = tree2.inventory['hello-id']
 
 
199
        eq(ie.revision, 'test@rev-2')
 
 
201
    def test_reused_rev_id(self):
 
 
202
        """Test that a revision id cannot be reused in a branch"""
 
 
203
        wt = self.make_branch_and_tree('.')
 
 
205
        wt.commit('initial', rev_id='test@rev-1', allow_pointless=True)
 
 
206
        self.assertRaises(Exception,
 
 
210
                          allow_pointless=True)
 
 
212
    def test_commit_move(self):
 
 
213
        """Test commit of revisions with moved files and directories"""
 
 
214
        eq = self.assertEquals
 
 
215
        wt = self.make_branch_and_tree('.')
 
 
218
        self.build_tree(['hello', 'a/', 'b/'])
 
 
219
        wt.add(['hello', 'a', 'b'], ['hello-id', 'a-id', 'b-id'])
 
 
220
        wt.commit('initial', rev_id=r1, allow_pointless=False)
 
 
221
        wt.move(['hello'], 'a')
 
 
223
        wt.commit('two', rev_id=r2, allow_pointless=False)
 
 
224
        self.check_inventory_shape(wt.read_working_inventory(),
 
 
225
                                   ['a', 'a/hello', 'b'])
 
 
229
        wt.commit('three', rev_id=r3, allow_pointless=False)
 
 
230
        self.check_inventory_shape(wt.read_working_inventory(),
 
 
231
                                   ['a', 'a/hello', 'a/b'])
 
 
232
        self.check_inventory_shape(b.repository.get_revision_inventory(r3),
 
 
233
                                   ['a', 'a/hello', 'a/b'])
 
 
235
        wt.move(['a/hello'], 'a/b')
 
 
237
        wt.commit('four', rev_id=r4, allow_pointless=False)
 
 
238
        self.check_inventory_shape(wt.read_working_inventory(),
 
 
239
                                   ['a', 'a/b/hello', 'a/b'])
 
 
241
        inv = b.repository.get_revision_inventory(r4)
 
 
242
        eq(inv['hello-id'].revision, r4)
 
 
243
        eq(inv['a-id'].revision, r1)
 
 
244
        eq(inv['b-id'].revision, r3)
 
 
246
    def test_removed_commit(self):
 
 
247
        """Commit with a removed file"""
 
 
248
        wt = self.make_branch_and_tree('.')
 
 
250
        file('hello', 'w').write('hello world')
 
 
251
        wt.add(['hello'], ['hello-id'])
 
 
252
        wt.commit(message='add hello')
 
 
254
        wt.commit('removed hello', rev_id='rev2')
 
 
256
        tree = b.repository.revision_tree('rev2')
 
 
257
        self.assertFalse(tree.has_id('hello-id'))
 
 
259
    def test_committed_ancestry(self):
 
 
260
        """Test commit appends revisions to ancestry."""
 
 
261
        wt = self.make_branch_and_tree('.')
 
 
265
            file('hello', 'w').write((str(i) * 4) + '\n')
 
 
267
                wt.add(['hello'], ['hello-id'])
 
 
268
            rev_id = 'test@rev-%d' % (i+1)
 
 
269
            rev_ids.append(rev_id)
 
 
270
            wt.commit(message='rev %d' % (i+1),
 
 
272
        eq = self.assertEquals
 
 
273
        eq(b.revision_history(), rev_ids)
 
 
275
            anc = b.repository.get_ancestry(rev_ids[i])
 
 
276
            eq(anc, [None] + rev_ids[:i+1])
 
 
278
    def test_commit_new_subdir_child_selective(self):
 
 
279
        wt = self.make_branch_and_tree('.')
 
 
281
        self.build_tree(['dir/', 'dir/file1', 'dir/file2'])
 
 
282
        wt.add(['dir', 'dir/file1', 'dir/file2'],
 
 
283
              ['dirid', 'file1id', 'file2id'])
 
 
284
        wt.commit('dir/file1', specific_files=['dir/file1'], rev_id='1')
 
 
285
        inv = b.repository.get_inventory('1')
 
 
286
        self.assertEqual('1', inv['dirid'].revision)
 
 
287
        self.assertEqual('1', inv['file1id'].revision)
 
 
288
        # FIXME: This should raise a KeyError I think, rbc20051006
 
 
289
        self.assertRaises(BzrError, inv.__getitem__, 'file2id')
 
 
291
    def test_strict_commit(self):
 
 
292
        """Try and commit with unknown files and strict = True, should fail."""
 
 
293
        from bzrlib.errors import StrictCommitFailed
 
 
294
        wt = self.make_branch_and_tree('.')
 
 
296
        file('hello', 'w').write('hello world')
 
 
298
        file('goodbye', 'w').write('goodbye cruel world!')
 
 
299
        self.assertRaises(StrictCommitFailed, wt.commit,
 
 
300
            message='add hello but not goodbye', strict=True)
 
 
302
    def test_strict_commit_without_unknowns(self):
 
 
303
        """Try and commit with no unknown files and strict = True,
 
 
305
        from bzrlib.errors import StrictCommitFailed
 
 
306
        wt = self.make_branch_and_tree('.')
 
 
308
        file('hello', 'w').write('hello world')
 
 
310
        wt.commit(message='add hello', strict=True)
 
 
312
    def test_nonstrict_commit(self):
 
 
313
        """Try and commit with unknown files and strict = False, should work."""
 
 
314
        wt = self.make_branch_and_tree('.')
 
 
316
        file('hello', 'w').write('hello world')
 
 
318
        file('goodbye', 'w').write('goodbye cruel world!')
 
 
319
        wt.commit(message='add hello but not goodbye', strict=False)
 
 
321
    def test_nonstrict_commit_without_unknowns(self):
 
 
322
        """Try and commit with no unknown files and strict = False,
 
 
324
        wt = self.make_branch_and_tree('.')
 
 
326
        file('hello', 'w').write('hello world')
 
 
328
        wt.commit(message='add hello', strict=False)
 
 
330
    def test_signed_commit(self):
 
 
332
        import bzrlib.commit as commit
 
 
333
        oldstrategy = bzrlib.gpg.GPGStrategy
 
 
334
        wt = self.make_branch_and_tree('.')
 
 
336
        wt.commit("base", allow_pointless=True, rev_id='A')
 
 
337
        self.failIf(branch.repository.has_signature_for_revision_id('A'))
 
 
339
            from bzrlib.testament import Testament
 
 
340
            # monkey patch gpg signing mechanism
 
 
341
            bzrlib.gpg.GPGStrategy = bzrlib.gpg.LoopbackGPGStrategy
 
 
342
            commit.Commit(config=MustSignConfig(branch)).commit(message="base",
 
 
343
                                                      allow_pointless=True,
 
 
346
            self.assertEqual(Testament.from_revision(branch.repository,
 
 
347
                             'B').as_short_text(),
 
 
348
                             branch.repository.get_signature_text('B'))
 
 
350
            bzrlib.gpg.GPGStrategy = oldstrategy
 
 
352
    def test_commit_failed_signature(self):
 
 
354
        import bzrlib.commit as commit
 
 
355
        oldstrategy = bzrlib.gpg.GPGStrategy
 
 
356
        wt = self.make_branch_and_tree('.')
 
 
358
        wt.commit("base", allow_pointless=True, rev_id='A')
 
 
359
        self.failIf(branch.repository.has_signature_for_revision_id('A'))
 
 
361
            from bzrlib.testament import Testament
 
 
362
            # monkey patch gpg signing mechanism
 
 
363
            bzrlib.gpg.GPGStrategy = bzrlib.gpg.DisabledGPGStrategy
 
 
364
            config = MustSignConfig(branch)
 
 
365
            self.assertRaises(SigningFailed,
 
 
366
                              commit.Commit(config=config).commit,
 
 
368
                              allow_pointless=True,
 
 
371
            branch = Branch.open(self.get_url('.'))
 
 
372
            self.assertEqual(branch.revision_history(), ['A'])
 
 
373
            self.failIf(branch.repository.has_revision('B'))
 
 
375
            bzrlib.gpg.GPGStrategy = oldstrategy
 
 
377
    def test_commit_invokes_hooks(self):
 
 
378
        import bzrlib.commit as commit
 
 
379
        wt = self.make_branch_and_tree('.')
 
 
382
        def called(branch, rev_id):
 
 
383
            calls.append('called')
 
 
384
        bzrlib.ahook = called
 
 
386
            config = BranchWithHooks(branch)
 
 
387
            commit.Commit(config=config).commit(
 
 
389
                            allow_pointless=True,
 
 
390
                            rev_id='A', working_tree = wt)
 
 
391
            self.assertEqual(['called', 'called'], calls)
 
 
395
    def test_commit_object_doesnt_set_nick(self):
 
 
396
        # using the Commit object directly does not set the branch nick.
 
 
397
        wt = self.make_branch_and_tree('.')
 
 
399
        c.commit(working_tree=wt, message='empty tree', allow_pointless=True)
 
 
400
        self.assertEquals(wt.branch.revno(), 1)
 
 
402
                         wt.branch.repository.get_revision(
 
 
403
                            wt.branch.last_revision()).properties)
 
 
405
    def test_safe_master_lock(self):
 
 
407
        master = BzrDirMetaFormat1().initialize('master')
 
 
408
        master.create_repository()
 
 
409
        master_branch = master.create_branch()
 
 
410
        master.create_workingtree()
 
 
411
        bound = master.sprout('bound')
 
 
412
        wt = bound.open_workingtree()
 
 
413
        wt.branch.set_bound_location(os.path.realpath('master'))
 
 
415
        orig_default = lockdir._DEFAULT_TIMEOUT_SECONDS
 
 
416
        master_branch.lock_write()
 
 
418
            lockdir._DEFAULT_TIMEOUT_SECONDS = 1
 
 
419
            self.assertRaises(LockContention, wt.commit, 'silly')
 
 
421
            lockdir._DEFAULT_TIMEOUT_SECONDS = orig_default
 
 
422
            master_branch.unlock()
 
 
424
    def test_commit_bound_merge(self):
 
 
425
        # see bug #43959; commit of a merge in a bound branch fails to push
 
 
426
        # the new commit into the master
 
 
427
        master_branch = self.make_branch('master')
 
 
428
        bound_tree = self.make_branch_and_tree('bound')
 
 
429
        bound_tree.branch.bind(master_branch)
 
 
431
        self.build_tree_contents([('bound/content_file', 'initial contents\n')])
 
 
432
        bound_tree.add(['content_file'])
 
 
433
        bound_tree.commit(message='woo!')
 
 
435
        other_bzrdir = master_branch.bzrdir.sprout('other')
 
 
436
        other_tree = other_bzrdir.open_workingtree()
 
 
438
        # do a commit to the the other branch changing the content file so
 
 
439
        # that our commit after merging will have a merged revision in the
 
 
440
        # content file history.
 
 
441
        self.build_tree_contents([('other/content_file', 'change in other\n')])
 
 
442
        other_tree.commit('change in other')
 
 
444
        # do a merge into the bound branch from other, and then change the
 
 
445
        # content file locally to force a new revision (rather than using the
 
 
446
        # revision from other). This forces extra processing in commit.
 
 
447
        bound_tree.merge_from_branch(other_tree.branch)
 
 
448
        self.build_tree_contents([('bound/content_file', 'change in bound\n')])
 
 
450
        # before #34959 was fixed, this failed with 'revision not present in
 
 
451
        # weave' when trying to implicitly push from the bound branch to the master
 
 
452
        bound_tree.commit(message='commit of merge in bound tree')
 
 
454
    def test_commit_reporting_after_merge(self):
 
 
455
        # when doing a commit of a merge, the reporter needs to still 
 
 
456
        # be called for each item that is added/removed/deleted.
 
 
457
        this_tree = self.make_branch_and_tree('this')
 
 
458
        # we need a bunch of files and dirs, to perform one action on each.
 
 
461
            'this/dirtoreparent/',
 
 
464
            'this/filetoreparent',
 
 
481
        this_tree.commit('create_files')
 
 
482
        other_dir = this_tree.bzrdir.sprout('other')
 
 
483
        other_tree = other_dir.open_workingtree()
 
 
484
        other_tree.lock_write()
 
 
485
        # perform the needed actions on the files and dirs.
 
 
487
            other_tree.rename_one('dirtorename', 'renameddir')
 
 
488
            other_tree.rename_one('dirtoreparent', 'renameddir/reparenteddir')
 
 
489
            other_tree.rename_one('filetorename', 'renamedfile')
 
 
490
            other_tree.rename_one('filetoreparent', 'renameddir/reparentedfile')
 
 
491
            other_tree.remove(['dirtoremove', 'filetoremove'])
 
 
492
            self.build_tree_contents([
 
 
494
                ('other/filetomodify', 'new content'),
 
 
495
                ('other/newfile', 'new file content')])
 
 
496
            other_tree.add('newfile')
 
 
497
            other_tree.add('newdir/')
 
 
498
            other_tree.commit('modify all sample files and dirs.')
 
 
501
        this_tree.merge_from_branch(other_tree.branch)
 
 
502
        reporter = CapturingReporter()
 
 
503
        this_tree.commit('do the commit', reporter=reporter)
 
 
505
            ('change', 'unchanged', ''),
 
 
506
            ('change', 'unchanged', 'dirtoleave'),
 
 
507
            ('change', 'unchanged', 'filetoleave'),
 
 
508
            ('change', 'modified', 'filetomodify'),
 
 
509
            ('change', 'added', 'newdir'),
 
 
510
            ('change', 'added', 'newfile'),
 
 
511
            ('renamed', 'renamed', 'dirtorename', 'renameddir'),
 
 
512
            ('renamed', 'renamed', 'dirtoreparent', 'renameddir/reparenteddir'),
 
 
513
            ('renamed', 'renamed', 'filetoreparent', 'renameddir/reparentedfile'),
 
 
514
            ('renamed', 'renamed', 'filetorename', 'renamedfile'),
 
 
515
            ('deleted', 'dirtoremove'),
 
 
516
            ('deleted', 'filetoremove'),
 
 
520
    def test_commit_removals_respects_filespec(self):
 
 
521
        """Commit respects the specified_files for removals."""
 
 
522
        tree = self.make_branch_and_tree('.')
 
 
523
        self.build_tree(['a', 'b'])
 
 
525
        tree.commit('added a, b')
 
 
526
        tree.remove(['a', 'b'])
 
 
527
        tree.commit('removed a', specific_files='a')
 
 
528
        basis = tree.basis_tree().inventory
 
 
529
        self.assertIs(None, basis.path2id('a'))
 
 
530
        self.assertFalse(basis.path2id('b') is None)
 
 
532
    def test_commit_saves_1ms_timestamp(self):
 
 
533
        """Passing in a timestamp is saved with 1ms resolution"""
 
 
534
        tree = self.make_branch_and_tree('.')
 
 
535
        self.build_tree(['a'])
 
 
537
        tree.commit('added a', timestamp=1153248633.4186721, timezone=0,
 
 
540
        rev = tree.branch.repository.get_revision('a1')
 
 
541
        self.assertEqual(1153248633.419, rev.timestamp)
 
 
543
    def test_commit_has_1ms_resolution(self):
 
 
544
        """Allowing commit to generate the timestamp also has 1ms resolution"""
 
 
545
        tree = self.make_branch_and_tree('.')
 
 
546
        self.build_tree(['a'])
 
 
548
        tree.commit('added a', rev_id='a1')
 
 
550
        rev = tree.branch.repository.get_revision('a1')
 
 
551
        timestamp = rev.timestamp
 
 
552
        timestamp_1ms = round(timestamp, 3)
 
 
553
        self.assertEqual(timestamp_1ms, timestamp)
 
 
555
    def test_commit_unversioned_specified(self):
 
 
556
        """Commit should raise if specified files isn't in basis or worktree"""
 
 
557
        tree = self.make_branch_and_tree('.')
 
 
558
        self.assertRaises(errors.PathsNotVersionedError, tree.commit, 
 
 
559
                          'message', specific_files=['bogus'])