From dda0607aa63d8dddb7bad53ce03205c264c2f6dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Mon, 25 May 2026 13:03:35 +0100 Subject: [PATCH 1/5] Add commit --amend option Replaces the tip revision with a new one that shares the same parents. The previous commit's message, authors and timestamp are inherited unless the user passes --message, --author or --commit-time to override them; other revision properties (such as bug links) are carried over as well. --- breezy/builtins.py | 55 ++++++++++++- breezy/tests/blackbox/test_commit.py | 115 +++++++++++++++++++++++++++ doc/en/release-notes/brz-3.4.txt | 6 ++ 3 files changed, 175 insertions(+), 1 deletion(-) diff --git a/breezy/builtins.py b/breezy/builtins.py index 2f35ca3dea..cf5b76ca07 100644 --- a/breezy/builtins.py +++ b/breezy/builtins.py @@ -4491,6 +4491,13 @@ class cmd_commit(Command): # noqa: D101 "system do not push data that can not be natively " "represented.", ), + Option( + "amend", + help="Replace the tip of the branch by recording a new " + "revision with the same parents. By default the previous " + "commit's message, authors and timestamp are reused; pass " + "--message, --author or --commit-time to override them.", + ), ] aliases = ["ci", "checkin"] @@ -4571,6 +4578,7 @@ def run( exclude=None, commit_time=None, lossy=False, + amend=False, ): """Execute the commit command. @@ -4589,9 +4597,11 @@ def run( exclude: Files to exclude from the commit. commit_time: Set specific commit timestamp. lossy: Allow lossy commits to foreign branches. + amend: Replace the tip revision rather than adding a new one. """ import itertools + from . import revision as _mod_revision from .commit import PointlessCommit from .errors import ConflictsInTree, StrictCommitFailed from .msgeditor import ( @@ -4600,6 +4610,7 @@ def run( make_commit_message_template_encoded, set_commit_message, ) + from .uncommit import uncommit from .workingtree import WorkingTree commit_stamp = offset = None @@ -4636,6 +4647,44 @@ def run( if local and not tree.branch.get_bound_location(): raise errors.LocalRequiresBoundBranch() + if amend: + with tree.lock_read(): + tip_revid = tree.branch.last_revision() + if _mod_revision.is_null(tip_revid): + raise errors.CommandError( + gettext("Nothing to amend: branch has no revisions.") + ) + pending_merges = tree.get_parent_ids()[1:] + if pending_merges: + raise errors.CommandError( + gettext( + "Cannot amend a commit while there are pending " + "merges. Commit the merge first, then amend." + ) + ) + amended_revision = tree.branch.repository.get_revision(tip_revid) + if message is None and file is None: + # Inherit the existing message. Use --message to provide + # a new one explicitly. + message = amended_revision.message + if not author: + # Inherit only when the original commit recorded an explicit + # author distinct from the committer; otherwise let the new + # commit pick up the committer as usual. + existing_authors = amended_revision.properties.get("authors") + existing_author = amended_revision.properties.get("author") + if existing_authors is not None: + author = existing_authors.split("\n") + elif existing_author is not None: + author = [existing_author] + if commit_stamp is None: + commit_stamp = amended_revision.timestamp + offset = amended_revision.timezone + for key, value in amended_revision.properties.items(): + if key in ("authors", "author", "branch-nick"): + continue + properties.setdefault(key, value) + if message is not None: try: file_exists = osutils.lexists(message) @@ -4711,11 +4760,15 @@ def get_message(commit_obj): # but the command line should not do that. if not selected_list: selected_list = None + if amend: + # Replace the tip revision by first removing it, then committing + # on top of its parent. uncommit handles bound branches itself. + uncommit(tree.branch, tree=tree, local=local, keep_tags=True) try: tree.commit( message_callback=get_message, specific_files=selected_list, - allow_pointless=unchanged, + allow_pointless=unchanged or amend, strict=strict, local=local, reporter=None, diff --git a/breezy/tests/blackbox/test_commit.py b/breezy/tests/blackbox/test_commit.py index 03d0efa353..9078efe9d0 100644 --- a/breezy/tests/blackbox/test_commit.py +++ b/breezy/tests/blackbox/test_commit.py @@ -807,6 +807,121 @@ def test_commit_time_missing_tz(self): # some other exception self.assertContainsString(err, "missing a timezone offset") + def test_amend_replaces_tip(self): + tree = self.make_branch_and_tree("tree") + self.build_tree(["tree/a"]) + tree.add("a") + tree.commit("first") + old_revid = tree.last_revision() + self.build_tree_contents([("tree/a", b"updated\n")]) + self.run_bzr("commit --amend -m amended tree") + self.assertEqual(1, tree.branch.revno()) + new_revid = tree.last_revision() + self.assertNotEqual(old_revid, new_revid) + new_rev = tree.branch.repository.get_revision(new_revid) + self.assertEqual("amended", new_rev.message) + + def test_amend_inherits_message(self): + tree = self.make_branch_and_tree("tree") + self.build_tree(["tree/a"]) + tree.add("a") + tree.commit("original message") + self.build_tree_contents([("tree/a", b"changed\n")]) + self.run_bzr(["commit", "--amend", "--unchanged"], working_dir="tree") + new_rev = tree.branch.repository.get_revision(tree.last_revision()) + self.assertEqual("original message", new_rev.message) + + def test_amend_inherits_author_and_time(self): + tree = self.make_branch_and_tree("tree") + self.build_tree(["tree/a"]) + tree.add("a") + self.run_bzr( + [ + "commit", + "-m", + "first", + "--author", + "Jane ", + "--commit-time", + "2009-10-10 08:00:00 +0100", + "tree/a", + ] + ) + self.build_tree_contents([("tree/a", b"changed\n")]) + self.run_bzr(["commit", "--amend", "-m", "amended", "tree"]) + new_rev = tree.branch.repository.get_revision(tree.last_revision()) + self.assertEqual("Jane ", new_rev.properties["authors"]) + self.assertEqual( + "Sat 2009-10-10 08:00:00 +0100", + osutils.format_date(new_rev.timestamp, new_rev.timezone), + ) + + def test_amend_inherits_bug_property(self): + tree = self.make_branch_and_tree("tree") + self.build_tree(["tree/a"]) + tree.add("a") + self.run_bzr(["commit", "-m", "first", "--fixes", "lp:42", "tree/a"]) + self.build_tree_contents([("tree/a", b"changed\n")]) + self.run_bzr(["commit", "--amend", "-m", "amended", "tree"]) + new_rev = tree.branch.repository.get_revision(tree.last_revision()) + self.assertIn("bugs", new_rev.properties) + self.assertIn("lp/42", new_rev.properties["bugs"]) + + def test_amend_overrides_message(self): + tree = self.make_branch_and_tree("tree") + self.build_tree(["tree/a"]) + tree.add("a") + tree.commit("original") + self.build_tree_contents([("tree/a", b"changed\n")]) + self.run_bzr(["commit", "--amend", "-m", "replacement", "tree"]) + new_rev = tree.branch.repository.get_revision(tree.last_revision()) + self.assertEqual("replacement", new_rev.message) + + def test_amend_overrides_author(self): + tree = self.make_branch_and_tree("tree") + self.build_tree(["tree/a"]) + tree.add("a") + self.run_bzr( + ["commit", "-m", "first", "--author", "Jane ", "tree/a"] + ) + self.build_tree_contents([("tree/a", b"changed\n")]) + self.run_bzr( + [ + "commit", + "--amend", + "-m", + "amended", + "--author", + "Joe ", + "tree", + ] + ) + new_rev = tree.branch.repository.get_revision(tree.last_revision()) + self.assertEqual("Joe ", new_rev.properties["authors"]) + + def test_amend_with_no_revisions(self): + self.make_branch_and_tree("tree") + _out, err = self.run_bzr( + ["commit", "--amend", "-m", "amended", "tree"], retcode=3 + ) + self.assertContainsString(err, "Nothing to amend") + + def test_amend_with_pending_merges_refused(self): + base = self.make_branch_and_tree("base") + self.build_tree(["base/a"]) + base.add("a") + base.commit("base") + other = base.controldir.sprout("other").open_workingtree() + self.build_tree_contents([("other/a", b"other\n")]) + other.commit("other") + self.build_tree_contents([("base/a", b"base change\n")]) + base.commit("base change") + base.merge_from_branch(other.branch) + _out, err = self.run_bzr( + ["commit", "--amend", "-m", "amended", "base"], retcode=3 + ) + self.assertContainsString(err, "pending merges") + def test_partial_commit_with_renames_in_tree(self): # this test illustrates bug #140419 t = self.make_branch_and_tree(".") diff --git a/doc/en/release-notes/brz-3.4.txt b/doc/en/release-notes/brz-3.4.txt index 7c527e358d..b230f6ab78 100644 --- a/doc/en/release-notes/brz-3.4.txt +++ b/doc/en/release-notes/brz-3.4.txt @@ -26,6 +26,12 @@ New Features .. New commands, options, etc that users may wish to try out. + * ``brz commit`` now accepts an ``--amend`` option that replaces the tip + revision with a new one sharing the same parents. The previous + commit's message, authors and timestamp are reused unless overridden + with ``--message``, ``--author`` or ``--commit-time``. + (Jelmer Vernooij) + Improvements ************ From 30d7d0ee1f11df65ede972b806501aaa1ab51be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Mon, 25 May 2026 13:35:38 +0100 Subject: [PATCH 2/5] commit --amend: open editor pre-filled with previous message --- breezy/builtins.py | 39 ++++++++++++++++++++-------- breezy/tests/blackbox/test_commit.py | 38 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/breezy/builtins.py b/breezy/builtins.py index cf5b76ca07..6e73dca903 100644 --- a/breezy/builtins.py +++ b/breezy/builtins.py @@ -4648,14 +4648,17 @@ def run( raise errors.LocalRequiresBoundBranch() if amend: + # Inherit metadata from the revision being replaced. The new + # message is resolved by get_message later; when the user does + # not pass --message/--file, the editor is opened pre-filled + # with the previous commit's message. with tree.lock_read(): tip_revid = tree.branch.last_revision() if _mod_revision.is_null(tip_revid): raise errors.CommandError( gettext("Nothing to amend: branch has no revisions.") ) - pending_merges = tree.get_parent_ids()[1:] - if pending_merges: + if tree.get_parent_ids()[1:]: raise errors.CommandError( gettext( "Cannot amend a commit while there are pending " @@ -4663,14 +4666,7 @@ def run( ) ) amended_revision = tree.branch.repository.get_revision(tip_revid) - if message is None and file is None: - # Inherit the existing message. Use --message to provide - # a new one explicitly. - message = amended_revision.message if not author: - # Inherit only when the original commit recorded an explicit - # author distinct from the committer; otherwise let the new - # commit pick up the committer as usual. existing_authors = amended_revision.properties.get("authors") existing_author = amended_revision.properties.get("author") if existing_authors is not None: @@ -4731,6 +4727,8 @@ def get_message(commit_obj): my_message = set_commit_message(commit_obj) if my_message is None: start_message = generate_commit_message_template(commit_obj) + if start_message is None and amend: + start_message = amended_revision.message if start_message is not None: start_message = start_message.encode( osutils.get_user_encoding() @@ -4761,8 +4759,27 @@ def get_message(commit_obj): if not selected_list: selected_list = None if amend: - # Replace the tip revision by first removing it, then committing - # on top of its parent. uncommit handles bound branches itself. + # Resolve the commit message before mutating any branch state so + # the user can cancel the editor without leaving us half-amended. + # The hooks called via get_message expect an object exposing the + # working tree, so synthesise a minimal stand-in. + class _AmendCommitContext: + def __init__(self, work_tree): + self.work_tree = work_tree + self.branch = work_tree.branch + self.amended_revision = amended_revision + self.parents = work_tree.get_parent_ids() + self.revprops = properties + self.config_stack = work_tree.get_config_stack() + self.specific_files = selected_list + self.exclude = exclude + + resolved_message = get_message(_AmendCommitContext(tree)) + + def get_message(commit_obj, _resolved=resolved_message): + return _resolved + + # Now rewind the branch tip; the next commit recreates it. uncommit(tree.branch, tree=tree, local=local, keep_tags=True) try: tree.commit( diff --git a/breezy/tests/blackbox/test_commit.py b/breezy/tests/blackbox/test_commit.py index 9078efe9d0..7857f3dec0 100644 --- a/breezy/tests/blackbox/test_commit.py +++ b/breezy/tests/blackbox/test_commit.py @@ -906,6 +906,44 @@ def test_amend_with_no_revisions(self): ) self.assertContainsString(err, "Nothing to amend") + def test_amend_editor_prepopulated_with_old_message(self): + if sys.platform == "win32": + self.skipTest("editor recording not implemented on Windows") + tree = self.make_branch_and_tree("tree") + self.build_tree(["tree/a"]) + tree.add("a") + tree.commit("first message") + # Record what the editor sees, then exit without modifying it. + with open("recorder.sh", "wb") as f: + f.write(b"#!/bin/sh\ncat $1 > recorded.txt\n") + os.chmod("recorder.sh", 0o755) # noqa: S103 + self.overrideEnv("BRZ_EDITOR", os.path.abspath("recorder.sh")) + self.build_tree_contents([("tree/a", b"changed\n")]) + self.run_bzr(["commit", "--amend", "tree"], stdin="y\n") + with open("recorded.txt") as f: + recorded = f.read() + self.assertContainsString(recorded, "first message") + + def test_amend_editor_cancellation_preserves_branch(self): + if sys.platform == "win32": + self.skipTest("editor cancellation not testable on Windows") + tree = self.make_branch_and_tree("tree") + self.build_tree(["tree/a"]) + tree.add("a") + tree.commit("kept") + old_revid = tree.last_revision() + with open("noop.sh", "wb") as f: + f.write(b"#!/bin/sh\n") + os.chmod("noop.sh", 0o755) # noqa: S103 + self.overrideEnv("BRZ_EDITOR", os.path.abspath("noop.sh")) + self.build_tree_contents([("tree/a", b"changed\n")]) + # Answering "no" to the "use anyway?" prompt should error out and + # leave the branch tip unchanged. + _out, err = self.run_bzr(["commit", "--amend", "tree"], retcode=3, stdin="n\n") + self.assertContainsString(err, "Empty commit message specified") + tree = tree.controldir.open_workingtree() + self.assertEqual(old_revid, tree.last_revision()) + def test_amend_with_pending_merges_refused(self): base = self.make_branch_and_tree("base") self.build_tree(["base/a"]) From 6c07161051b21a6016015bb8c5cfcca1b4b586a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Mon, 25 May 2026 13:47:57 +0100 Subject: [PATCH 3/5] commit --amend: show the right diff in the editor template --- breezy/builtins.py | 77 +++++++++++++++++++++++++--- breezy/tests/blackbox/test_commit.py | 10 +++- 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/breezy/builtins.py b/breezy/builtins.py index 6e73dca903..9fd9ec802f 100644 --- a/breezy/builtins.py +++ b/breezy/builtins.py @@ -4371,6 +4371,58 @@ def run(self): self.outf.write(f"{osutils.local_time_offset()}\n") +def _amend_status_template(tree, amended_revision, specific_files, show_diff): + """Build a commit message template for ``commit --amend``. + + Unlike a regular commit, the relevant comparison is the working tree + against the grandparent of the original tip — that is what the new + revision will contain. ``show_tree_status`` compares the working tree + against its current basis by default, so we drive it with an explicit + revision pointing at the grandparent. + """ + from io import BytesIO, StringIO + + from . import revision as _mod_revision + from . import revisionspec + from .diff import show_diff_trees + from .status import show_tree_status + + repository = tree.branch.repository + grandparent_ids = [ + p for p in amended_revision.parent_ids if not _mod_revision.is_null(p) + ] + grandparent_revid = ( + grandparent_ids[0] if grandparent_ids else _mod_revision.NULL_REVISION + ) + encoding = osutils.get_user_encoding() + status_tmp = StringIO() + show_tree_status( + tree, + specific_files=specific_files, + to_file=status_tmp, + verbose=True, + revision=[ + revisionspec.RevisionSpec.from_string( + "revid:" + grandparent_revid.decode("utf-8") + ) + ], + show_pending=False, + ) + template = status_tmp.getvalue().encode(encoding, "replace") + if show_diff: + from_tree = repository.revision_tree(grandparent_revid) + stream = BytesIO() + show_diff_trees( + from_tree, + tree, + stream, + specific_files, + path_encoding=encoding, + ) + template = template + b"\n" + stream.getvalue() + return template + + class cmd_commit(Command): # noqa: D101 __doc__ = """Commit changes into a new revision. @@ -4713,12 +4765,25 @@ def get_message(commit_obj): else: # No message supplied: make one up. # text is the status of the tree - text = make_commit_message_template_encoded( - tree, - selected_list, - diff=show_diff, - output_encoding=osutils.get_user_encoding(), - ) + if amend: + # During amend the working tree's basis is still the + # original tip, so the default template would show no + # changes. Generate a template that compares against + # the tip's parent — what the new commit will actually + # contain. + text = _amend_status_template( + tree, + amended_revision, + selected_list, + show_diff=show_diff, + ) + else: + text = make_commit_message_template_encoded( + tree, + selected_list, + diff=show_diff, + output_encoding=osutils.get_user_encoding(), + ) # start_message is the template generated from hooks # XXX: Warning - looks like hooks return unicode, # make_commit_message_template_encoded returns user encoding. diff --git a/breezy/tests/blackbox/test_commit.py b/breezy/tests/blackbox/test_commit.py index 7857f3dec0..60fde79541 100644 --- a/breezy/tests/blackbox/test_commit.py +++ b/breezy/tests/blackbox/test_commit.py @@ -913,16 +913,24 @@ def test_amend_editor_prepopulated_with_old_message(self): self.build_tree(["tree/a"]) tree.add("a") tree.commit("first message") + tree.commit("second message", allow_pointless=True) # Record what the editor sees, then exit without modifying it. with open("recorder.sh", "wb") as f: f.write(b"#!/bin/sh\ncat $1 > recorded.txt\n") os.chmod("recorder.sh", 0o755) # noqa: S103 self.overrideEnv("BRZ_EDITOR", os.path.abspath("recorder.sh")) + self.build_tree(["tree/b"]) + tree.add("b") self.build_tree_contents([("tree/a", b"changed\n")]) self.run_bzr(["commit", "--amend", "tree"], stdin="y\n") with open("recorded.txt") as f: recorded = f.read() - self.assertContainsString(recorded, "first message") + self.assertContainsString(recorded, "second message") + # The status section should reflect the contents of the new commit + # (modifications since the grandparent), not an empty diff against + # the original tip. + self.assertContainsString(recorded, "added:\n b") + self.assertContainsString(recorded, "modified:\n a") def test_amend_editor_cancellation_preserves_branch(self): if sys.platform == "win32": From 675894e35aac077e7dc8c5edd65d233b74e8ec7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Wed, 27 May 2026 01:30:08 +0100 Subject: [PATCH 4/5] Fix commit --amend blackbox tests --- breezy/tests/blackbox/test_commit.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/breezy/tests/blackbox/test_commit.py b/breezy/tests/blackbox/test_commit.py index 60fde79541..512225b51a 100644 --- a/breezy/tests/blackbox/test_commit.py +++ b/breezy/tests/blackbox/test_commit.py @@ -822,14 +822,24 @@ def test_amend_replaces_tip(self): self.assertEqual("amended", new_rev.message) def test_amend_inherits_message(self): + if sys.platform == "win32": + self.skipTest("editor recording not implemented on Windows") tree = self.make_branch_and_tree("tree") self.build_tree(["tree/a"]) tree.add("a") tree.commit("original message") + # Use an editor that leaves the pre-filled message untouched, then + # accept it via the "Commit message was not edited" confirmation. + with open("noop.sh", "wb") as f: + f.write(b"#!/bin/sh\n") + os.chmod("noop.sh", 0o755) # noqa: S103 + self.overrideEnv("BRZ_EDITOR", os.path.abspath("noop.sh")) self.build_tree_contents([("tree/a", b"changed\n")]) - self.run_bzr(["commit", "--amend", "--unchanged"], working_dir="tree") + self.run_bzr( + ["commit", "--amend", "--unchanged"], working_dir="tree", stdin="y\n" + ) new_rev = tree.branch.repository.get_revision(tree.last_revision()) - self.assertEqual("original message", new_rev.message) + self.assertEqual("original message", new_rev.message.rstrip("\n")) def test_amend_inherits_author_and_time(self): tree = self.make_branch_and_tree("tree") @@ -865,7 +875,7 @@ def test_amend_inherits_bug_property(self): self.run_bzr(["commit", "--amend", "-m", "amended", "tree"]) new_rev = tree.branch.repository.get_revision(tree.last_revision()) self.assertIn("bugs", new_rev.properties) - self.assertIn("lp/42", new_rev.properties["bugs"]) + self.assertIn("https://launchpad.net/bugs/42", new_rev.properties["bugs"]) def test_amend_overrides_message(self): tree = self.make_branch_and_tree("tree") From c923f22a19ff50c0bd65e311e1f07245b4609472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Wed, 27 May 2026 11:10:14 +0100 Subject: [PATCH 5/5] commit --amend: fix mypy signature mismatch on get_message --- breezy/builtins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/breezy/builtins.py b/breezy/builtins.py index 9fd9ec802f..ac540591e5 100644 --- a/breezy/builtins.py +++ b/breezy/builtins.py @@ -4841,8 +4841,8 @@ def __init__(self, work_tree): resolved_message = get_message(_AmendCommitContext(tree)) - def get_message(commit_obj, _resolved=resolved_message): - return _resolved + def get_message(commit_obj): + return resolved_message # Now rewind the branch tip; the next commit recreates it. uncommit(tree.branch, tree=tree, local=local, keep_tags=True)