commit --amend initial version
This commit is contained in:
@@ -29,7 +29,7 @@
|
|||||||
"context": [{"key": "setting.vdiff_view"}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["C"], "command": "vdiff_action", "args": {"action": "commit"},
|
{ "keys": ["C"], "command": "vdiff_action", "args": {"action": "commit"},
|
||||||
"context": [{"key": "setting.vdiff_view"}] },
|
"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"}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["?"], "command": "vdiff_action", "args": {"action": "help"},
|
{ "keys": ["?"], "command": "vdiff_action", "args": {"action": "help"},
|
||||||
"context": [{"key": "setting.vdiff_view"}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ Keys inside the review view:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `f` / `c` | changed files / recent commits panel |
|
| `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 |
|
| `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 |
|
| `o` or Enter | open the real file at this line |
|
||||||
| `]` / `[` | next / previous change (wraps at the ends) |
|
| `]` / `[` | next / previous change (wraps at the ends) |
|
||||||
| `n` / `p` | next / previous file of the current list (wraps at the ends) |
|
| `n` / `p` | next / previous file of the current list (wraps at the ends) |
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ class Window:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.panel = None
|
self.panel = None
|
||||||
self.input = None
|
self.input = None
|
||||||
|
self.input_view = None
|
||||||
self.commands = []
|
self.commands = []
|
||||||
|
|
||||||
def focus_view(self, view):
|
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):
|
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)
|
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:
|
class View:
|
||||||
|
|||||||
@@ -206,6 +206,71 @@ def test_commit_prompts_then_commits(monkeypatch):
|
|||||||
assert refreshed == ["committed 2 files"]
|
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):
|
def test_commit_message_counts_one_file(monkeypatch):
|
||||||
refreshed = []
|
refreshed = []
|
||||||
monkeypatch.setattr(commands.git, "commit", lambda repo, message: None)
|
monkeypatch.setattr(commands.git, "commit", lambda repo, message: None)
|
||||||
|
|||||||
@@ -200,3 +200,72 @@ def test_commit_without_staged_files_raises(repo):
|
|||||||
with pytest.raises(git.GitError) as info:
|
with pytest.raises(git.GitError) as info:
|
||||||
git.commit(repo, "nothing to record")
|
git.commit(repo, "nothing to record")
|
||||||
assert info.value.code != 0
|
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)
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ def test_help_popup_lists_every_key():
|
|||||||
assert view.is_popup_visible()
|
assert view.is_popup_visible()
|
||||||
for group in ("Navigate", "Panels", "Actions", "View"):
|
for group in ("Navigate", "Panels", "Actions", "View"):
|
||||||
assert ">{}<".format(group) in html
|
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 ">{}<".format(keys) in html
|
||||||
assert "next / previous change" in html and "open the real file at this line" 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
|
# 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)
|
_, html = help_popup(st)
|
||||||
|
|
||||||
assert "uncommitted only" in html
|
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="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
|
# everything that still applies stays clickable
|
||||||
assert 'href="files"' in html and 'href="open"' in html and 'href="refresh"' in html
|
assert 'href="files"' in html and 'href="open"' in html and 'href="refresh"' in html
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import sublime
|
|||||||
import sublime_plugin
|
import sublime_plugin
|
||||||
|
|
||||||
from . import git, parse, render, state
|
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
|
# quick-panel kind per status letter: colour + the letter; the description comes from git.STATUS_NAMES
|
||||||
STATUS_COLOURS = {
|
STATUS_COLOURS = {
|
||||||
@@ -132,8 +132,8 @@ def commit_items(commits):
|
|||||||
annotation = c.short
|
annotation = c.short
|
||||||
if c.files is not None:
|
if c.files is not None:
|
||||||
# merges and empty commits have no diffstat, so they show the hash alone
|
# merges and empty commits have no diffstat, so they show the hash alone
|
||||||
annotation = "{} {} changed -{} +{} {}".format(
|
annotation = "{} changed -{} +{} {}".format(
|
||||||
c.files, "file" if c.files == 1 else "files", c.deletions, c.insertions, c.short
|
file_count(c.files), c.deletions, c.insertions, c.short
|
||||||
)
|
)
|
||||||
subject = c.subject or "(no subject)"
|
subject = c.subject or "(no subject)"
|
||||||
budget = max(COMMIT_ROW_WIDTH - len(annotation), MIN_SUBJECT_WIDTH)
|
budget = max(COMMIT_ROW_WIDTH - len(annotation), MIN_SUBJECT_WIDTH)
|
||||||
@@ -440,15 +440,49 @@ def _act_commit(view, window, st):
|
|||||||
if not message.strip():
|
if not message.strip():
|
||||||
status("commit cancelled: empty message")
|
status("commit cancelled: empty message")
|
||||||
return
|
return
|
||||||
noun = "file" if len(staged) == 1 else "files"
|
|
||||||
run_async(
|
run_async(
|
||||||
lambda: git.commit(st.repo, message),
|
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)
|
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 _move_line(forward):
|
||||||
def act(view, window, st):
|
def act(view, window, st):
|
||||||
view.run_command("move", {"by": "lines", "forward": forward})
|
view.run_command("move", {"by": "lines", "forward": forward})
|
||||||
@@ -465,6 +499,7 @@ ACTIONS = {
|
|||||||
"unstage": _git_action("unstage", "unstaged", git.unstage),
|
"unstage": _git_action("unstage", "unstaged", git.unstage),
|
||||||
"discard": _git_action("discard", "discarded", git.discard, confirm=_confirm_discard),
|
"discard": _git_action("discard", "discarded", git.discard, confirm=_confirm_discard),
|
||||||
"commit": _act_commit,
|
"commit": _act_commit,
|
||||||
|
"amend": _act_amend,
|
||||||
"refresh": _act_refresh,
|
"refresh": _act_refresh,
|
||||||
"open": _act_open,
|
"open": _act_open,
|
||||||
"next_file": _step_file(1),
|
"next_file": _step_file(1),
|
||||||
|
|||||||
14
vdiff/git.py
14
vdiff/git.py
@@ -252,6 +252,20 @@ def commit(repo, message):
|
|||||||
run(repo, ["commit", "-m", 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):
|
def unstage(repo, change):
|
||||||
"""Move the file's staged changes back to the working tree"""
|
"""Move the file's staged changes back to the working tree"""
|
||||||
if has_head(repo):
|
if has_head(repo):
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import sublime_plugin
|
|||||||
from . import parse
|
from . import parse
|
||||||
from .git import STATUS_NAMES
|
from .git import STATUS_NAMES
|
||||||
from .state import VIEW_FLAG
|
from .state import VIEW_FLAG
|
||||||
from .utils import settings
|
from .utils import file_count, settings
|
||||||
|
|
||||||
LOG = logging.getLogger("vdiff")
|
LOG = logging.getLogger("vdiff")
|
||||||
|
|
||||||
@@ -74,7 +74,8 @@ KEY_GROUPS = [
|
|||||||
Key("s", "stage", "stage", ("stage",)),
|
Key("s", "stage", "stage", ("stage",)),
|
||||||
Key("u", "unstage", "unstage", ("unstage",)),
|
Key("u", "unstage", "unstage", ("unstage",)),
|
||||||
Key("d", "discard", "discard", ("discard",)),
|
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, [
|
("View", True, [
|
||||||
Key("r", "refresh", "refresh", ("refresh",)),
|
Key("r", "refresh", "refresh", ("refresh",)),
|
||||||
@@ -335,8 +336,8 @@ def _commit_parts(st):
|
|||||||
insertions = sum(c.insertions or 0 for c in st.files)
|
insertions = sum(c.insertions or 0 for c in st.files)
|
||||||
deletions = sum(c.deletions or 0 for c in st.files)
|
deletions = sum(c.deletions or 0 for c in st.files)
|
||||||
parts.append(
|
parts.append(
|
||||||
'{} {} changed <span class="del">-{}</span> <span class="ins">+{}</span>'.format(
|
'{} changed <span class="del">-{}</span> <span class="ins">+{}</span>'.format(
|
||||||
len(st.files), "file" if len(st.files) == 1 else "files", deletions, insertions
|
file_count(len(st.files)), deletions, insertions
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if info:
|
if info:
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ def apply_git_binary():
|
|||||||
git.GIT_BINARY = settings().get("git_binary") or "git"
|
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):
|
def status(message):
|
||||||
"""Status-bar message with the plugin prefix"""
|
"""Status-bar message with the plugin prefix"""
|
||||||
sublime.status_message("vDiff: {}".format(message))
|
sublime.status_message("vDiff: {}".format(message))
|
||||||
|
|||||||
Reference in New Issue
Block a user