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
27
from bzrlib.branch import Branch
28
from bzrlib.bzrdir import BzrDir, BzrDirMetaFormat1
29
from bzrlib.commit import Commit, NullCommitReporter
30
from bzrlib.config import BranchConfig
31
from bzrlib.errors import (PointlessCommit, BzrError, SigningFailed,
33
from bzrlib.tests import SymlinkFeature, TestCaseWithTransport
34
from bzrlib.workingtree import WorkingTree
37
# TODO: Test commit with some added, and added-but-missing files
39
class MustSignConfig(BranchConfig):
41
def signature_needed(self):
44
def gpg_signing_command(self):
48
class BranchWithHooks(BranchConfig):
50
def post_commit(self):
51
return "bzrlib.ahook bzrlib.ahook"
54
class CapturingReporter(NullCommitReporter):
55
"""This reporter captures the calls made to it for evaluation later."""
58
# a list of the calls this received
61
def snapshot_change(self, change, path):
62
self.calls.append(('change', change, path))
64
def deleted(self, file_id):
65
self.calls.append(('deleted', file_id))
67
def missing(self, path):
68
self.calls.append(('missing', path))
70
def renamed(self, change, old_path, new_path):
71
self.calls.append(('renamed', change, old_path, new_path))
77
class TestCommit(TestCaseWithTransport):
79
def test_simple_commit(self):
80
"""Commit and check two versions of a single file."""
81
wt = self.make_branch_and_tree('.')
83
file('hello', 'w').write('hello world')
85
wt.commit(message='add hello')
86
file_id = wt.path2id('hello')
88
file('hello', 'w').write('version 2')
89
wt.commit(message='commit 2')
91
eq = self.assertEquals
93
rh = b.revision_history()
94
rev = b.repository.get_revision(rh[0])
95
eq(rev.message, 'add hello')
97
tree1 = b.repository.revision_tree(rh[0])
99
text = tree1.get_file_text(file_id)
101
self.assertEqual('hello world', text)
103
tree2 = b.repository.revision_tree(rh[1])
105
text = tree2.get_file_text(file_id)
107
self.assertEqual('version 2', text)
109
def test_delete_commit(self):
110
"""Test a commit with a deleted file"""
111
wt = self.make_branch_and_tree('.')
113
file('hello', 'w').write('hello world')
114
wt.add(['hello'], ['hello-id'])
115
wt.commit(message='add hello')
118
wt.commit('removed hello', rev_id='rev2')
120
tree = b.repository.revision_tree('rev2')
121
self.assertFalse(tree.has_id('hello-id'))
123
def test_pointless_commit(self):
124
"""Commit refuses unless there are changes or it's forced."""
125
wt = self.make_branch_and_tree('.')
127
file('hello', 'w').write('hello')
129
wt.commit(message='add hello')
130
self.assertEquals(b.revno(), 1)
131
self.assertRaises(PointlessCommit,
134
allow_pointless=False)
135
self.assertEquals(b.revno(), 1)
137
def test_commit_empty(self):
138
"""Commiting an empty tree works."""
139
wt = self.make_branch_and_tree('.')
141
wt.commit(message='empty tree', allow_pointless=True)
142
self.assertRaises(PointlessCommit,
144
message='empty tree',
145
allow_pointless=False)
146
wt.commit(message='empty tree', allow_pointless=True)
147
self.assertEquals(b.revno(), 2)
149
def test_selective_delete(self):
150
"""Selective commit in tree with deletions"""
151
wt = self.make_branch_and_tree('.')
153
file('hello', 'w').write('hello')
154
file('buongia', 'w').write('buongia')
155
wt.add(['hello', 'buongia'],
156
['hello-id', 'buongia-id'])
157
wt.commit(message='add files',
161
file('buongia', 'w').write('new text')
162
wt.commit(message='update text',
163
specific_files=['buongia'],
164
allow_pointless=False,
167
wt.commit(message='remove hello',
168
specific_files=['hello'],
169
allow_pointless=False,
172
eq = self.assertEquals
175
tree2 = b.repository.revision_tree('test@rev-2')
177
self.addCleanup(tree2.unlock)
178
self.assertTrue(tree2.has_filename('hello'))
179
self.assertEquals(tree2.get_file_text('hello-id'), 'hello')
180
self.assertEquals(tree2.get_file_text('buongia-id'), 'new text')
182
tree3 = b.repository.revision_tree('test@rev-3')
184
self.addCleanup(tree3.unlock)
185
self.assertFalse(tree3.has_filename('hello'))
186
self.assertEquals(tree3.get_file_text('buongia-id'), 'new text')
188
def test_commit_rename(self):
189
"""Test commit of a revision where a file is renamed."""
190
tree = self.make_branch_and_tree('.')
192
self.build_tree(['hello'], line_endings='binary')
193
tree.add(['hello'], ['hello-id'])
194
tree.commit(message='one', rev_id='test@rev-1', allow_pointless=False)
196
tree.rename_one('hello', 'fruity')
197
tree.commit(message='renamed', rev_id='test@rev-2', allow_pointless=False)
199
eq = self.assertEquals
200
tree1 = b.repository.revision_tree('test@rev-1')
202
self.addCleanup(tree1.unlock)
203
eq(tree1.id2path('hello-id'), 'hello')
204
eq(tree1.get_file_text('hello-id'), 'contents of hello\n')
205
self.assertFalse(tree1.has_filename('fruity'))
206
self.check_inventory_shape(tree1.inventory, ['hello'])
207
ie = tree1.inventory['hello-id']
208
eq(ie.revision, 'test@rev-1')
210
tree2 = b.repository.revision_tree('test@rev-2')
212
self.addCleanup(tree2.unlock)
213
eq(tree2.id2path('hello-id'), 'fruity')
214
eq(tree2.get_file_text('hello-id'), 'contents of hello\n')
215
self.check_inventory_shape(tree2.inventory, ['fruity'])
216
ie = tree2.inventory['hello-id']
217
eq(ie.revision, 'test@rev-2')
219
def test_reused_rev_id(self):
220
"""Test that a revision id cannot be reused in a branch"""
221
wt = self.make_branch_and_tree('.')
223
wt.commit('initial', rev_id='test@rev-1', allow_pointless=True)
224
self.assertRaises(Exception,
228
allow_pointless=True)
230
def test_commit_move(self):
231
"""Test commit of revisions with moved files and directories"""
232
eq = self.assertEquals
233
wt = self.make_branch_and_tree('.')
236
self.build_tree(['hello', 'a/', 'b/'])
237
wt.add(['hello', 'a', 'b'], ['hello-id', 'a-id', 'b-id'])
238
wt.commit('initial', rev_id=r1, allow_pointless=False)
239
wt.move(['hello'], 'a')
241
wt.commit('two', rev_id=r2, allow_pointless=False)
244
self.check_inventory_shape(wt.read_working_inventory(),
245
['a/', 'a/hello', 'b/'])
251
wt.commit('three', rev_id=r3, allow_pointless=False)
254
self.check_inventory_shape(wt.read_working_inventory(),
255
['a/', 'a/hello', 'a/b/'])
256
self.check_inventory_shape(b.repository.get_revision_inventory(r3),
257
['a/', 'a/hello', 'a/b/'])
261
wt.move(['a/hello'], 'a/b')
263
wt.commit('four', rev_id=r4, allow_pointless=False)
266
self.check_inventory_shape(wt.read_working_inventory(),
267
['a/', 'a/b/hello', 'a/b/'])
271
inv = b.repository.get_revision_inventory(r4)
272
eq(inv['hello-id'].revision, r4)
273
eq(inv['a-id'].revision, r1)
274
eq(inv['b-id'].revision, r3)
276
def test_removed_commit(self):
277
"""Commit with a removed file"""
278
wt = self.make_branch_and_tree('.')
280
file('hello', 'w').write('hello world')
281
wt.add(['hello'], ['hello-id'])
282
wt.commit(message='add hello')
284
wt.commit('removed hello', rev_id='rev2')
286
tree = b.repository.revision_tree('rev2')
287
self.assertFalse(tree.has_id('hello-id'))
289
def test_committed_ancestry(self):
290
"""Test commit appends revisions to ancestry."""
291
wt = self.make_branch_and_tree('.')
295
file('hello', 'w').write((str(i) * 4) + '\n')
297
wt.add(['hello'], ['hello-id'])
298
rev_id = 'test@rev-%d' % (i+1)
299
rev_ids.append(rev_id)
300
wt.commit(message='rev %d' % (i+1),
302
eq = self.assertEquals
303
eq(b.revision_history(), rev_ids)
305
anc = b.repository.get_ancestry(rev_ids[i])
306
eq(anc, [None] + rev_ids[:i+1])
308
def test_commit_new_subdir_child_selective(self):
309
wt = self.make_branch_and_tree('.')
311
self.build_tree(['dir/', 'dir/file1', 'dir/file2'])
312
wt.add(['dir', 'dir/file1', 'dir/file2'],
313
['dirid', 'file1id', 'file2id'])
314
wt.commit('dir/file1', specific_files=['dir/file1'], rev_id='1')
315
inv = b.repository.get_inventory('1')
316
self.assertEqual('1', inv['dirid'].revision)
317
self.assertEqual('1', inv['file1id'].revision)
318
# FIXME: This should raise a KeyError I think, rbc20051006
319
self.assertRaises(BzrError, inv.__getitem__, 'file2id')
321
def test_strict_commit(self):
322
"""Try and commit with unknown files and strict = True, should fail."""
323
from bzrlib.errors import StrictCommitFailed
324
wt = self.make_branch_and_tree('.')
326
file('hello', 'w').write('hello world')
328
file('goodbye', 'w').write('goodbye cruel world!')
329
self.assertRaises(StrictCommitFailed, wt.commit,
330
message='add hello but not goodbye', strict=True)
332
def test_strict_commit_without_unknowns(self):
333
"""Try and commit with no unknown files and strict = True,
335
from bzrlib.errors import StrictCommitFailed
336
wt = self.make_branch_and_tree('.')
338
file('hello', 'w').write('hello world')
340
wt.commit(message='add hello', strict=True)
342
def test_nonstrict_commit(self):
343
"""Try and commit with unknown files and strict = False, should work."""
344
wt = self.make_branch_and_tree('.')
346
file('hello', 'w').write('hello world')
348
file('goodbye', 'w').write('goodbye cruel world!')
349
wt.commit(message='add hello but not goodbye', strict=False)
351
def test_nonstrict_commit_without_unknowns(self):
352
"""Try and commit with no unknown files and strict = False,
354
wt = self.make_branch_and_tree('.')
356
file('hello', 'w').write('hello world')
358
wt.commit(message='add hello', strict=False)
360
def test_signed_commit(self):
362
import bzrlib.commit as commit
363
oldstrategy = bzrlib.gpg.GPGStrategy
364
wt = self.make_branch_and_tree('.')
366
wt.commit("base", allow_pointless=True, rev_id='A')
367
self.failIf(branch.repository.has_signature_for_revision_id('A'))
369
from bzrlib.testament import Testament
370
# monkey patch gpg signing mechanism
371
bzrlib.gpg.GPGStrategy = bzrlib.gpg.LoopbackGPGStrategy
372
commit.Commit(config=MustSignConfig(branch)).commit(message="base",
373
allow_pointless=True,
377
return bzrlib.gpg.LoopbackGPGStrategy(None).sign(text)
378
self.assertEqual(sign(Testament.from_revision(branch.repository,
379
'B').as_short_text()),
380
branch.repository.get_signature_text('B'))
382
bzrlib.gpg.GPGStrategy = oldstrategy
384
def test_commit_failed_signature(self):
386
import bzrlib.commit as commit
387
oldstrategy = bzrlib.gpg.GPGStrategy
388
wt = self.make_branch_and_tree('.')
390
wt.commit("base", allow_pointless=True, rev_id='A')
391
self.failIf(branch.repository.has_signature_for_revision_id('A'))
393
from bzrlib.testament import Testament
394
# monkey patch gpg signing mechanism
395
bzrlib.gpg.GPGStrategy = bzrlib.gpg.DisabledGPGStrategy
396
config = MustSignConfig(branch)
397
self.assertRaises(SigningFailed,
398
commit.Commit(config=config).commit,
400
allow_pointless=True,
403
branch = Branch.open(self.get_url('.'))
404
self.assertEqual(branch.revision_history(), ['A'])
405
self.failIf(branch.repository.has_revision('B'))
407
bzrlib.gpg.GPGStrategy = oldstrategy
409
def test_commit_invokes_hooks(self):
410
import bzrlib.commit as commit
411
wt = self.make_branch_and_tree('.')
414
def called(branch, rev_id):
415
calls.append('called')
416
bzrlib.ahook = called
418
config = BranchWithHooks(branch)
419
commit.Commit(config=config).commit(
421
allow_pointless=True,
422
rev_id='A', working_tree = wt)
423
self.assertEqual(['called', 'called'], calls)
427
def test_commit_object_doesnt_set_nick(self):
428
# using the Commit object directly does not set the branch nick.
429
wt = self.make_branch_and_tree('.')
431
c.commit(working_tree=wt, message='empty tree', allow_pointless=True)
432
self.assertEquals(wt.branch.revno(), 1)
434
wt.branch.repository.get_revision(
435
wt.branch.last_revision()).properties)
437
def test_safe_master_lock(self):
439
master = BzrDirMetaFormat1().initialize('master')
440
master.create_repository()
441
master_branch = master.create_branch()
442
master.create_workingtree()
443
bound = master.sprout('bound')
444
wt = bound.open_workingtree()
445
wt.branch.set_bound_location(os.path.realpath('master'))
447
orig_default = lockdir._DEFAULT_TIMEOUT_SECONDS
448
master_branch.lock_write()
450
lockdir._DEFAULT_TIMEOUT_SECONDS = 1
451
self.assertRaises(LockContention, wt.commit, 'silly')
453
lockdir._DEFAULT_TIMEOUT_SECONDS = orig_default
454
master_branch.unlock()
456
def test_commit_bound_merge(self):
457
# see bug #43959; commit of a merge in a bound branch fails to push
458
# the new commit into the master
459
master_branch = self.make_branch('master')
460
bound_tree = self.make_branch_and_tree('bound')
461
bound_tree.branch.bind(master_branch)
463
self.build_tree_contents([('bound/content_file', 'initial contents\n')])
464
bound_tree.add(['content_file'])
465
bound_tree.commit(message='woo!')
467
other_bzrdir = master_branch.bzrdir.sprout('other')
468
other_tree = other_bzrdir.open_workingtree()
470
# do a commit to the the other branch changing the content file so
471
# that our commit after merging will have a merged revision in the
472
# content file history.
473
self.build_tree_contents([('other/content_file', 'change in other\n')])
474
other_tree.commit('change in other')
476
# do a merge into the bound branch from other, and then change the
477
# content file locally to force a new revision (rather than using the
478
# revision from other). This forces extra processing in commit.
479
bound_tree.merge_from_branch(other_tree.branch)
480
self.build_tree_contents([('bound/content_file', 'change in bound\n')])
482
# before #34959 was fixed, this failed with 'revision not present in
483
# weave' when trying to implicitly push from the bound branch to the master
484
bound_tree.commit(message='commit of merge in bound tree')
486
def test_commit_reporting_after_merge(self):
487
# when doing a commit of a merge, the reporter needs to still
488
# be called for each item that is added/removed/deleted.
489
this_tree = self.make_branch_and_tree('this')
490
# we need a bunch of files and dirs, to perform one action on each.
493
'this/dirtoreparent/',
496
'this/filetoreparent',
513
this_tree.commit('create_files')
514
other_dir = this_tree.bzrdir.sprout('other')
515
other_tree = other_dir.open_workingtree()
516
other_tree.lock_write()
517
# perform the needed actions on the files and dirs.
519
other_tree.rename_one('dirtorename', 'renameddir')
520
other_tree.rename_one('dirtoreparent', 'renameddir/reparenteddir')
521
other_tree.rename_one('filetorename', 'renamedfile')
522
other_tree.rename_one('filetoreparent', 'renameddir/reparentedfile')
523
other_tree.remove(['dirtoremove', 'filetoremove'])
524
self.build_tree_contents([
526
('other/filetomodify', 'new content'),
527
('other/newfile', 'new file content')])
528
other_tree.add('newfile')
529
other_tree.add('newdir/')
530
other_tree.commit('modify all sample files and dirs.')
533
this_tree.merge_from_branch(other_tree.branch)
534
reporter = CapturingReporter()
535
this_tree.commit('do the commit', reporter=reporter)
537
('change', 'unchanged', ''),
538
('change', 'unchanged', 'dirtoleave'),
539
('change', 'unchanged', 'filetoleave'),
540
('change', 'modified', 'filetomodify'),
541
('change', 'added', 'newdir'),
542
('change', 'added', 'newfile'),
543
('renamed', 'renamed', 'dirtorename', 'renameddir'),
544
('renamed', 'renamed', 'filetorename', 'renamedfile'),
545
('renamed', 'renamed', 'dirtoreparent', 'renameddir/reparenteddir'),
546
('renamed', 'renamed', 'filetoreparent', 'renameddir/reparentedfile'),
547
('deleted', 'dirtoremove'),
548
('deleted', 'filetoremove'),
552
def test_commit_removals_respects_filespec(self):
553
"""Commit respects the specified_files for removals."""
554
tree = self.make_branch_and_tree('.')
555
self.build_tree(['a', 'b'])
557
tree.commit('added a, b')
558
tree.remove(['a', 'b'])
559
tree.commit('removed a', specific_files='a')
560
basis = tree.basis_tree()
563
self.assertIs(None, basis.path2id('a'))
564
self.assertFalse(basis.path2id('b') is None)
568
def test_commit_saves_1ms_timestamp(self):
569
"""Passing in a timestamp is saved with 1ms resolution"""
570
tree = self.make_branch_and_tree('.')
571
self.build_tree(['a'])
573
tree.commit('added a', timestamp=1153248633.4186721, timezone=0,
576
rev = tree.branch.repository.get_revision('a1')
577
self.assertEqual(1153248633.419, rev.timestamp)
579
def test_commit_has_1ms_resolution(self):
580
"""Allowing commit to generate the timestamp also has 1ms resolution"""
581
tree = self.make_branch_and_tree('.')
582
self.build_tree(['a'])
584
tree.commit('added a', rev_id='a1')
586
rev = tree.branch.repository.get_revision('a1')
587
timestamp = rev.timestamp
588
timestamp_1ms = round(timestamp, 3)
589
self.assertEqual(timestamp_1ms, timestamp)
591
def assertBasisTreeKind(self, kind, tree, file_id):
592
basis = tree.basis_tree()
595
self.assertEqual(kind, basis.kind(file_id))
599
def test_commit_kind_changes(self):
600
self.requireFeature(SymlinkFeature)
601
tree = self.make_branch_and_tree('.')
602
os.symlink('target', 'name')
603
tree.add('name', 'a-file-id')
604
tree.commit('Added a symlink')
605
self.assertBasisTreeKind('symlink', tree, 'a-file-id')
608
self.build_tree(['name'])
609
tree.commit('Changed symlink to file')
610
self.assertBasisTreeKind('file', tree, 'a-file-id')
613
os.symlink('target', 'name')
614
tree.commit('file to symlink')
615
self.assertBasisTreeKind('symlink', tree, 'a-file-id')
619
tree.commit('symlink to directory')
620
self.assertBasisTreeKind('directory', tree, 'a-file-id')
623
os.symlink('target', 'name')
624
tree.commit('directory to symlink')
625
self.assertBasisTreeKind('symlink', tree, 'a-file-id')
627
# prepare for directory <-> file tests
630
tree.commit('symlink to directory')
631
self.assertBasisTreeKind('directory', tree, 'a-file-id')
634
self.build_tree(['name'])
635
tree.commit('Changed directory to file')
636
self.assertBasisTreeKind('file', tree, 'a-file-id')
640
tree.commit('file to directory')
641
self.assertBasisTreeKind('directory', tree, 'a-file-id')
643
def test_commit_unversioned_specified(self):
644
"""Commit should raise if specified files isn't in basis or worktree"""
645
tree = self.make_branch_and_tree('.')
646
self.assertRaises(errors.PathsNotVersionedError, tree.commit,
647
'message', specific_files=['bogus'])
649
class Callback(object):
651
def __init__(self, message, testcase):
653
self.message = message
654
self.testcase = testcase
656
def __call__(self, commit_obj):
658
self.testcase.assertTrue(isinstance(commit_obj, Commit))
661
def test_commit_callback(self):
662
"""Commit should invoke a callback to get the message"""
664
tree = self.make_branch_and_tree('.')
668
self.assertTrue(isinstance(e, BzrError))
669
self.assertEqual('The message or message_callback keyword'
670
' parameter is required for commit().', str(e))
672
self.fail('exception not raised')
673
cb = self.Callback(u'commit 1', self)
674
tree.commit(message_callback=cb)
675
self.assertTrue(cb.called)
676
repository = tree.branch.repository
677
message = repository.get_revision(tree.last_revision()).message
678
self.assertEqual('commit 1', message)
680
def test_no_callback_pointless(self):
681
"""Callback should not be invoked for pointless commit"""
682
tree = self.make_branch_and_tree('.')
683
cb = self.Callback(u'commit 2', self)
684
self.assertRaises(PointlessCommit, tree.commit, message_callback=cb,
685
allow_pointless=False)
686
self.assertFalse(cb.called)
688
def test_no_callback_netfailure(self):
689
"""Callback should not be invoked if connectivity fails"""
690
tree = self.make_branch_and_tree('.')
691
cb = self.Callback(u'commit 2', self)
692
repository = tree.branch.repository
693
# simulate network failure
694
def raise_(self, arg, arg2):
695
raise errors.NoSuchFile('foo')
696
repository.add_inventory = raise_
697
self.assertRaises(errors.NoSuchFile, tree.commit, message_callback=cb)
698
self.assertFalse(cb.called)
700
def test_selected_file_merge_commit(self):
701
"""Ensure the correct error is raised"""
702
tree = self.make_branch_and_tree('foo')
703
# pending merge would turn into a left parent
704
tree.commit('commit 1')
705
tree.add_parent_tree_id('example')
706
self.build_tree(['foo/bar', 'foo/baz'])
707
tree.add(['bar', 'baz'])
708
err = self.assertRaises(errors.CannotCommitSelectedFileMerge,
709
tree.commit, 'commit 2', specific_files=['bar', 'baz'])
710
self.assertEqual(['bar', 'baz'], err.files)
711
self.assertEqual('Selected-file commit of merges is not supported'
712
' yet: files bar, baz', str(err))
714
def test_commit_ordering(self):
715
"""Test of corner-case commit ordering error"""
716
tree = self.make_branch_and_tree('.')
717
self.build_tree(['a/', 'a/z/', 'a/c/', 'a/z/x', 'a/z/y'])
718
tree.add(['a/', 'a/z/', 'a/c/', 'a/z/x', 'a/z/y'])
720
self.build_tree(['a/c/d/'])
722
tree.rename_one('a/z/x', 'a/c/d/x')
723
tree.commit('test', specific_files=['a/z/y'])
725
def test_commit_no_author(self):
726
"""The default kwarg author in MutableTree.commit should not add
727
the 'author' revision property.
729
tree = self.make_branch_and_tree('foo')
730
rev_id = tree.commit('commit 1')
731
rev = tree.branch.repository.get_revision(rev_id)
732
self.assertFalse('author' in rev.properties)
734
def test_commit_author(self):
735
"""Passing a non-empty author kwarg to MutableTree.commit should add
736
the 'author' revision property.
738
tree = self.make_branch_and_tree('foo')
739
rev_id = tree.commit('commit 1', author='John Doe <jdoe@example.com>')
740
rev = tree.branch.repository.get_revision(rev_id)
741
self.assertEqual('John Doe <jdoe@example.com>',
742
rev.properties['author'])