Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 142 additions & 7 deletions breezy/builtins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -4491,6 +4543,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"]

Expand Down Expand Up @@ -4571,6 +4630,7 @@ def run(
exclude=None,
commit_time=None,
lossy=False,
amend=False,
):
"""Execute the commit command.

Expand All @@ -4589,9 +4649,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 (
Expand All @@ -4600,6 +4662,7 @@ def run(
make_commit_message_template_encoded,
set_commit_message,
)
from .uncommit import uncommit
from .workingtree import WorkingTree

commit_stamp = offset = None
Expand Down Expand Up @@ -4636,6 +4699,40 @@ def run(
if local and not tree.branch.get_bound_location():
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.")
)
if tree.get_parent_ids()[1:]:
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 not author:
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)
Expand Down Expand Up @@ -4668,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.
Expand All @@ -4682,6 +4792,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()
Expand Down Expand Up @@ -4711,11 +4823,34 @@ def get_message(commit_obj):
# but the command line should not do that.
if not selected_list:
selected_list = None
if amend:
# 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):
return resolved_message

# Now rewind the branch tip; the next commit recreates it.
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,
Expand Down
171 changes: 171 additions & 0 deletions breezy/tests/blackbox/test_commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,177 @@ 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):
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", stdin="y\n"
)
new_rev = tree.branch.repository.get_revision(tree.last_revision())
self.assertEqual("original message", new_rev.message.rstrip("\n"))

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 <jane@example.com>",
"--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 <jane@example.com>", 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("https://launchpad.net/bugs/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 <j@example.com>", "tree/a"]
)
self.build_tree_contents([("tree/a", b"changed\n")])
self.run_bzr(
[
"commit",
"--amend",
"-m",
"amended",
"--author",
"Joe <joe@example.com>",
"tree",
]
)
new_rev = tree.branch.repository.get_revision(tree.last_revision())
self.assertEqual("Joe <joe@example.com>", 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_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")
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, "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":
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"])
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(".")
Expand Down
Loading
Loading