From 4749835e4f4f246b8a94061680f7e41650a0ca2f Mon Sep 17 00:00:00 2001 From: Vaclav Tvrdik Date: Fri, 25 Sep 2026 10:28:10 +0200 Subject: [PATCH] commit --amend initial version --- Default.sublime-keymap | 2 +- README.md | 3 +- tests/stubs/sublime.py | 6 +++- tests/test_commands.py | 65 +++++++++++++++++++++++++++++++++++++++ tests/test_git.py | 69 ++++++++++++++++++++++++++++++++++++++++++ tests/test_render.py | 6 ++-- vdiff/commands.py | 45 ++++++++++++++++++++++++--- vdiff/git.py | 14 +++++++++ vdiff/render.py | 9 +++--- vdiff/utils.py | 5 +++ 10 files changed, 209 insertions(+), 15 deletions(-) diff --git a/Default.sublime-keymap b/Default.sublime-keymap index 721d42e..9c5f6e2 100644 --- a/Default.sublime-keymap +++ b/Default.sublime-keymap @@ -29,7 +29,7 @@ "context": [{"key": "setting.vdiff_view"}] }, { "keys": ["C"], "command": "vdiff_action", "args": {"action": "commit"}, "context": [{"key": "setting.vdiff_view"}] }, - { "keys": ["m"], "command": "vdiff_action", "args": {"action": "commit"}, + { "keys": ["m"], "command": "vdiff_action", "args": {"action": "amend"}, "context": [{"key": "setting.vdiff_view"}] }, { "keys": ["?"], "command": "vdiff_action", "args": {"action": "help"}, "context": [{"key": "setting.vdiff_view"}] }, diff --git a/README.md b/README.md index a7dc5f4..c6329e1 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ Keys inside the review view: |---|---| | `f` / `c` | changed files / recent commits panel | | `s` / `u` / `d` | stage / unstage / discard the file (uncommitted mode); `s` then moves on to the next unstaged file | -| `C` or `m` | commit the staged files, typing the message in an input panel | +| `C` | commit the staged files, typing the message in an input panel | +| `m` | amend the last commit: edit its subject, keep its body, fold in anything staged | | `o` or Enter | open the real file at this line | | `]` / `[` | next / previous change (wraps at the ends) | | `n` / `p` | next / previous file of the current list (wraps at the ends) | diff --git a/tests/stubs/sublime.py b/tests/stubs/sublime.py index ef92589..0c3df1f 100644 --- a/tests/stubs/sublime.py +++ b/tests/stubs/sublime.py @@ -89,6 +89,7 @@ class Window: def __init__(self): self.panel = None self.input = None + self.input_view = None self.commands = [] def focus_view(self, view): @@ -102,7 +103,10 @@ class Window: def show_input_panel(self, caption, initial_text, on_done, on_change, on_cancel): self.input = (caption, initial_text, on_done, on_change, on_cancel) - return View() + self.input_view = View(initial_text) + # the real panel hands its prefill back selected, which is why callers clear the selection + self.input_view.sel().add(Region(0, len(initial_text))) + return self.input_view class View: diff --git a/tests/test_commands.py b/tests/test_commands.py index bd6f49f..5055ba8 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -206,6 +206,71 @@ def test_commit_prompts_then_commits(monkeypatch): assert refreshed == ["committed 2 files"] +def prompt_amend(monkeypatch, st, head="last subject"): + """Press m; git reports `head` as HEAD's message. Returns (window, amended, refreshed)""" + amended, refreshed = [], [] + monkeypatch.setattr(commands.git, "head_message", lambda repo: head) + monkeypatch.setattr( + commands.git, "amend", lambda repo, message, allow_empty=False: amended.append((repo, message, allow_empty)) + ) + monkeypatch.setattr(commands, "refresh_listing", lambda view, st, message=None: refreshed.append(message)) + window = sublime.Window() + commands.ACTIONS["amend"](sublime.View(), window, st) + return window, amended, refreshed + + +def test_amend_refuses_commit_mode(monkeypatch): + messages = [] + monkeypatch.setattr(commands.sublime, "status_message", lambda m: messages.append(m)) + st = staged_state(True) + st.enter("abc", None) + + window, amended, _ = prompt_amend(monkeypatch, st) + assert messages[-1] == "vDiff: amend works on uncommitted changes only" + assert window.input is None and amended == [] + + +def test_amend_prefills_the_subject_with_the_caret_at_its_end(monkeypatch): + window, amended, _ = prompt_amend(monkeypatch, staged_state(True), head="fix the parser") + + caption, initial, _, _, on_cancel = window.input + assert caption == "Amend message" and initial == "fix the parser" and amended == [] + # the panel selects its prefill; the caret must sit after it, ready to type + assert list(window.input_view.sel()) == [sublime.Region(len("fix the parser"))] + # Esc is a no-op: there is no cancel callback to run + assert on_cancel is None + + +def test_amend_offers_the_subject_and_keeps_the_rest_verbatim(monkeypatch): + head = "typo hree\n\nCloses #12.\n\n- a\n- b" + window, amended, refreshed = prompt_amend(monkeypatch, staged_state(True, False, True), head=head) + + assert window.input[1] == "typo hree" # only the first line is offered for editing + window.input[2]("typo here") + # everything below the first line comes back untouched, and staged files keep git's guard + assert amended == [("/repo", "typo here" + head[len("typo hree") :], False)] + assert refreshed == ["amended the last commit with 2 files"] + + +def test_amend_rewords_with_nothing_staged(monkeypatch): + window, amended, refreshed = prompt_amend(monkeypatch, staged_state(False, False)) + + window.input[2]("just a reword") + # a reword alone opts out of git's refusal, so an empty commit can still be renamed + assert amended == [("/repo", "just a reword", True)] + assert refreshed == ["amended the last commit"] + + +def test_amend_with_an_empty_message_does_nothing(monkeypatch): + messages = [] + monkeypatch.setattr(commands.sublime, "status_message", lambda m: messages.append(m)) + window, amended, refreshed = prompt_amend(monkeypatch, staged_state(True)) + + window.input[2](" ") + assert amended == [] and refreshed == [] + assert messages[-1] == "vDiff: amend cancelled: empty message" + + def test_commit_message_counts_one_file(monkeypatch): refreshed = [] monkeypatch.setattr(commands.git, "commit", lambda repo, message: None) diff --git a/tests/test_git.py b/tests/test_git.py index 163c32e..6fb02d5 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -200,3 +200,72 @@ def test_commit_without_staged_files_raises(repo): with pytest.raises(git.GitError) as info: git.commit(repo, "nothing to record") assert info.value.code != 0 + + +def message(repo): + """HEAD's raw message, as git stores it""" + return git.run(repo, ["log", "-1", "--format=%B"]).strip("\n") + + +def test_head_message_returns_the_whole_message(repo): + assert git.head_message(repo) == "initial" + + sh(repo, "commit", "-q", "--allow-empty", "-m", "second", "-m", "body text\nsecond line") + assert git.head_message(repo) == "second\n\nbody text\nsecond line" + + +def test_amend_keeps_everything_below_the_first_line_byte_for_byte(repo): + body = "Closes #12.\n\n- a\n- b" + sh(repo, "commit", "-q", "--allow-empty", "-m", "typo hree", "-m", body) + before = git.run(repo, ["rev-list", "--count", "HEAD"]) + _, newline, rest = git.head_message(repo).partition("\n") + + git.amend(repo, "typo here" + newline + rest, allow_empty=True) + + # the paragraph the user never touched comes back exactly as git stored it + assert message(repo) == "typo here\n\n" + body + # an amend rewrites HEAD, it does not add a commit + assert git.run(repo, ["rev-list", "--count", "HEAD"]) == before + + +def test_amend_without_a_body_leaves_a_one_line_message(repo): + git.amend(repo, "reworded") + assert message(repo) == "reworded" + + +def test_amend_rewords_an_empty_commit(repo): + # git refuses an amend that leaves nothing behind unless --allow-empty says otherwise + sh(repo, "commit", "-q", "--allow-empty", "-m", "marker") + git.amend(repo, "marker, reworded", allow_empty=True) + assert message(repo) == "marker, reworded" + + +def test_amend_that_would_empty_the_commit_is_refused(repo): + # folding staged files in keeps git's guard: only a reword opts out of it + write(repo, "new.py", "temporary\n") + sh(repo, "add", "new.py") + sh(repo, "commit", "-q", "-m", "add new.py") + sh(repo, "rm", "-q", "new.py") + + with pytest.raises(git.GitError): + git.amend(repo, "add new.py") + + +def test_amend_folds_staged_files_into_head(repo): + write(repo, "a.py", "amended\n") + changes = {c.path: c for c in git.changed_files(repo)} + git.stage(repo, changes["a.py"]) + before = git.run(repo, ["rev-list", "--count", "HEAD"]) + + git.amend(repo, "initial, with a.py") + + assert git.run(repo, ["rev-list", "--count", "HEAD"]) == before + assert not git.changed_files(repo) + assert message(repo) == "initial, with a.py" + + +def test_head_message_without_a_commit_raises(tmp_path): + fresh = str(tmp_path) + sh(fresh, "init", "-q", "-b", "main") + with pytest.raises(git.GitError): + git.head_message(fresh) diff --git a/tests/test_render.py b/tests/test_render.py index fc79673..c14a511 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -134,7 +134,7 @@ def test_help_popup_lists_every_key(): assert view.is_popup_visible() for group in ("Navigate", "Panels", "Actions", "View"): assert ">{}<".format(group) in html - for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s", "u", "d", "C m", "r", "?", "esc"): + for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s", "u", "d", "C", "m", "r", "?", "esc"): assert ">{}<".format(keys) in html assert "next / previous change" in html and "open the real file at this line" in html # working-tree mode: nothing dimmed, every single-action row is a link @@ -152,9 +152,9 @@ def test_help_popup_dims_file_actions_in_commit_mode(): _, html = help_popup(st) assert "uncommitted only" in html - assert html.count('class="dim"') == 4 + assert html.count('class="dim"') == 5 assert 'href="stage"' not in html and 'href="unstage"' not in html and 'href="discard"' not in html - assert 'href="commit"' not in html + assert 'href="commit"' not in html and 'href="amend"' not in html # everything that still applies stays clickable assert 'href="files"' in html and 'href="open"' in html and 'href="refresh"' in html diff --git a/vdiff/commands.py b/vdiff/commands.py index 64bff8d..d607201 100644 --- a/vdiff/commands.py +++ b/vdiff/commands.py @@ -6,7 +6,7 @@ import sublime import sublime_plugin from . import git, parse, render, state -from .utils import run_async, settings, status +from .utils import file_count, run_async, settings, status # quick-panel kind per status letter: colour + the letter; the description comes from git.STATUS_NAMES STATUS_COLOURS = { @@ -132,8 +132,8 @@ def commit_items(commits): annotation = c.short if c.files is not None: # merges and empty commits have no diffstat, so they show the hash alone - annotation = "{} {} changed -{} +{} {}".format( - c.files, "file" if c.files == 1 else "files", c.deletions, c.insertions, c.short + annotation = "{} changed -{} +{} {}".format( + file_count(c.files), c.deletions, c.insertions, c.short ) subject = c.subject or "(no subject)" budget = max(COMMIT_ROW_WIDTH - len(annotation), MIN_SUBJECT_WIDTH) @@ -440,15 +440,49 @@ def _act_commit(view, window, st): if not message.strip(): status("commit cancelled: empty message") return - noun = "file" if len(staged) == 1 else "files" run_async( lambda: git.commit(st.repo, message), - lambda _: refresh_listing(view, st, "committed {} {}".format(len(staged), noun)), + lambda _: refresh_listing(view, st, "committed {}".format(file_count(len(staged)))), ) window.show_input_panel("Commit message", "", on_done, None, None) +def _act_amend(view, window, st): + """Rewrite HEAD's message in an input panel, folding in whatever is staged; Esc there cancels + + The panel is one line, so only the subject is offered; the rest of the message rides along + verbatim. Nothing staged is fine - then the amend is a pure reword. + """ + if st.in_commit: + status("amend works on uncommitted changes only") + return + staged = [c for c in st.files if c.staged] + + def prompt(message): + subject, newline, body = message.partition("\n") + + def on_done(text): + if not text.strip(): + status("amend cancelled: empty message") + return + done = "amended the last commit" + if staged: + done += " with {}".format(file_count(len(staged))) + run_async( + # nothing staged makes this a pure reword, which git refuses on an empty commit + lambda: git.amend(st.repo, text + newline + body, allow_empty=not staged), + lambda _: refresh_listing(view, st, done), + ) + + panel = window.show_input_panel("Amend message", subject, on_done, None, None) + # the panel selects the whole prefill; the caret belongs after it, ready to type + panel.sel().clear() + panel.sel().add(sublime.Region(panel.size())) + + run_async(lambda: git.head_message(st.repo), prompt) + + def _move_line(forward): def act(view, window, st): view.run_command("move", {"by": "lines", "forward": forward}) @@ -465,6 +499,7 @@ ACTIONS = { "unstage": _git_action("unstage", "unstaged", git.unstage), "discard": _git_action("discard", "discarded", git.discard, confirm=_confirm_discard), "commit": _act_commit, + "amend": _act_amend, "refresh": _act_refresh, "open": _act_open, "next_file": _step_file(1), diff --git a/vdiff/git.py b/vdiff/git.py index 130cac7..14182f7 100644 --- a/vdiff/git.py +++ b/vdiff/git.py @@ -252,6 +252,20 @@ def commit(repo, message): run(repo, ["commit", "-m", message]) +def head_message(repo): + """HEAD's whole message, exactly as git stores it""" + return run(repo, ["log", "-1", "--format=%B"]).strip("\n") + + +def amend(repo, message, allow_empty=False): + """Rewrite HEAD with `message`, folding in whatever is staged + + `allow_empty` for a pure reword: git otherwise refuses an amend whose result carries no change, + which would fail on a commit that happens to be empty. + """ + run(repo, ["commit", "--amend"] + (["--allow-empty"] if allow_empty else []) + ["-m", message]) + + def unstage(repo, change): """Move the file's staged changes back to the working tree""" if has_head(repo): diff --git a/vdiff/render.py b/vdiff/render.py index abd8b48..70c11d3 100644 --- a/vdiff/render.py +++ b/vdiff/render.py @@ -11,7 +11,7 @@ import sublime_plugin from . import parse from .git import STATUS_NAMES from .state import VIEW_FLAG -from .utils import settings +from .utils import file_count, settings LOG = logging.getLogger("vdiff") @@ -74,7 +74,8 @@ KEY_GROUPS = [ Key("s", "stage", "stage", ("stage",)), Key("u", "unstage", "unstage", ("unstage",)), Key("d", "discard", "discard", ("discard",)), - Key("C m", "commit the staged files", "commit", ("commit",)), + Key("C", "commit the staged files", "commit", ("commit",)), + Key("m", "amend the last commit", "amend", ("amend",)), ]), ("View", True, [ Key("r", "refresh", "refresh", ("refresh",)), @@ -335,8 +336,8 @@ def _commit_parts(st): insertions = sum(c.insertions or 0 for c in st.files) deletions = sum(c.deletions or 0 for c in st.files) parts.append( - '{} {} changed -{} +{}'.format( - len(st.files), "file" if len(st.files) == 1 else "files", deletions, insertions + '{} changed -{} +{}'.format( + file_count(len(st.files)), deletions, insertions ) ) if info: diff --git a/vdiff/utils.py b/vdiff/utils.py index 77360a3..94da653 100644 --- a/vdiff/utils.py +++ b/vdiff/utils.py @@ -27,6 +27,11 @@ def apply_git_binary(): git.GIT_BINARY = settings().get("git_binary") or "git" +def file_count(count): + """'1 file' / '3 files' for a message""" + return "{} {}".format(count, "file" if count == 1 else "files") + + def status(message): """Status-bar message with the plugin prefix""" sublime.status_message("vDiff: {}".format(message))