From 767f3e1d01891bde859389392a049db09dcf0384 Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Fri, 23 Oct 2026 17:21:46 -0400 Subject: [PATCH] Allow file toggling directly in diff stat comments Integrate file toggling into the diff stat lines instead of displaying a separate file list comment block in the commit buffer. Deselected files are now marked with '#- ', and deleting a file line also excludes it from the commit. In addition, handle file renames across diff stat comments properly, allow file toggling during amend operations against the original HEAD, disallow file deselection during pure regenerate mode, and ensure proper revert handling on abort or error. Add corresponding unit and integration tests. Assisted-by: Gemini:Gemini 3.8 Flash Signed-off-by: Simo Sorce --- README.md | 12 +- doc/vimini.txt | 10 +- plugin/vimini.vim | 4 +- python3/vimini/agent/server.py | 4 + python3/vimini/code.py | 9 +- python3/vimini/commit.py | 666 ++++++++++--- python3/vimini/main.py | 4 +- tests/test_apply_patch.py | 99 ++ tests/test_commit_files.py | 1682 ++++++++++++++++++++++++++++++-- 9 files changed, 2302 insertions(+), 188 deletions(-) create mode 100644 tests/test_apply_patch.py diff --git a/README.md b/README.md index b30ca28..9f9d9a0 100644 --- a/README.md +++ b/README.md @@ -361,17 +361,17 @@ Automates the commit process using AI. This command has three main modes: When run without flags (or with `-n`), it automates the creation of a new commit: 1. Stages all current changes (`git add .`). 2. Generates a commit message based on the staged diff. -3. Opens a commit message buffer for editing. The buffer lists all files included in the commit as comments at the bottom. -4. In Normal mode, move the cursor over the file list and press `` to toggle file inclusion (deselected files are marked with a `-` sign, and pressing `` again re-adds them). -5. Save and close the buffer to commit. All files marked with `-` are removed from the commit, and only the remaining files are committed. Close without saving to abort. +3. Opens a commit message buffer for editing. The buffer displays the diff stat with all files included in the commit as comments at the bottom. +4. In Normal mode, move the cursor over the diff stat file line and press `` to toggle file inclusion (deselected files are marked with `#-`, and pressing `` again re-adds them) to exclude the file. +5. Save and close the buffer to commit. All deselected files are removed from the commit, and only the remaining files are committed. Close without saving to abort. **Regenerate Mode (`-r`):** When run with the `-r` flag, it regenerates the commit message for the last commit (`HEAD`) and amends it: 1. It gets the diff from the `HEAD` commit. 2. It generates a new commit message based on that diff. -3. Opens the commit message buffer listing the files in `HEAD` (press `` in Normal mode to toggle/deselect files). -4. If confirmed and saved, it amends the `HEAD` commit with the new message and deselected files removed. +3. Opens the commit message buffer listing the diff stat for `HEAD` (file toggling is not available in regenerate mode). +4. If confirmed and saved, it amends the `HEAD` commit with the new message. **Amend Code and Regenerate Mode (`-a`):** @@ -379,7 +379,7 @@ When run with the `-a` flag, it amends the current working tree changes into the 1. Stages current changes. 2. Amends the code into `HEAD` (`git commit --amend --no-edit`). 3. Generates a new commit message based on the new combined diff from `HEAD`. -4. Opens the commit message buffer listing files (press `` in Normal mode to toggle/deselect files). +4. Opens the commit message buffer listing the combined diff stat (press `` in Normal mode to toggle/deselect files with `#-`). 5. If confirmed and saved, it amends the `HEAD` commit with the new message and deselected files removed. You can also provide optional `[instructions]` at the end of the command to guide the AI's message generation (e.g., "Mention issue #123" or "Keep it very brief"). diff --git a/doc/vimini.txt b/doc/vimini.txt index e233f64..a57f92c 100644 --- a/doc/vimini.txt +++ b/doc/vimini.txt @@ -26,10 +26,12 @@ COMMANDS *vimini-commands* *:ViminiCommit* [-n] [-r] [-a] [instruction] Generates a commit message and commits changes using Gemini. - Files included in the commit are listed as comments at the bottom of the - buffer. In Normal mode, press on a file to toggle its inclusion - (deselected files are marked with -). When saved and closed, deselected - files are removed and only remaining files are committed. + Files included in the commit are shown in the diff stat comments at the + bottom of the buffer. In Normal mode, press on a file to toggle its + inclusion (deselected files are marked with #-), or delete the file's line. + When saved and closed, deselected files are removed and only remaining + files are committed (file toggling is not available in regenerate mode with + -r). -n: Omit co-author trailer. -r: Regenerate commit message for HEAD and amend. -a: Amend working tree changes into HEAD and regenerate commit message. diff --git a/plugin/vimini.vim b/plugin/vimini.vim index 8c236b6..6525228 100644 --- a/plugin/vimini.vim +++ b/plugin/vimini.vim @@ -414,8 +414,8 @@ command! -nargs=* ViminiCommit call ViminiCommit() function! ViminiCommitToggleFile() py3 << EOF try: - from vimini.commit import toggle_file - toggle_file() + from vimini import main + main.toggle_file() except Exception as e: pass EOF diff --git a/python3/vimini/agent/server.py b/python3/vimini/agent/server.py index 46295e3..d01f195 100644 --- a/python3/vimini/agent/server.py +++ b/python3/vimini/agent/server.py @@ -109,6 +109,10 @@ def execute_function(req_id, method, params, result_queue, conn): "regenerate": params.get("regenerate") if isinstance(params, dict) else False, + "amend": params.get("amend") if isinstance(params, dict) else False, + "orig_head": params.get("orig_head") + if isinstance(params, dict) + else None, "assistant": params.get("assistant") if isinstance(params, dict) else True, diff --git a/python3/vimini/code.py b/python3/vimini/code.py index d23e290..3805de6 100644 --- a/python3/vimini/code.py +++ b/python3/vimini/code.py @@ -406,7 +406,14 @@ def apply_patch(diff_content, project_root=None, silent=False): except FileNotFoundError: # Fall back to git apply if patch command is not in PATH try: - git_cmd = ["git", "-C", project_root, "apply", "--whitespace=nowarn", "-"] + git_cmd = [ + "git", + "-C", + project_root, + "apply", + "--whitespace=nowarn", + "-", + ] result = subprocess.run( git_cmd, input=diff_content, diff --git a/python3/vimini/commit.py b/python3/vimini/commit.py index c8099d4..1a36d08 100644 --- a/python3/vimini/commit.py +++ b/python3/vimini/commit.py @@ -1,5 +1,6 @@ import vim import os +import re import json import subprocess import textwrap @@ -10,6 +11,7 @@ # In-memory store for commit files keyed by temporary commit filename _commit_files_by_file = {} +_commit_meta_by_file = {} def _run_format_command(repo_path): @@ -139,7 +141,60 @@ def _revert_staged(repo_path): reset_cmd = ["git", "-C", repo_path, "reset", "HEAD", "--"] else: reset_cmd = ["git", "-C", repo_path, "rm", "--cached", "-r", "--", "."] - subprocess.run(reset_cmd, check=False) + subprocess.run(reset_cmd, capture_output=True, check=False) + + +def _revert_amend(repo_path, orig_head): + """Reverts an amended commit back to the original HEAD commit, restoring working tree changes.""" + if not repo_path or not orig_head: + return + reset_cmd = ["git", "-C", repo_path, "reset", orig_head] + subprocess.run(reset_cmd, capture_output=True, check=False) + + +def _clean_rename_path(p): + p = p.strip().strip('"').strip("'") + cleaned = os.path.normpath(p).replace("\\", "/").lstrip("/") + return cleaned if cleaned != "." else "" + + +def _extract_rename_paths(file_part): + """Extracts (source, target) file paths from a diff stat rename spec.""" + if "=>" not in file_part: + return None, None + # Strip trailing similarity percentage e.g. " (100%)" + file_part = re.sub(r"\s*\(\d+%\)$", "", file_part.strip()) + if "{" in file_part and "}" in file_part: + prefix, rest = file_part.split("{", 1) + rename_spec, suffix = rest.split("}", 1) + if "=>" in rename_spec: + old_part, new_part = rename_spec.split("=>", 1) + source = _clean_rename_path(f"{prefix}{old_part.strip()}{suffix}") + target = _clean_rename_path(f"{prefix}{new_part.strip()}{suffix}") + return (source or None, target or None) + parts = file_part.split("=>", 1) + source = _clean_rename_path(parts[0].strip()) + target = _clean_rename_path(parts[1].strip()) + return (source or None, target or None) + + +def _match_body(body, commit_files): + """Checks if body or diff-stat file_part in body matches commit_files. Returns matched filename or None.""" + if not body: + return None + if body in commit_files: + return body + if "|" in body: + file_part = body.rsplit("|", 1)[0].strip() + if file_part in commit_files: + return file_part + if "=>" in file_part: + source, target = _extract_rename_paths(file_part) + if target and target in commit_files: + return target + if source and source in commit_files: + return source + return None def parse_file_line(line, commit_files): @@ -147,30 +202,31 @@ def parse_file_line(line, commit_files): Parses a line to check if it represents a file from commit_files. Returns (filename, is_deselected) if it matches a file in commit_files, or (None, None) if it does not. + The only valid deselection form is exactly '#- '. """ if not line or not line.startswith("#"): return None, None - # Exact matches with standard prefix formatting - if line.startswith("# - "): - candidate = line[4:].strip() - if candidate in commit_files: - return candidate, True - if line.startswith("# "): - candidate = line[4:].strip() - if candidate in commit_files: - return candidate, False - - # Flexible parsing for manual edits / variations - rest = line.lstrip("#").strip() - if rest.startswith("-"): - candidate = rest[1:].strip() - if candidate in commit_files: - return candidate, True - else: - candidate = rest - if candidate in commit_files: - return candidate, False + if not isinstance(commit_files, (set, frozenset)): + commit_files = set(commit_files) + + # Exactly '#- ' indicates a deselected file + if line.startswith("#- "): + body = line[3:].lstrip() + matched = _match_body(body, commit_files) + if matched: + return matched, True + return None, None + + rest = line[1:] + stripped_rest = rest.lstrip() + if not stripped_rest: + return None, None + + # Check if line matches directly (included file) + matched = _match_body(stripped_rest, commit_files) + if matched: + return matched, False return None, None @@ -191,9 +247,46 @@ def _get_commit_files_for_buffer(): return [] +def _get_amend_files_for_buffer(): + """Retrieves the list of files to amend for the current buffer, if defined.""" + try: + raw = vim.eval("get(b:, 'vimini_commit_amend_files', '')") + if raw: + return set(json.loads(raw)) + except Exception: + pass + try: + tmp_filename = vim.eval("get(b:, 'vimini_commit_tmp_file', '')") + meta = _commit_meta_by_file.get(tmp_filename, {}) + if "amend_files" in meta: + return set(meta["amend_files"]) + except Exception: + pass + return None + + +def _is_pure_regenerate_buffer(): + """Checks if the current commit buffer is in pure regenerate mode (regenerate and not amend).""" + try: + raw_regen = vim.eval("get(b:, 'vimini_commit_regenerate', 0)") + raw_amend = vim.eval("get(b:, 'vimini_commit_amend', 0)") + regen = int(raw_regen) if raw_regen else 0 + amend = int(raw_amend) if raw_amend else 0 + if regen and not amend: + return True + if regen == 0 and amend == 0: + tmp_filename = vim.eval("get(b:, 'vimini_commit_tmp_file', '')") + meta = _commit_meta_by_file.get(tmp_filename, {}) + if meta.get("regenerate") and not meta.get("amend"): + return True + except Exception: + pass + return False + + def toggle_file(): """ - Toggles file inclusion (marked with - or readded) for the file under the cursor in the commit buffer. + Toggles file inclusion (marked with #- or readded) for the file under the cursor in the commit buffer. """ try: commit_files = _get_commit_files_for_buffer() @@ -207,16 +300,37 @@ def toggle_file(): return line = vim.current.buffer[row - 1] - fn, is_deselected = parse_file_line(line, commit_files) + commit_files_set = set(commit_files) + fn, is_deselected = parse_file_line(line, commit_files_set) if fn is None: return + new_line = None if is_deselected: - # Re-add file - new_line = f"# {fn}" + # Re-add file (turn into included) + if line.startswith("#- "): + new_line = "# " + line[4:] + else: + new_line = "# " + line[3:] else: - # Deselect file (mark with -) - new_line = f"# - {fn}" + if _is_pure_regenerate_buffer(): + util.display_message( + "Files cannot be deselected in regenerate mode", error=True + ) + return + + files_to_amend = _get_amend_files_for_buffer() + if files_to_amend is not None and fn not in files_to_amend: + util.display_message( + "This file is not being changed by the amendment", error=True + ) + return + + # Deselect file (mark with exactly #- ) + if line.startswith("# "): + new_line = "#- " + line[3:] + else: + new_line = "#- " + line[2:] vim.current.buffer[row - 1] = new_line new_col = min(col, len(new_line) - 1 if new_line else 0) @@ -231,15 +345,42 @@ class CommitChannelHandler(BaseChannelHandler): Handles channel responses for commit message generation requests. """ - def __init__(self, req_id, files_to_commit=None): + def __init__( + self, + req_id, + files_to_commit=None, + orig_head=None, + amend=False, + regenerate=False, + repo_path=None, + ): super().__init__(req_id) self.files_to_commit = files_to_commit or [] + self.orig_head = orig_head + self.amend = amend + self.regenerate = regenerate + self.repo_path = repo_path def handle_response(self, result): self.finished = True if not result.get("commit_files") and self.files_to_commit: result["commit_files"] = self.files_to_commit - handle_commit_response(self.req_id, result) + if not result.get("orig_head") and self.orig_head: + result["orig_head"] = self.orig_head + if "amend" not in result and self.amend: + result["amend"] = self.amend + if "regenerate" not in result and self.regenerate: + result["regenerate"] = self.regenerate + if not result.get("repo_path") and self.repo_path: + result["repo_path"] = self.repo_path + try: + handle_commit_response(self.req_id, result) + except Exception as e: + if self.amend and self.orig_head and self.repo_path: + _revert_amend(self.repo_path, self.orig_head) + elif not result.get("regenerate") and self.repo_path: + _revert_staged(self.repo_path) + util.display_message(f"Error: {e}", error=True) def handle_error(self, error): self.finished = True @@ -248,6 +389,10 @@ def handle_error(self, error): if isinstance(error, dict) else str(error) ) + if self.amend and self.orig_head and self.repo_path: + _revert_amend(self.repo_path, self.orig_head) + elif not self.amend and not self.regenerate and self.repo_path: + _revert_staged(self.repo_path) util.display_message(f"Error: {err_msg}", error=True) @@ -257,6 +402,8 @@ def handle_commit_response(req_id, result): diff_stat_output = result.get("diff_stat_output", "") commit_files = result.get("commit_files") or [] regenerate = bool(result.get("regenerate", False)) + amend = bool(result.get("amend", False)) + orig_head = result.get("orig_head") or "" assistant = bool(result.get("assistant", True)) response_text = text.strip() @@ -281,7 +428,10 @@ def handle_commit_response(req_id, result): if not subject: msg = "Failed to generate a commit message." - if not regenerate and repo_path: + if amend and orig_head and repo_path: + msg += " Reverting amend." + _revert_amend(repo_path, orig_head) + elif not regenerate and repo_path: msg += " Reverting `git add`." _revert_staged(repo_path) util.display_message(msg, error=True) @@ -301,41 +451,121 @@ def handle_commit_response(req_id, result): if body: buffer_content.extend(body.split("\n")) - if diff_stat_output: + rename_pairs = [] + if diff_stat_output or commit_files: stat_header = "# --- Diff stat ---" buffer_content.extend(["", stat_header]) - for line in diff_stat_output.split("\n"): - buffer_content.append(f"# {line}") - - if commit_files: - files_header = ( - "# --- Files in commit ---" if regenerate else "# --- Files to commit ---" - ) - buffer_content.extend(["", files_header]) - buffer_content.append("# (Press to toggle file inclusion)") - for fn in commit_files: - buffer_content.append(f"# {fn}") + if not (regenerate and not amend): + buffer_content.append("# (Press to toggle file inclusion)") + if diff_stat_output: + files_to_amend = [] + commit_files_set = set(commit_files) + seen_files = set() + for line in diff_stat_output.split("\n"): + if not line.strip(): + continue + fn, _ = parse_file_line(f"# {line.strip()}", commit_files_set) + if fn: + seen_files.add(fn) + if "|" in line: + file_part = line.rsplit("|", 1)[0].strip() + if "=>" in file_part: + source, target = _extract_rename_paths(file_part) + if source: + seen_files.add(source) + if target: + seen_files.add(target) + if source and target: + rename_pairs.append((source, target)) + buffer_content.append(f"# {line.strip()}") + else: + buffer_content.append(f"# {line.strip()}") + for fn in commit_files: + if fn not in seen_files: + buffer_content.append(f"# {fn}") + else: + for fn in commit_files: + buffer_content.append(f"# {fn}") + + if regenerate and not amend: + files_to_amend = [] + elif amend: + files_to_amend = [] + if orig_head and repo_path: + try: + res = subprocess.run( + [ + "git", + "-C", + repo_path, + "diff-tree", + "--no-commit-id", + "--name-only", + "-r", + "-z", + orig_head, + "HEAD", + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + if res.returncode == 0 and res.stdout: + files_to_amend = [f for f in res.stdout.split("\0") if f] + except Exception: + files_to_amend = [] + for s, t in rename_pairs: + if s in files_to_amend or t in files_to_amend: + if s not in files_to_amend: + files_to_amend.append(s) + if t not in files_to_amend: + files_to_amend.append(t) + else: + files_to_amend = list(commit_files) vim.current.buffer[:] = buffer_content safe_tmp = tmp_filename.replace("'", "''") safe_repo = repo_path.replace("'", "''") + safe_orig_head = orig_head.replace("'", "''") safe_files = json.dumps(commit_files).replace("'", "''") + safe_renames = json.dumps(rename_pairs).replace("'", "''") + safe_amend_files = json.dumps(files_to_amend).replace("'", "''") vim.command(f"let b:vimini_commit_tmp_file = '{safe_tmp}'") vim.command(f"let b:vimini_commit_repo_path = '{safe_repo}'") vim.command(f"let b:vimini_commit_regenerate = {1 if regenerate else 0}") + vim.command(f"let b:vimini_commit_amend = {1 if amend else 0}") + vim.command(f"let b:vimini_commit_orig_head = '{safe_orig_head}'") vim.command(f"let b:vimini_commit_assistant = {1 if assistant else 0}") vim.command(f"let b:vimini_commit_files = '{safe_files}'") + vim.command(f"let b:vimini_commit_renames = '{safe_renames}'") + vim.command(f"let b:vimini_commit_amend_files = '{safe_amend_files}'") _commit_files_by_file[tmp_filename] = list(commit_files) + _commit_meta_by_file[tmp_filename] = { + "files": list(commit_files), + "orig_head": orig_head, + "amend": amend, + "regenerate": regenerate, + "repo_path": repo_path, + "renames": rename_pairs, + "amend_files": list(files_to_amend), + } vim.command("nnoremap :call ViminiCommitToggleFile()") vim.command( "autocmd BufWipeout py3 from vimini.commit import finalize_commit; finalize_commit()" ) + if regenerate and not amend: + review_msg = "Review the commit message. Save and close the buffer to commit, or close without saving to abort." + else: + review_msg = "Review the commit message. Toggle files with . Save and close the buffer to commit, or close without saving to abort." + util.display_message( - "Review the commit message. Toggle files with . Save and close the buffer to commit, or close without saving to abort.", + review_msg, history=True, ) vim.command("redraw!") @@ -360,12 +590,26 @@ def commit(assistant=True, regenerate=False, amend=False, refinement=None): diff_to_process = "" diff_stat_output = "" files_to_commit = [] + orig_head = "" if not regenerate: if not _run_format_command(repo_path): return if amend: + head_res = subprocess.run( + ["git", "-C", repo_path, "rev-parse", "HEAD"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + if head_res.returncode != 0: + util.display_message("Git error: failed to resolve HEAD.", error=True) + return + orig_head = head_res.stdout.strip() + if not _stage_changes(repo_path, "Staging changes for amend..."): return @@ -384,6 +628,7 @@ def commit(assistant=True, regenerate=False, amend=False, refinement=None): error_message = ( amend_code_result.stderr or amend_code_result.stdout ).strip() + _revert_staged(repo_path) util.display_message(f"Git amend failed: {error_message}", error=True) return @@ -404,11 +649,25 @@ def commit(assistant=True, regenerate=False, amend=False, refinement=None): if diff_result.returncode != 0: error_message = (diff_result.stderr or "git show HEAD failed.").strip() + if amend and orig_head: + _revert_amend(repo_path, orig_head) util.display_message(f"Git error: {error_message}", error=True) return diff_to_process = diff_result.stdout.strip() - stat_cmd = ["git", "-C", repo_path, "show", "--format=", "--stat"] + stat_cmd = [ + "git", + "-c", + "core.quotepath=false", + "-C", + repo_path, + "show", + "--format=", + "--stat", + "--stat-width=1000", + "--stat-name-width=1000", + "--stat-graph-width=10", + ] stat_result = subprocess.run( stat_cmd, capture_output=True, @@ -467,7 +726,19 @@ def commit(assistant=True, regenerate=False, amend=False, refinement=None): diff_to_process = staged_diff_result.stdout.strip() - staged_stat_cmd = ["git", "-C", repo_path, "diff", "--staged", "--stat"] + staged_stat_cmd = [ + "git", + "-c", + "core.quotepath=false", + "-C", + repo_path, + "diff", + "--staged", + "--stat", + "--stat-width=1000", + "--stat-name-width=1000", + "--stat-graph-width=10", + ] staged_stat_result = subprocess.run( staged_stat_cmd, capture_output=True, @@ -502,6 +773,8 @@ def commit(assistant=True, regenerate=False, amend=False, refinement=None): ] if not diff_to_process: + if amend and orig_head: + _revert_amend(repo_path, orig_head) message = ( "HEAD commit is empty. Nothing to regenerate." if regenerate @@ -531,7 +804,14 @@ def commit(assistant=True, regenerate=False, amend=False, refinement=None): job_id = str(util.reserve_next_job_id("Commit")) - handler = CommitChannelHandler(job_id, files_to_commit=files_to_commit) + handler = CommitChannelHandler( + job_id, + files_to_commit=files_to_commit, + orig_head=orig_head, + amend=amend, + regenerate=regenerate, + repo_path=repo_path, + ) register_handler(job_id, handler) req = { @@ -544,23 +824,37 @@ def commit(assistant=True, regenerate=False, amend=False, refinement=None): "diff_stat_output": diff_stat_output, "commit_files": files_to_commit, "regenerate": regenerate, + "amend": amend, + "orig_head": orig_head, "assistant": assistant, }, } if not util.send_channel_request(req): unregister_handler(job_id) - msg = "Commit cancelled (sending request to agent server failed)." - if not regenerate: - msg += " Reverting `git add`." + if amend and orig_head: + _revert_amend(repo_path, orig_head) + msg = "Amend cancelled (sending request to agent server failed). Reverting amend." + elif not regenerate: _revert_staged(repo_path) + msg = "Commit cancelled (sending request to agent server failed). Reverting `git add`." + else: + msg = "Commit cancelled (sending request to agent server failed)." util.display_message(msg, error=True) except FileNotFoundError: + if amend and orig_head: + _revert_amend(repo_path, orig_head) + elif repo_path and not regenerate: + _revert_staged(repo_path) util.display_message( "Error: `git` command not found. Is it in your PATH?", error=True ) except Exception as e: + if amend and orig_head: + _revert_amend(repo_path, orig_head) + elif repo_path and not regenerate: + _revert_staged(repo_path) util.display_message(f"Error: {e}", error=True) @@ -570,21 +864,42 @@ def finalize_commit(): try: tmp_filename = vim.eval("get(b:, 'vimini_commit_tmp_file', '')") repo_path = vim.eval("get(b:, 'vimini_commit_repo_path', '')") - regenerate = int(vim.eval("get(b:, 'vimini_commit_regenerate', 0)")) - assistant = int(vim.eval("get(b:, 'vimini_commit_assistant', 0)")) + raw_regen = vim.eval("get(b:, 'vimini_commit_regenerate', 0)") + regenerate = int(raw_regen) if raw_regen else 0 + raw_amend = vim.eval("get(b:, 'vimini_commit_amend', 0)") + amend = int(raw_amend) if raw_amend else 0 + raw_orig_head = vim.eval("get(b:, 'vimini_commit_orig_head', '')") + orig_head = str(raw_orig_head) if raw_orig_head else "" + raw_assistant = vim.eval("get(b:, 'vimini_commit_assistant', 0)") + assistant = int(raw_assistant) if raw_assistant else 0 raw_files = vim.eval("get(b:, 'vimini_commit_files', '')") commit_files = json.loads(raw_files) if raw_files else [] + raw_renames = vim.eval("get(b:, 'vimini_commit_renames', '')") + rename_pairs = json.loads(raw_renames) if raw_renames else [] except Exception as e: util.log_info(f"Failed to read commit variables: {e}") return - if not commit_files and tmp_filename in _commit_files_by_file: - commit_files = _commit_files_by_file.pop(tmp_filename, []) + meta = _commit_meta_by_file.pop(tmp_filename, {}) + if not commit_files: + commit_files = meta.get("files", _commit_files_by_file.pop(tmp_filename, [])) else: _commit_files_by_file.pop(tmp_filename, None) + if not orig_head: + orig_head = meta.get("orig_head", "") + if not amend: + amend = meta.get("amend", 0) + if not repo_path: + repo_path = meta.get("repo_path", "") + if not regenerate: + regenerate = meta.get("regenerate", 0) + if not rename_pairs: + rename_pairs = meta.get("renames", []) if not tmp_filename or not os.path.exists(tmp_filename): util.log_info("Failed to find commit log file") + if amend and orig_head and repo_path: + _revert_amend(repo_path, orig_head) return try: @@ -592,6 +907,8 @@ def finalize_commit(): content = f.read() os.remove(tmp_filename) except Exception as e: + if amend and orig_head and repo_path: + _revert_amend(repo_path, orig_head) util.display_message(f"Error reading commit message: {e}", error=True) return @@ -603,7 +920,13 @@ def finalize_commit(): break if not has_non_comment: - if regenerate: + if amend and orig_head: + util.display_message( + "Amend cancelled (no commit message saved). Reverting amend.", + error=True, + ) + _revert_amend(repo_path, orig_head) + elif regenerate: util.display_message( "Amend cancelled (no commit message saved).", error=True ) @@ -615,17 +938,87 @@ def finalize_commit(): _revert_staged(repo_path) return - # Parse file lines to find deselected files (marked with -) + # Parse file lines to find deselected files (marked with exactly #- ) deselected_files = [] if commit_files: - for line in content.split("\n"): - fn, is_deselected = parse_file_line(line, commit_files) - if fn and is_deselected and fn not in deselected_files: - deselected_files.append(fn) + stat_header = "# --- Diff stat ---" + if stat_header not in content: + if amend and orig_head: + util.display_message( + "Amend cancelled (commit was malformed: missing diff stat header). Reverting amend.", + error=True, + ) + _revert_amend(repo_path, orig_head) + elif regenerate: + util.display_message( + "Amend cancelled (commit was malformed: missing diff stat header).", + error=True, + ) + else: + util.display_message( + "Commit cancelled (commit was malformed: missing diff stat header). Reverting `git add`.", + error=True, + ) + _revert_staged(repo_path) + return + + commit_files_set = set(commit_files) + seen_included = set() + stat_content = content.split(stat_header, 1)[1] + for line in stat_content.split("\n"): + fn, is_deselected = parse_file_line(line, commit_files_set) + if fn: + rename_source, rename_target = None, None + if is_deselected: + body = line[3:].lstrip() + else: + body = line[1:].lstrip() + if "|" in body: + file_part = body.rsplit("|", 1)[0].strip() + if "=>" in file_part: + rename_source, rename_target = _extract_rename_paths(file_part) + + if is_deselected: + if fn not in deselected_files: + deselected_files.append(fn) + if rename_source and rename_source not in deselected_files: + deselected_files.append(rename_source) + if rename_target and rename_target not in deselected_files: + deselected_files.append(rename_target) + else: + seen_included.add(fn) + if rename_source: + seen_included.add(rename_source) + if rename_target: + seen_included.add(rename_target) + + if regenerate and not amend: + if deselected_files or any(fn not in seen_included for fn in commit_files): + util.display_message( + "Files cannot be deselected in regenerate mode", error=True + ) + return + else: + for fn in commit_files: + if fn not in seen_included and fn not in deselected_files: + deselected_files.append(fn) + + for s, t in rename_pairs: + if s in deselected_files or t in deselected_files: + if s not in deselected_files: + deselected_files.append(s) + if t not in deselected_files: + deselected_files.append(t) remaining_files = [f for f in commit_files if f not in deselected_files] if not remaining_files: - if regenerate: + if amend and orig_head: + util.display_message( + "Amend cancelled (no files selected to commit). Reverting amend.", + error=True, + ) + _revert_amend(repo_path, orig_head) + elif regenerate: util.display_message( "Amend cancelled (no files selected to commit).", error=True, @@ -639,69 +1032,106 @@ def finalize_commit(): return if deselected_files: - util.log_info(f"Removing deselected files from commit: {deselected_files}") - if regenerate: - head_parent_check = subprocess.run( - ["git", "-C", repo_path, "rev-parse", "--verify", "HEAD~1"], - capture_output=True, - check=False, + try: + util.log_info( + f"Removing deselected files from commit: {deselected_files}" ) - if head_parent_check.returncode == 0: + if amend and orig_head: unstage_cmd = [ "git", "-C", repo_path, "reset", - "HEAD~1", + orig_head, "--", ] + deselected_files + elif regenerate: + head_parent_check = subprocess.run( + ["git", "-C", repo_path, "rev-parse", "--verify", "HEAD~1"], + capture_output=True, + check=False, + ) + if head_parent_check.returncode == 0: + unstage_cmd = [ + "git", + "-C", + repo_path, + "reset", + "HEAD~1", + "--", + ] + deselected_files + else: + unstage_cmd = [ + "git", + "-C", + repo_path, + "rm", + "--cached", + "--", + ] + deselected_files else: - unstage_cmd = [ - "git", - "-C", - repo_path, - "rm", - "--cached", - "--", - ] + deselected_files - else: - head_check = subprocess.run( - ["git", "-C", repo_path, "rev-parse", "--verify", "HEAD"], + head_check = subprocess.run( + ["git", "-C", repo_path, "rev-parse", "--verify", "HEAD"], + capture_output=True, + check=False, + ) + if head_check.returncode == 0: + unstage_cmd = [ + "git", + "-C", + repo_path, + "reset", + "HEAD", + "--", + ] + deselected_files + else: + unstage_cmd = [ + "git", + "-C", + repo_path, + "rm", + "--cached", + "--", + ] + deselected_files + + unstage_res = subprocess.run( + unstage_cmd, capture_output=True, + text=True, + encoding="utf-8", + errors="replace", check=False, ) - if head_check.returncode == 0: - unstage_cmd = [ - "git", - "-C", - repo_path, - "reset", - "HEAD", - "--", - ] + deselected_files - else: - unstage_cmd = [ - "git", - "-C", - repo_path, - "rm", - "--cached", - "--", - ] + deselected_files - - unstage_res = subprocess.run( - unstage_cmd, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - check=False, - ) - if unstage_res.returncode != 0: - err = (unstage_res.stderr or unstage_res.stdout).strip() + if unstage_res.returncode != 0: + err = (unstage_res.stderr or unstage_res.stdout).strip() + util.display_message( + f"Failed to unstage deselected files: {err}", error=True + ) + if amend and orig_head: + _revert_amend(repo_path, orig_head) + elif not regenerate and repo_path: + _revert_staged(repo_path) + elif regenerate and repo_path: + subprocess.run( + ["git", "-C", repo_path, "reset", "HEAD", "--"], + capture_output=True, + check=False, + ) + return + except Exception as e: util.display_message( - f"Failed to unstage deselected files: {err}", error=True + f"Error unstaging deselected files: {e}", error=True ) + if amend and orig_head: + _revert_amend(repo_path, orig_head) + elif not regenerate and repo_path: + _revert_staged(repo_path) + elif regenerate and repo_path: + subprocess.run( + ["git", "-C", repo_path, "reset", "HEAD", "--"], + capture_output=True, + check=False, + ) return util.log_info("Commit Message accepted") @@ -741,10 +1171,28 @@ def finalize_commit(): else: error_message = (commit_result.stderr or commit_result.stdout).strip() action_past = "amend" if regenerate else "commit" + if amend and orig_head: + _revert_amend(repo_path, orig_head) + elif regenerate and repo_path: + subprocess.run( + ["git", "-C", repo_path, "reset", "HEAD", "--"], + capture_output=True, + check=False, + ) util.display_message( f"Git {action_past} failed: {error_message}", error=True ) except Exception as e: + if amend and orig_head: + _revert_amend(repo_path, orig_head) + elif regenerate and repo_path: + subprocess.run( + ["git", "-C", repo_path, "reset", "HEAD", "--"], + capture_output=True, + check=False, + ) + elif repo_path: + _revert_staged(repo_path) util.display_message(f"Error: {e}", error=True) diff --git a/python3/vimini/main.py b/python3/vimini/main.py index 6dea22d..013d5b7 100644 --- a/python3/vimini/main.py +++ b/python3/vimini/main.py @@ -309,8 +309,8 @@ def help(command_name=None): "", ":ViminiCommit [-n] [-r] [-a] [instruction]", " Generates a commit message and commits changes.", - " Files to be committed are listed at the bottom of the buffer as comments.", - " Press in Normal mode to toggle files off (marked with -) or on.", + " Files to be committed are listed in the diff stat comments.", + " Press in Normal mode to toggle files off (marked with #-) or on.", " -n: No co-author trailer.", " -r: Regenerate/Amend HEAD.", " -a: Amend changes into HEAD and regenerate commit message.", diff --git a/tests/test_apply_patch.py b/tests/test_apply_patch.py new file mode 100644 index 0000000..dd776cd --- /dev/null +++ b/tests/test_apply_patch.py @@ -0,0 +1,99 @@ +import sys +import os +import pytest +from unittest.mock import MagicMock, patch + +sys.path.insert( + 0, os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "python3")) +) + +from vimini.code import apply_patch + + +def test_apply_patch_empty_diff(): + success, err = apply_patch("", silent=True) + assert success is False + assert "empty" in err + + +def test_apply_patch_with_patch_command_success(tmp_path): + project_root = str(tmp_path) + diff_content = "diff --git a/file.py b/file.py\n--- a/file.py\n+++ b/file.py\n@@ -1 +1 @@\n-a\n+b\n" + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + success, err = apply_patch(diff_content, project_root=project_root, silent=True) + + assert success is True + assert err is None + patch_calls = [c for c in mock_run.call_args_list if c[0][0][0] == "patch"] + assert len(patch_calls) == 1 + assert patch_calls[0][0][0] == ["patch", "-p1", "-N", "-r", "-"] + + +def test_apply_patch_fallback_to_git_apply(tmp_path): + project_root = str(tmp_path) + diff_content = "diff --git a/file.py b/file.py\n--- a/file.py\n+++ b/file.py\n@@ -1 +1 @@\n-a\n+b\n" + + with patch("subprocess.run") as mock_run: + # First call (patch) raises FileNotFoundError, second call (git apply) succeeds + def fake_run(cmd, *args, **kwargs): + if cmd[0] == "patch": + raise FileNotFoundError("patch not found") + return MagicMock(returncode=0, stdout="", stderr="") + + mock_run.side_effect = fake_run + + success, err = apply_patch(diff_content, project_root=project_root, silent=True) + + assert success is True + assert err is None + git_calls = [c for c in mock_run.call_args_list if "apply" in c[0][0]] + assert len(git_calls) == 1 + git_cmd = git_calls[0][0][0] + assert git_cmd == [ + "git", + "-C", + project_root, + "apply", + "--whitespace=nowarn", + "-", + ] + + +def test_apply_patch_fallback_git_apply_failure(tmp_path): + project_root = str(tmp_path) + diff_content = "diff --git a/file.py b/file.py\n--- a/file.py\n+++ b/file.py\n@@ -1 +1 @@\n-a\n+b\n" + + with patch("subprocess.run") as mock_run: + + def fake_run(cmd, *args, **kwargs): + if cmd[0] == "patch": + raise FileNotFoundError("patch not found") + return MagicMock( + returncode=1, stdout="", stderr="error: patch does not apply" + ) + + mock_run.side_effect = fake_run + + success, err = apply_patch(diff_content, project_root=project_root, silent=True) + + assert success is False + assert "error: patch does not apply" in err + + +def test_apply_patch_both_patch_and_git_missing(tmp_path): + project_root = str(tmp_path) + diff_content = "diff --git a/file.py b/file.py\n--- a/file.py\n+++ b/file.py\n@@ -1 +1 @@\n-a\n+b\n" + + with patch("subprocess.run") as mock_run: + + def fake_run(cmd, *args, **kwargs): + raise FileNotFoundError("command not found") + + mock_run.side_effect = fake_run + + success, err = apply_patch(diff_content, project_root=project_root, silent=True) + + assert success is False + assert "commands not available" in err diff --git a/tests/test_commit_files.py b/tests/test_commit_files.py index df29782..62f10fb 100644 --- a/tests/test_commit_files.py +++ b/tests/test_commit_files.py @@ -11,13 +11,39 @@ from vimini.commit import ( parse_file_line, toggle_file, + _extract_rename_paths, handle_commit_response, + commit, finalize_commit, _revert_staged, + _revert_amend, _commit_files_by_file, + CommitChannelHandler, ) +def test_extract_rename_paths(): + assert _extract_rename_paths("{old => new}/file.txt") == ( + "old/file.txt", + "new/file.txt", + ) + assert _extract_rename_paths("{dir => }/file.txt") == ("dir/file.txt", "file.txt") + assert _extract_rename_paths("{ => dir}/file.txt") == ("file.txt", "dir/file.txt") + assert _extract_rename_paths("dir/{old => new}.py") == ("dir/old.py", "dir/new.py") + assert _extract_rename_paths("old.txt => new.txt") == ("old.txt", "new.txt") + assert _extract_rename_paths("dir/{old => new}") == ("dir/old", "dir/new") + assert _extract_rename_paths("no rename here") == (None, None) + assert _extract_rename_paths("old.txt => new.txt (100%)") == ("old.txt", "new.txt") + assert _extract_rename_paths("{old_dir => new_dir}/file.py (95%)") == ( + "old_dir/file.py", + "new_dir/file.py", + ) + assert _extract_rename_paths("dir/{old => new}.py (100%)") == ( + "dir/old.py", + "dir/new.py", + ) + + def test_parse_file_line(): commit_files = ["src/app.py", "README.md", "path/with space/file.txt"] @@ -26,19 +52,20 @@ def test_parse_file_line(): assert fn == "src/app.py" assert deselected is False - # Deselected file - fn, deselected = parse_file_line("# - src/app.py", commit_files) + # Deselected file with valid '#- ' indicator + fn, deselected = parse_file_line("#- src/app.py", commit_files) assert fn == "src/app.py" assert deselected is True - # Variations - fn, deselected = parse_file_line("# -src/app.py", commit_files) + fn, deselected = parse_file_line("#- src/app.py", commit_files) assert fn == "src/app.py" assert deselected is True - fn, deselected = parse_file_line("#- src/app.py", commit_files) - assert fn == "src/app.py" - assert deselected is True + # Invalid deselection formats: spaces between # and -, or lack of spaces after #- + assert parse_file_line("# - src/app.py", commit_files) == (None, None) + assert parse_file_line("# -src/app.py", commit_files) == (None, None) + assert parse_file_line("#-src/app.py", commit_files) == (None, None) + assert parse_file_line("#-\tsrc/app.py", commit_files) == (None, None) fn, deselected = parse_file_line("# src/app.py", commit_files) assert fn == "src/app.py" @@ -49,16 +76,105 @@ def test_parse_file_line(): assert fn == "path/with space/file.txt" assert deselected is False - fn, deselected = parse_file_line("# - path/with space/file.txt", commit_files) + fn, deselected = parse_file_line("#- path/with space/file.txt", commit_files) + assert fn == "path/with space/file.txt" + assert deselected is True + + # Diff stat formatted lines + fn, deselected = parse_file_line("# src/app.py | 5 ++---", commit_files) + assert fn == "src/app.py" + assert deselected is False + + fn, deselected = parse_file_line("#- src/app.py | 5 ++---", commit_files) + assert fn == "src/app.py" + assert deselected is True + + fn, deselected = parse_file_line( + "# path/with space/file.txt | 2 +-", commit_files + ) assert fn == "path/with space/file.txt" + assert deselected is False + + # Renamed files in diff stat + commit_files_rename = ["file.txt", "new_dir/sub.py", "app.py"] + fn, deselected = parse_file_line( + "# {old_dir => }/file.txt | 2 +-", commit_files_rename + ) + assert fn == "file.txt" + assert deselected is False + + fn, deselected = parse_file_line( + "#- {old_dir => }/file.txt | 2 +-", commit_files_rename + ) + assert fn == "file.txt" + assert deselected is True + + fn, deselected = parse_file_line( + "# old_app.py => app.py | 5 ++---", commit_files_rename + ) + assert fn == "app.py" + assert deselected is False + + # Renamed files with similarity percentage in diff stat + fn, deselected = parse_file_line( + "# old.txt => new.txt (100%) | 0", ["old.txt", "new.txt"] + ) + assert fn == "new.txt" + assert deselected is False + + fn, deselected = parse_file_line( + "#- old.txt => new.txt (100%) | 0", ["old.txt", "new.txt"] + ) + assert fn == "new.txt" + assert deselected is True + + fn, deselected = parse_file_line( + "# {old_dir => new_dir}/file.py (95%) | 2 +-", + ["old_dir/file.py", "new_dir/file.py"], + ) + assert fn == "new_dir/file.py" + assert deselected is False + + # Files with leading hyphens + commit_files_hyphens = ["-file.txt", "--flag.py"] + fn, deselected = parse_file_line("# -file.txt", commit_files_hyphens) + assert fn == "-file.txt" + assert deselected is False + + fn, deselected = parse_file_line("# -file.txt | 2 +-", commit_files_hyphens) + assert fn == "-file.txt" + assert deselected is False + + fn, deselected = parse_file_line("#- -file.txt", commit_files_hyphens) + assert fn == "-file.txt" + assert deselected is True + + fn, deselected = parse_file_line("#- -file.txt | 2 +-", commit_files_hyphens) + assert fn == "-file.txt" + assert deselected is True + + fn, deselected = parse_file_line("# --flag.py", commit_files_hyphens) + assert fn == "--flag.py" + assert deselected is False + + # Path with pipe character in diff stat + commit_files_pipe = ["path/to|file.txt"] + fn, deselected = parse_file_line("# path/to|file.txt | 2 +-", commit_files_pipe) + assert fn == "path/to|file.txt" + assert deselected is False + + fn, deselected = parse_file_line("#- path/to|file.txt | 2 +-", commit_files_pipe) + assert fn == "path/to|file.txt" assert deselected is True # Non-file lines should return None, None - assert parse_file_line("# --- Files to commit ---", commit_files) == (None, None) + assert parse_file_line("# --- Diff stat ---", commit_files) == (None, None) assert parse_file_line( "# (Press to toggle file inclusion)", commit_files ) == (None, None) - assert parse_file_line("# src/app.py | 5 ++---", commit_files) == (None, None) + assert parse_file_line( + "# 2 files changed, 5 insertions(+), 2 deletions(-)", commit_files + ) == (None, None) assert parse_file_line("# This is a normal comment", commit_files) == (None, None) assert parse_file_line("Commit subject line", commit_files) == (None, None) assert parse_file_line("", commit_files) == (None, None) @@ -71,9 +187,11 @@ def test_toggle_file(): mock_buffer = [ "Commit title", "", - "# --- Files to commit ---", - "# src/app.py", - "# - README.md", + "# --- Diff stat ---", + "# (Press to toggle file inclusion)", + "# src/app.py | 2 +-", + "#- README.md | 5 +++--", + "# 2 files changed", ] with ( @@ -81,29 +199,156 @@ def test_toggle_file(): patch.object(vim, "eval") as mock_eval, patch.object(vim, "command") as mock_cmd, ): - vim.current.window.cursor = (4, 4) # Row 4 is "# src/app.py" + vim.current.window.cursor = (5, 4) # Row 5 is "# src/app.py | 2 +-" mock_eval.side_effect = lambda expr: ( json.dumps(commit_files) if "vimini_commit_files" in expr else "" ) - # Toggle src/app.py (included -> deselected) + # Toggle src/app.py (included -> deselected, preserving stat) toggle_file() - assert vim.current.buffer[3] == "# - src/app.py" + assert vim.current.buffer[4] == "#- src/app.py | 2 +-" - # Toggle src/app.py again (deselected -> included) + # Toggle src/app.py again (deselected -> included, preserving stat) toggle_file() - assert vim.current.buffer[3] == "# src/app.py" + assert vim.current.buffer[4] == "# src/app.py | 2 +-" - # Move to row 5 ("# - README.md") and toggle (deselected -> included) - vim.current.window.cursor = (5, 4) + # Move to row 6 ("#- README.md | 5 +++--") and toggle (deselected -> included) + vim.current.window.cursor = (6, 4) toggle_file() - assert vim.current.buffer[4] == "# README.md" + assert vim.current.buffer[5] == "# README.md | 5 +++--" - # Move to non-file line (Row 3: header) and toggle -> should not modify line + # Move to non-file line (Row 3: header) -> should not modify line vim.current.window.cursor = (3, 2) toggle_file() - assert vim.current.buffer[2] == "# --- Files to commit ---" + assert vim.current.buffer[2] == "# --- Diff stat ---" + + # Move to summary line (Row 7) -> should not modify line + vim.current.window.cursor = (7, 2) + toggle_file() + assert vim.current.buffer[6] == "# 2 files changed" + + +def test_toggle_file_with_leading_hyphen(): + import vim + + commit_files = ["-file.txt", "--flag.py"] + mock_buffer = [ + "Commit title", + "", + "# --- Diff stat ---", + "# (Press to toggle file inclusion)", + "# -file.txt | 2 +-", + "# --flag.py | 1 +", + ] + + with ( + patch.object(vim.current, "buffer", mock_buffer), + patch.object(vim, "eval") as mock_eval, + patch.object(vim, "command") as mock_cmd, + ): + vim.current.window.cursor = (5, 4) + + mock_eval.side_effect = lambda expr: ( + json.dumps(commit_files) if "vimini_commit_files" in expr else "" + ) + + toggle_file() + assert vim.current.buffer[4] == "#- -file.txt | 2 +-" + toggle_file() + assert vim.current.buffer[4] == "# -file.txt | 2 +-" + + vim.current.window.cursor = (6, 4) + toggle_file() + assert vim.current.buffer[5] == "#- --flag.py | 1 +" + toggle_file() + assert vim.current.buffer[5] == "# --flag.py | 1 +" + + +def test_toggle_file_pure_regenerate_emits_error(): + import vim + + commit_files = ["src/app.py"] + mock_buffer = [ + "Commit title", + "", + "# --- Diff stat ---", + "# src/app.py | 2 +-", + ] + + def mock_eval(expr): + if "vimini_commit_files" in expr: + return json.dumps(commit_files) + if "vimini_commit_amend" in expr: + return 0 + if "vimini_commit_regenerate" in expr: + return 1 + if "vimini_commit_amend_files" in expr: + return json.dumps([]) + if "vimini_commit_tmp_file" in expr: + return "" + return "" + + with ( + patch.object(vim.current, "buffer", mock_buffer), + patch.object(vim, "eval", side_effect=mock_eval), + patch("vimini.util.display_message") as mock_display, + ): + vim.current.window.cursor = (4, 4) + toggle_file() + # Line should remain unchanged + assert vim.current.buffer[3] == "# src/app.py | 2 +-" + err_calls = [c for c in mock_display.call_args_list if c.kwargs.get("error")] + assert any( + "Files cannot be deselected in regenerate mode" in str(c) for c in err_calls + ) + + +def test_toggle_file_amend_allowed_and_disallowed(): + import vim + + commit_files = ["src/changed.py", "src/unchanged.py"] + amend_files = ["src/changed.py"] + mock_buffer = [ + "Commit title", + "", + "# --- Diff stat ---", + "# (Press to toggle file inclusion)", + "# src/changed.py | 2 +-", + "# src/unchanged.py | 1 +", + "#- src/unchanged.py | 1 +", + ] + + def mock_eval(expr): + if "vimini_commit_amend_files" in expr: + return json.dumps(amend_files) + if "vimini_commit_files" in expr: + return json.dumps(commit_files) + if "vimini_commit_amend" in expr: + return 1 + return "" + + with ( + patch.object(vim.current, "buffer", mock_buffer), + patch.object(vim, "eval", side_effect=mock_eval), + patch("vimini.util.display_message") as mock_display, + ): + # Trying to deselect unchanged file -> disallowed + vim.current.window.cursor = (6, 4) + toggle_file() + assert vim.current.buffer[5] == "# src/unchanged.py | 1 +" + err_calls = [c for c in mock_display.call_args_list if c.kwargs.get("error")] + assert any( + "This file is not being changed by the amendment" in str(c) + for c in err_calls + ) + + # Deselecting changed file -> allowed + mock_display.reset_mock() + vim.current.window.cursor = (5, 4) + toggle_file() + assert vim.current.buffer[4] == "#- src/changed.py | 2 +-" + assert not mock_display.called def test_handle_commit_response_adds_files_and_mapping(tmp_path): @@ -118,29 +363,158 @@ def test_handle_commit_response_adds_files_and_mapping(tmp_path): "assistant": True, } + fake_temp = tmp_path / "fake.gitcommit" + fake_temp.touch() mock_buffer = [] with ( patch.object(vim.current, "buffer", mock_buffer), patch("vimini.util.new_split") as mock_split, patch.object(vim, "command") as mock_cmd, patch("vimini.util.display_message") as mock_display, + patch("tempfile.NamedTemporaryFile") as mock_named_temp, ): - handle_commit_response("job_1", result) - - assert mock_split.called - # Check buffer lines - assert "Add user authentication" in vim.current.buffer - assert "Implements OAuth2 login." in vim.current.buffer - assert "# --- Diff stat ---" in vim.current.buffer - assert "# --- Files to commit ---" in vim.current.buffer - assert "# (Press to toggle file inclusion)" in vim.current.buffer - assert "# src/app.py" in vim.current.buffer + mock_named_temp.return_value.__enter__.return_value.name = str(fake_temp) + try: + handle_commit_response("job_1", result) + assert mock_split.called + # Check buffer lines + assert "Add user authentication" in vim.current.buffer + assert "Implements OAuth2 login." in vim.current.buffer + assert "# --- Diff stat ---" in vim.current.buffer + assert "# (Press to toggle file inclusion)" in vim.current.buffer + assert "# src/app.py | 2 +-" in vim.current.buffer + assert "# 1 file changed" in vim.current.buffer + assert "# --- Files to commit ---" not in vim.current.buffer + finally: + _commit_files_by_file.pop(str(fake_temp), None) + if fake_temp.exists(): + fake_temp.unlink() cmd_calls = [c[0][0] for c in mock_cmd.call_args_list] assert any("b:vimini_commit_files" in c for c in cmd_calls) assert any("ViminiCommitToggleFile" in c for c in cmd_calls) +def test_handle_commit_response_rename_no_extraneous_comment(tmp_path): + import vim + + result = { + "text": "Rename file\n---\nRenamed old.txt to new.txt", + "repo_path": "/dummy/repo", + "diff_stat_output": " old.txt => new.txt | 2 +-\n 1 file changed", + "commit_files": ["old.txt", "new.txt"], + "regenerate": True, + "assistant": True, + } + + fake_temp = tmp_path / "fake_rename.gitcommit" + fake_temp.touch() + mock_buffer = [] + with ( + patch.object(vim.current, "buffer", mock_buffer), + patch("vimini.util.new_split"), + patch.object(vim, "command"), + patch("vimini.util.display_message"), + patch("tempfile.NamedTemporaryFile") as mock_named_temp, + ): + mock_named_temp.return_value.__enter__.return_value.name = str(fake_temp) + try: + handle_commit_response("job_rename", result) + + assert "# old.txt => new.txt | 2 +-" in vim.current.buffer + # old.txt should NOT be appended as an extraneous bare line below diff stat + assert "# old.txt" not in vim.current.buffer + assert "# new.txt" not in vim.current.buffer + finally: + _commit_files_by_file.pop(str(fake_temp), None) + if fake_temp.exists(): + fake_temp.unlink() + + +def test_handle_commit_response_rename_with_similarity_percentages(tmp_path): + import vim + + result = { + "text": "Rename files\n---\nRenamed files with similarity percentage.", + "repo_path": "/dummy/repo", + "diff_stat_output": ( + " old.txt => new.txt (100%) | 0\n" + " {old_dir => new_dir}/file.py (95%) | 2 +-\n" + " 2 files changed" + ), + "commit_files": ["new.txt", "new_dir/file.py"], + "regenerate": True, + "assistant": True, + } + + fake_temp = tmp_path / "fake_rename_pct.gitcommit" + fake_temp.touch() + mock_buffer = [] + with ( + patch.object(vim.current, "buffer", mock_buffer), + patch("vimini.util.new_split"), + patch.object(vim, "command"), + patch("vimini.util.display_message"), + patch("tempfile.NamedTemporaryFile") as mock_named_temp, + ): + mock_named_temp.return_value.__enter__.return_value.name = str(fake_temp) + try: + handle_commit_response("job_rename_pct", result) + + assert "# old.txt => new.txt (100%) | 0" in vim.current.buffer + assert "# {old_dir => new_dir}/file.py (95%) | 2 +-" in vim.current.buffer + # Neither should be appended as bare comment lines at bottom + assert "# old.txt" not in vim.current.buffer + assert "# new.txt" not in vim.current.buffer + assert "# old_dir/file.py" not in vim.current.buffer + assert "# new_dir/file.py" not in vim.current.buffer + finally: + _commit_files_by_file.pop(str(fake_temp), None) + if fake_temp.exists(): + fake_temp.unlink() + + +def test_handle_commit_response_pure_regenerate_no_space_hint(tmp_path): + import vim + + result = { + "text": "Regen subject\n---\nRegen body", + "repo_path": "/dummy/repo", + "diff_stat_output": " src/app.py | 2 +-\n 1 file changed", + "commit_files": ["src/app.py"], + "regenerate": True, + "amend": False, + "assistant": True, + } + + fake_temp = tmp_path / "fake_regen_nospace.gitcommit" + fake_temp.touch() + mock_buffer = [] + with ( + patch.object(vim.current, "buffer", mock_buffer), + patch("vimini.util.new_split"), + patch.object(vim, "command"), + patch("vimini.util.display_message") as mock_display, + patch("tempfile.NamedTemporaryFile") as mock_named_temp, + ): + mock_named_temp.return_value.__enter__.return_value.name = str(fake_temp) + try: + handle_commit_response("job_regen_nospace", result) + + assert "# --- Diff stat ---" in vim.current.buffer + assert ( + "# (Press to toggle file inclusion)" not in vim.current.buffer + ) + assert "# src/app.py | 2 +-" in vim.current.buffer + calls = [str(c) for c in mock_display.call_args_list] + assert any("Review the commit message. Save and close" in c for c in calls) + assert not any("Toggle files with " in c for c in calls) + finally: + _commit_files_by_file.pop(str(fake_temp), None) + if fake_temp.exists(): + fake_temp.unlink() + + def test_finalize_commit_removes_deselected_files(tmp_path): import vim @@ -151,9 +525,10 @@ def test_finalize_commit_removes_deselected_files(tmp_path): content = ( "Commit subject\n\n" "Commit body\n\n" - "# --- Files to commit ---\n" - "# src/included.py\n" - "# - src/excluded.py\n" + "# --- Diff stat ---\n" + "# (Press to toggle file inclusion)\n" + "# src/included.py | 2 +-\n" + "#- src/excluded.py | 3 +--\n" ) tmp_file.write_text(content, encoding="utf-8") @@ -197,15 +572,19 @@ def mock_eval_fn(expr): assert len(commit_calls) == 1 -def test_finalize_commit_all_files_deselected_cancels(tmp_path): +def test_finalize_commit_renamed_file_deselected_resets_both_paths(tmp_path): import vim - tmp_file = tmp_path / "commit_msg_all_deselected.txt" + tmp_file = tmp_path / "commit_msg_rename_deselected.txt" repo_path = str(tmp_path) - commit_files = ["file1.py", "file2.py"] + commit_files = ["kept.py", "old.txt", "new.txt"] content = ( - "Commit subject\n\n# --- Files to commit ---\n# - file1.py\n# - file2.py\n" + "Commit subject\n\n" + "# --- Diff stat ---\n" + "# (Press to toggle file inclusion)\n" + "# kept.py | 1 +\n" + "#- old.txt => new.txt | 2 +-\n" ) tmp_file.write_text(content, encoding="utf-8") @@ -220,37 +599,45 @@ def mock_eval_fn(expr): return 0 if "vimini_commit_files" in expr: return json.dumps(commit_files) + if "vimini_commit_renames" in expr: + return json.dumps([["old.txt", "new.txt"]]) return "" with ( patch.object(vim, "eval", side_effect=mock_eval_fn), patch("subprocess.run") as mock_run, - patch("vimini.util.display_message") as mock_display, + patch("vimini.util.display_message"), ): - mock_proc_ok = MagicMock(returncode=0, stdout="", stderr="") + mock_proc_ok = MagicMock( + returncode=0, stdout="[main abc1234] Commit subject", stderr="" + ) mock_run.return_value = mock_proc_ok finalize_commit() - # Should display cancellation error - err_calls = [c for c in mock_display.call_args_list if c.kwargs.get("error")] - assert any("no files selected to commit" in str(c) for c in err_calls) - - # git commit should NOT be called run_cmds = [c[0][0] for c in mock_run.call_args_list] - commit_calls = [cmd for cmd in run_cmds if "commit" in cmd and "-s" in cmd] - assert len(commit_calls) == 0 + + # Both source (old.txt) and target (new.txt) must be unstaged via reset + unstage_calls = [cmd for cmd in run_cmds if "reset" in cmd] + assert len(unstage_calls) == 1 + assert "old.txt" in unstage_calls[0] + assert "new.txt" in unstage_calls[0] + assert "kept.py" not in unstage_calls[0] -def test_finalize_commit_regenerate_uses_head_parent(tmp_path): +def test_finalize_commit_renamed_file_deleted_line_resets_both_paths(tmp_path): import vim - tmp_file = tmp_path / "commit_msg_regen.txt" + tmp_file = tmp_path / "commit_msg_rename_deleted.txt" repo_path = str(tmp_path) - commit_files = ["fileA.py", "fileB.py"] + commit_files = ["kept.py", "old.txt", "new.txt"] + # The rename line is completely deleted from the diff stat content = ( - "Amended subject\n\n# --- Files in commit ---\n# fileA.py\n# - fileB.py\n" + "Commit subject\n\n" + "# --- Diff stat ---\n" + "# (Press to toggle file inclusion)\n" + "# kept.py | 1 +\n" ) tmp_file.write_text(content, encoding="utf-8") @@ -260,33 +647,1200 @@ def mock_eval_fn(expr): if "vimini_commit_repo_path" in expr: return repo_path if "vimini_commit_regenerate" in expr: - return 1 + return 0 if "vimini_commit_assistant" in expr: return 0 if "vimini_commit_files" in expr: return json.dumps(commit_files) + if "vimini_commit_renames" in expr: + return json.dumps([["old.txt", "new.txt"]]) return "" with ( patch.object(vim, "eval", side_effect=mock_eval_fn), patch("subprocess.run") as mock_run, - patch("vimini.util.display_message") as mock_display, + patch("vimini.util.display_message"), ): - mock_proc_ok = MagicMock(returncode=0, stdout="", stderr="") + mock_proc_ok = MagicMock( + returncode=0, stdout="[main abc1234] Commit subject", stderr="" + ) mock_run.return_value = mock_proc_ok finalize_commit() run_cmds = [c[0][0] for c in mock_run.call_args_list] - # Verify unstage in regenerate mode resets against HEAD~1 - unstage_calls = [ - cmd - for cmd in run_cmds - if "reset" in cmd and "HEAD~1" in cmd and "fileB.py" in cmd - ] + unstage_calls = [cmd for cmd in run_cmds if "reset" in cmd] assert len(unstage_calls) == 1 + assert "old.txt" in unstage_calls[0] + assert "new.txt" in unstage_calls[0] + assert "kept.py" not in unstage_calls[0] + + +def test_finalize_commit_included_file_starting_with_hyphen(tmp_path): + import vim + + tmp_file = tmp_path / "commit_msg_hyphen.txt" + repo_path = str(tmp_path) + commit_files = ["-old.txt", "new.txt", "-standalone.py"] + + # Included files starting with hyphens: + # 1. Renamed file starting with hyphen: -old.txt => new.txt + # 2. Standalone file starting with hyphen: -standalone.py + content = ( + "Commit subject\n\n" + "# --- Diff stat ---\n" + "# (Press to toggle file inclusion)\n" + "# -old.txt => new.txt | 2 +-\n" + "# -standalone.py | 1 +\n" + ) + tmp_file.write_text(content, encoding="utf-8") + + def mock_eval_fn(expr): + if "vimini_commit_tmp_file" in expr: + return str(tmp_file) + if "vimini_commit_repo_path" in expr: + return repo_path + if "vimini_commit_regenerate" in expr: + return 0 + if "vimini_commit_assistant" in expr: + return 0 + if "vimini_commit_files" in expr: + return json.dumps(commit_files) + if "vimini_commit_renames" in expr: + return json.dumps([["-old.txt", "new.txt"]]) + return "" + + with ( + patch.object(vim, "eval", side_effect=mock_eval_fn), + patch("subprocess.run") as mock_run, + patch("vimini.util.display_message"), + ): + mock_proc_ok = MagicMock( + returncode=0, stdout="[main abc1234] Commit subject", stderr="" + ) + mock_run.return_value = mock_proc_ok + + finalize_commit() + + run_cmds = [c[0][0] for c in mock_run.call_args_list] + + # No unstage reset calls should occur since all files were included + unstage_calls = [cmd for cmd in run_cmds if "reset" in cmd] + assert len(unstage_calls) == 0 - # Verify commit command includes --amend - commit_calls = [cmd for cmd in run_cmds if "commit" in cmd and "--amend" in cmd] + # Commit should proceed with all files + commit_calls = [cmd for cmd in run_cmds if "commit" in cmd and "-s" in cmd] assert len(commit_calls) == 1 + + +def test_finalize_commit_all_files_deselected_cancels(tmp_path): + import vim + + tmp_file = tmp_path / "commit_msg_all_deselected.txt" + repo_path = str(tmp_path) + commit_files = ["file1.py", "file2.py"] + + content = ( + "Commit subject\n\n" + "# --- Diff stat ---\n" + "# (Press to toggle file inclusion)\n" + "#- file1.py | 1 +\n" + "#- file2.py | 1 -\n" + ) + tmp_file.write_text(content, encoding="utf-8") + + def mock_eval_fn(expr): + if "vimini_commit_tmp_file" in expr: + return str(tmp_file) + if "vimini_commit_repo_path" in expr: + return repo_path + if "vimini_commit_regenerate" in expr: + return 0 + if "vimini_commit_assistant" in expr: + return 0 + if "vimini_commit_files" in expr: + return json.dumps(commit_files) + return "" + + with ( + patch.object(vim, "eval", side_effect=mock_eval_fn), + patch("subprocess.run") as mock_run, + patch("vimini.util.display_message") as mock_display, + ): + mock_proc_ok = MagicMock(returncode=0, stdout="", stderr="") + mock_run.return_value = mock_proc_ok + + finalize_commit() + + # Should display cancellation error + err_calls = [c for c in mock_display.call_args_list if c.kwargs.get("error")] + assert any("no files selected to commit" in str(c) for c in err_calls) + + # git commit should NOT be called + run_cmds = [c[0][0] for c in mock_run.call_args_list] + commit_calls = [cmd for cmd in run_cmds if "commit" in cmd and "-s" in cmd] + assert len(commit_calls) == 0 + + +def test_finalize_commit_pure_regenerate_disallows_deselection(tmp_path): + import vim + + tmp_file = tmp_path / "commit_msg_regen.txt" + repo_path = str(tmp_path) + commit_files = ["fileA.py", "fileB.py"] + + content = ( + "Amended subject\n\n" + "# --- Diff stat ---\n" + "# fileA.py | 1 +\n" + "#- fileB.py | 1 -\n" + ) + tmp_file.write_text(content, encoding="utf-8") + + def mock_eval_fn(expr): + if "vimini_commit_tmp_file" in expr: + return str(tmp_file) + if "vimini_commit_repo_path" in expr: + return repo_path + if "vimini_commit_regenerate" in expr: + return 1 + if "vimini_commit_amend" in expr: + return 0 + if "vimini_commit_assistant" in expr: + return 0 + if "vimini_commit_files" in expr: + return json.dumps(commit_files) + return "" + + with ( + patch.object(vim, "eval", side_effect=mock_eval_fn), + patch("subprocess.run") as mock_run, + patch("vimini.util.display_message") as mock_display, + ): + mock_proc_ok = MagicMock(returncode=0, stdout="", stderr="") + mock_run.return_value = mock_proc_ok + + finalize_commit() + + err_calls = [c for c in mock_display.call_args_list if c.kwargs.get("error")] + assert any( + "Files cannot be deselected in regenerate mode" in str(c) for c in err_calls + ) + + run_cmds = [c[0][0] for c in mock_run.call_args_list] + assert not any("commit" in cmd for cmd in run_cmds) + assert not any("HEAD~1" in cmd for cmd in run_cmds) + + +def test_finalize_commit_pure_regenerate_disallows_deleted_diff_stat_line(tmp_path): + import vim + + tmp_file = tmp_path / "commit_msg_regen_deleted.txt" + repo_path = str(tmp_path) + commit_files = ["fileA.py", "fileB.py"] + + # fileB.py deleted from diff stat + content = "Amended subject\n\n# --- Diff stat ---\n# fileA.py | 1 +\n" + tmp_file.write_text(content, encoding="utf-8") + + def mock_eval_fn(expr): + if "vimini_commit_tmp_file" in expr: + return str(tmp_file) + if "vimini_commit_repo_path" in expr: + return repo_path + if "vimini_commit_regenerate" in expr: + return 1 + if "vimini_commit_amend" in expr: + return 0 + if "vimini_commit_assistant" in expr: + return 0 + if "vimini_commit_files" in expr: + return json.dumps(commit_files) + return "" + + with ( + patch.object(vim, "eval", side_effect=mock_eval_fn), + patch("subprocess.run") as mock_run, + patch("vimini.util.display_message") as mock_display, + ): + finalize_commit() + + err_calls = [c for c in mock_display.call_args_list if c.kwargs.get("error")] + assert any( + "Files cannot be deselected in regenerate mode" in str(c) for c in err_calls + ) + + run_cmds = [c[0][0] for c in mock_run.call_args_list] + assert not any("commit" in cmd for cmd in run_cmds) + + +def test_finalize_commit_unstage_failure_reverts_staged(tmp_path): + import vim + + tmp_file = tmp_path / "commit_msg_fail.txt" + repo_path = str(tmp_path) + commit_files = ["src/included.py", "src/excluded.py"] + + content = ( + "Commit subject\n\n" + "# --- Diff stat ---\n" + "# (Press to toggle file inclusion)\n" + "# src/included.py | 1 +\n" + "#- src/excluded.py | 1 -\n" + ) + tmp_file.write_text(content, encoding="utf-8") + + def mock_eval_fn(expr): + if "vimini_commit_tmp_file" in expr: + return str(tmp_file) + if "vimini_commit_repo_path" in expr: + return repo_path + if "vimini_commit_regenerate" in expr: + return 0 + if "vimini_commit_assistant" in expr: + return 0 + if "vimini_commit_files" in expr: + return json.dumps(commit_files) + return "" + + with ( + patch.object(vim, "eval", side_effect=mock_eval_fn), + patch("subprocess.run") as mock_run, + patch("vimini.commit._revert_staged") as mock_revert, + patch("vimini.util.display_message") as mock_display, + ): + + def fake_run(cmd, *args, **kwargs): + if "reset" in cmd and "src/excluded.py" in cmd: + return MagicMock( + returncode=1, stderr="fatal error unstaging", stdout="" + ) + return MagicMock(returncode=0, stdout="", stderr="") + + mock_run.side_effect = fake_run + + finalize_commit() + + # Should display error message + err_calls = [c for c in mock_display.call_args_list if c.kwargs.get("error")] + assert any("Failed to unstage deselected files" in str(c) for c in err_calls) + + # Must revert staged changes on failure + assert mock_revert.called + mock_revert.assert_called_once_with(repo_path) + + +def test_finalize_commit_unstage_exception_reverts_staged(tmp_path): + import vim + + tmp_file = tmp_path / "commit_msg_exc.txt" + repo_path = str(tmp_path) + commit_files = ["src/included.py", "src/excluded.py"] + + content = ( + "Commit subject\n\n" + "# --- Diff stat ---\n" + "# (Press to toggle file inclusion)\n" + "# src/included.py | 1 +\n" + "#- src/excluded.py | 1 -\n" + ) + tmp_file.write_text(content, encoding="utf-8") + + def mock_eval_fn(expr): + if "vimini_commit_tmp_file" in expr: + return str(tmp_file) + if "vimini_commit_repo_path" in expr: + return repo_path + if "vimini_commit_regenerate" in expr: + return 0 + if "vimini_commit_assistant" in expr: + return 0 + if "vimini_commit_files" in expr: + return json.dumps(commit_files) + return "" + + with ( + patch.object(vim, "eval", side_effect=mock_eval_fn), + patch("subprocess.run") as mock_run, + patch("vimini.commit._revert_staged") as mock_revert, + patch("vimini.util.display_message") as mock_display, + ): + mock_run.side_effect = OSError("git process failed") + + finalize_commit() + + err_calls = [c for c in mock_display.call_args_list if c.kwargs.get("error")] + assert any("Error unstaging deselected files" in str(c) for c in err_calls) + assert mock_revert.called + mock_revert.assert_called_once_with(repo_path) + + +def test_finalize_commit_deleted_file_lines_deselected(tmp_path): + import vim + + tmp_file = tmp_path / "commit_msg_deleted_line.txt" + repo_path = str(tmp_path) + commit_files = ["src/kept.py", "src/deleted.py"] + + # User deleted "# src/deleted.py | 1 -" line completely + content = ( + "Commit subject\n\n" + "# --- Diff stat ---\n" + "# (Press to toggle file inclusion)\n" + "# src/kept.py | 1 +\n" + ) + tmp_file.write_text(content, encoding="utf-8") + + def mock_eval_fn(expr): + if "vimini_commit_tmp_file" in expr: + return str(tmp_file) + if "vimini_commit_repo_path" in expr: + return repo_path + if "vimini_commit_regenerate" in expr: + return 0 + if "vimini_commit_assistant" in expr: + return 0 + if "vimini_commit_files" in expr: + return json.dumps(commit_files) + return "" + + with ( + patch.object(vim, "eval", side_effect=mock_eval_fn), + patch("subprocess.run") as mock_run, + patch("vimini.util.display_message") as mock_display, + ): + mock_proc_ok = MagicMock( + returncode=0, stdout="[main abc1234] Commit subject", stderr="" + ) + mock_run.return_value = mock_proc_ok + + finalize_commit() + + run_cmds = [c[0][0] for c in mock_run.call_args_list] + + # Deleted file should be unstaged via reset + unstage_calls = [ + cmd for cmd in run_cmds if "reset" in cmd and "src/deleted.py" in cmd + ] + assert len(unstage_calls) == 1 + assert "src/kept.py" not in unstage_calls[0] + + # Kept file should proceed to commit + commit_calls = [cmd for cmd in run_cmds if "commit" in cmd and "-s" in cmd] + assert len(commit_calls) == 1 + + +def test_revert_amend_invokes_git_reset(tmp_path): + repo_path = str(tmp_path) + orig_head = "deadbeef1234" + + with patch("subprocess.run") as mock_run: + _revert_amend(repo_path, orig_head) + assert mock_run.called + cmd = mock_run.call_args[0][0] + assert cmd == ["git", "-C", repo_path, "reset", orig_head] + + +def test_finalize_commit_amend_aborted_by_quitting_reverts_amend(tmp_path): + import vim + + tmp_file = tmp_path / "commit_msg_empty.txt" + # Empty content simulates closing the buffer without saving + tmp_file.write_text("", encoding="utf-8") + repo_path = str(tmp_path) + orig_head = "orig_commit_sha_123" + + def mock_eval_fn(expr): + if "vimini_commit_tmp_file" in expr: + return str(tmp_file) + if "vimini_commit_repo_path" in expr: + return repo_path + if "vimini_commit_regenerate" in expr: + return 1 + if "vimini_commit_amend" in expr: + return 1 + if "vimini_commit_orig_head" in expr: + return orig_head + if "vimini_commit_assistant" in expr: + return 0 + if "vimini_commit_files" in expr: + return json.dumps(["file1.py"]) + return "" + + with ( + patch.object(vim, "eval", side_effect=mock_eval_fn), + patch("vimini.commit._revert_amend") as mock_revert_amend, + patch("vimini.util.display_message") as mock_display, + ): + finalize_commit() + + # Must call _revert_amend with repo_path and orig_head + assert mock_revert_amend.called + mock_revert_amend.assert_called_once_with(repo_path, orig_head) + + # Should display amend cancellation with revert note + err_calls = [c for c in mock_display.call_args_list if c.kwargs.get("error")] + assert any( + "Amend cancelled" in str(c) and "Reverting amend" in str(c) + for c in err_calls + ) + + +def test_finalize_commit_amend_all_files_deselected_reverts_amend(tmp_path): + import vim + + tmp_file = tmp_path / "commit_msg_amend_deselected.txt" + repo_path = str(tmp_path) + orig_head = "orig_commit_sha_456" + commit_files = ["f1.py", "f2.py"] + + content = ( + "Amend subject\n\n" + "# --- Diff stat ---\n" + "# (Press to toggle file inclusion)\n" + "#- f1.py | 1 +\n" + "#- f2.py | 1 -\n" + ) + tmp_file.write_text(content, encoding="utf-8") + + def mock_eval_fn(expr): + if "vimini_commit_tmp_file" in expr: + return str(tmp_file) + if "vimini_commit_repo_path" in expr: + return repo_path + if "vimini_commit_regenerate" in expr: + return 1 + if "vimini_commit_amend" in expr: + return 1 + if "vimini_commit_orig_head" in expr: + return orig_head + if "vimini_commit_assistant" in expr: + return 0 + if "vimini_commit_files" in expr: + return json.dumps(commit_files) + return "" + + with ( + patch.object(vim, "eval", side_effect=mock_eval_fn), + patch("vimini.commit._revert_amend") as mock_revert_amend, + patch("vimini.util.display_message") as mock_display, + ): + finalize_commit() + + assert mock_revert_amend.called + mock_revert_amend.assert_called_once_with(repo_path, orig_head) + + err_calls = [c for c in mock_display.call_args_list if c.kwargs.get("error")] + assert any( + "Amend cancelled" in str(c) and "Reverting amend" in str(c) + for c in err_calls + ) + + +def test_finalize_commit_amend_commit_failure_reverts_amend(tmp_path): + import vim + + tmp_file = tmp_path / "commit_msg_amend_fail.txt" + repo_path = str(tmp_path) + orig_head = "orig_commit_sha_789" + commit_files = ["f1.py"] + + content = ( + "Amend subject\n\n" + "# --- Diff stat ---\n" + "# (Press to toggle file inclusion)\n" + "# f1.py | 1 +\n" + ) + tmp_file.write_text(content, encoding="utf-8") + + def mock_eval_fn(expr): + if "vimini_commit_tmp_file" in expr: + return str(tmp_file) + if "vimini_commit_repo_path" in expr: + return repo_path + if "vimini_commit_regenerate" in expr: + return 1 + if "vimini_commit_amend" in expr: + return 1 + if "vimini_commit_orig_head" in expr: + return orig_head + if "vimini_commit_assistant" in expr: + return 0 + if "vimini_commit_files" in expr: + return json.dumps(commit_files) + return "" + + with ( + patch.object(vim, "eval", side_effect=mock_eval_fn), + patch("subprocess.run") as mock_run, + patch("vimini.commit._revert_amend") as mock_revert_amend, + patch("vimini.util.display_message") as mock_display, + ): + # Simulate final git commit failing (e.g. hook rejected) + mock_run.return_value = MagicMock( + returncode=1, stderr="pre-commit hook failed", stdout="" + ) + + finalize_commit() + + assert mock_revert_amend.called + mock_revert_amend.assert_called_once_with(repo_path, orig_head) + + err_calls = [c for c in mock_display.call_args_list if c.kwargs.get("error")] + assert any("Git amend failed" in str(c) for c in err_calls) + + +def test_finalize_commit_regenerate_commit_failure_resets_index(tmp_path): + import vim + + tmp_file = tmp_path / "commit_msg_regen_fail.txt" + repo_path = str(tmp_path) + commit_files = ["f1.py", "f2.py"] + + content = "Regen subject\n\n# --- Diff stat ---\n# f1.py | 1 +\n# f2.py | 1 -\n" + tmp_file.write_text(content, encoding="utf-8") + + def mock_eval_fn(expr): + if "vimini_commit_tmp_file" in expr: + return str(tmp_file) + if "vimini_commit_repo_path" in expr: + return repo_path + if "vimini_commit_regenerate" in expr: + return 1 + if "vimini_commit_amend" in expr: + return 0 + if "vimini_commit_orig_head" in expr: + return "" + if "vimini_commit_assistant" in expr: + return 0 + if "vimini_commit_files" in expr: + return json.dumps(commit_files) + return "" + + with ( + patch.object(vim, "eval", side_effect=mock_eval_fn), + patch("subprocess.run") as mock_run, + patch("vimini.util.display_message") as mock_display, + ): + mock_run.side_effect = lambda cmd, *a, **kw: ( + MagicMock(returncode=1, stderr="pre-commit hook failed", stdout="") + if "commit" in cmd + else MagicMock(returncode=0, stdout="", stderr="") + ) + + finalize_commit() + + run_cmds = [c[0][0] for c in mock_run.call_args_list] + assert ["git", "-C", repo_path, "reset", "HEAD", "--"] in run_cmds + err_calls = [c for c in mock_display.call_args_list if c.kwargs.get("error")] + assert any("Git amend failed" in str(c) for c in err_calls) + + +def test_finalize_commit_outer_exception_reverts_staged(tmp_path): + import vim + + tmp_file = tmp_path / "commit_msg_outer_exc.txt" + repo_path = str(tmp_path) + commit_files = ["file1.py"] + + content = ( + "Commit subject\n\n" + "Commit body\n\n" + "# --- Diff stat ---\n" + "# (Press to toggle file inclusion)\n" + "# file1.py | 1 +\n" + ) + tmp_file.write_text(content, encoding="utf-8") + + def mock_eval_fn(expr): + if "vimini_commit_tmp_file" in expr: + return str(tmp_file) + if "vimini_commit_repo_path" in expr: + return repo_path + if "vimini_commit_regenerate" in expr: + return 0 + if "vimini_commit_amend" in expr: + return 0 + if "vimini_commit_files" in expr: + return json.dumps(commit_files) + return "" + + with ( + patch.object(vim, "eval", side_effect=mock_eval_fn), + patch("subprocess.run") as mock_run, + patch("vimini.commit._revert_staged") as mock_revert_staged, + patch("vimini.util.display_message") as mock_display, + ): + mock_run.side_effect = RuntimeError("unexpected subprocess error") + + finalize_commit() + + assert mock_revert_staged.called + mock_revert_staged.assert_called_once_with(repo_path) + err_calls = [c for c in mock_display.call_args_list if c.kwargs.get("error")] + assert any("unexpected subprocess error" in str(c) for c in err_calls) + + +def test_finalize_commit_missing_diff_stat_header_reverts_staged(tmp_path): + import vim + + tmp_file = tmp_path / "commit_msg_no_header.txt" + repo_path = str(tmp_path) + commit_files = ["file1.py"] + + # Diff stat header "# --- Diff stat ---" was removed/omitted + content = "Commit subject\n\nCommit body\n# file1.py | 1 +\n" + tmp_file.write_text(content, encoding="utf-8") + + def mock_eval_fn(expr): + if "vimini_commit_tmp_file" in expr: + return str(tmp_file) + if "vimini_commit_repo_path" in expr: + return repo_path + if "vimini_commit_regenerate" in expr: + return 0 + if "vimini_commit_amend" in expr: + return 0 + if "vimini_commit_files" in expr: + return json.dumps(commit_files) + return "" + + with ( + patch.object(vim, "eval", side_effect=mock_eval_fn), + patch("subprocess.run") as mock_run, + patch("vimini.commit._revert_staged") as mock_revert_staged, + patch("vimini.util.display_message") as mock_display, + ): + finalize_commit() + + assert mock_revert_staged.called + mock_revert_staged.assert_called_once_with(repo_path) + err_calls = [c for c in mock_display.call_args_list if c.kwargs.get("error")] + assert any( + "commit was malformed: missing diff stat header" in str(c) + for c in err_calls + ) + assert not any("commit" in c[0][0] for c in mock_run.call_args_list) + + +def test_finalize_commit_missing_diff_stat_header_reverts_amend(tmp_path): + import vim + + tmp_file = tmp_path / "commit_msg_no_header_amend.txt" + repo_path = str(tmp_path) + orig_head = "sha_orig_123" + commit_files = ["file1.py"] + + # Diff stat header "# --- Diff stat ---" was removed/omitted + content = "Commit subject\n\nCommit body\n# file1.py | 1 +\n" + tmp_file.write_text(content, encoding="utf-8") + + def mock_eval_fn(expr): + if "vimini_commit_tmp_file" in expr: + return str(tmp_file) + if "vimini_commit_repo_path" in expr: + return repo_path + if "vimini_commit_regenerate" in expr: + return 1 + if "vimini_commit_amend" in expr: + return 1 + if "vimini_commit_orig_head" in expr: + return orig_head + if "vimini_commit_files" in expr: + return json.dumps(commit_files) + return "" + + with ( + patch.object(vim, "eval", side_effect=mock_eval_fn), + patch("subprocess.run") as mock_run, + patch("vimini.commit._revert_amend") as mock_revert_amend, + patch("vimini.util.display_message") as mock_display, + ): + finalize_commit() + + assert mock_revert_amend.called + mock_revert_amend.assert_called_once_with(repo_path, orig_head) + err_calls = [c for c in mock_display.call_args_list if c.kwargs.get("error")] + assert any( + "commit was malformed: missing diff stat header" in str(c) + for c in err_calls + ) + assert not any("commit" in c[0][0] for c in mock_run.call_args_list) + + +def test_commit_stat_commands_include_stat_name_width(tmp_path): + repo_path = str(tmp_path) + with ( + patch("vimini.util.get_git_repo_root", return_value=repo_path), + patch("vimini.commit._run_format_command", return_value=True), + patch("vimini.commit._stage_changes", return_value=True), + patch("vimini.util.send_channel_request", return_value=True), + patch("subprocess.run") as mock_run, + ): + mock_proc_diff = MagicMock(returncode=0, stdout="diff --git a/f b/f", stderr="") + mock_proc_stat = MagicMock(returncode=0, stdout="f | 1 +", stderr="") + mock_proc_files = MagicMock(returncode=0, stdout="f\0", stderr="") + + def fake_run(cmd, *args, **kwargs): + if "--stat" in cmd: + return mock_proc_stat + if "--name-only" in cmd: + return mock_proc_files + return mock_proc_diff + + mock_run.side_effect = fake_run + + commit(regenerate=False) + stat_calls = [c[0][0] for c in mock_run.call_args_list if "--stat" in c[0][0]] + assert len(stat_calls) == 1 + assert "--stat-width=1000" in stat_calls[0] + assert "--stat-name-width=1000" in stat_calls[0] + assert "--stat-graph-width=10" in stat_calls[0] + assert "-c" in stat_calls[0] + assert "core.quotepath=false" in stat_calls[0] + + mock_run.reset_mock() + commit(regenerate=True) + stat_calls_regen = [ + c[0][0] for c in mock_run.call_args_list if "--stat" in c[0][0] + ] + assert len(stat_calls_regen) == 1 + assert "--stat-width=1000" in stat_calls_regen[0] + assert "--stat-name-width=1000" in stat_calls_regen[0] + assert "--stat-graph-width=10" in stat_calls_regen[0] + assert "-c" in stat_calls_regen[0] + assert "core.quotepath=false" in stat_calls_regen[0] + + +def test_commit_amend_initial_commit_failure_reverts_staged(tmp_path): + repo_path = str(tmp_path) + with ( + patch("vimini.util.get_git_repo_root", return_value=repo_path), + patch("vimini.commit._run_format_command", return_value=True), + patch("vimini.commit._stage_changes", return_value=True), + patch("vimini.commit._revert_staged") as mock_revert_staged, + patch("vimini.util.display_message") as mock_display, + patch("subprocess.run") as mock_run, + ): + mock_rev_parse = MagicMock(returncode=0, stdout="current_head_sha\n", stderr="") + mock_amend_fail = MagicMock( + returncode=1, stderr="pre-commit hook failed", stdout="" + ) + + def fake_run(cmd, *args, **kwargs): + if "rev-parse" in cmd: + return mock_rev_parse + if "commit" in cmd and "--amend" in cmd: + return mock_amend_fail + return MagicMock(returncode=0, stdout="", stderr="") + + mock_run.side_effect = fake_run + + commit(amend=True) + + assert mock_revert_staged.called + mock_revert_staged.assert_called_once_with(repo_path) + err_calls = [c for c in mock_display.call_args_list if c.kwargs.get("error")] + assert any("Git amend failed" in str(c) for c in err_calls) + + +def test_commit_channel_handler_handle_response_exception_reverts_amend(): + handler = CommitChannelHandler( + "job_err", + orig_head="head_sha_123", + amend=True, + repo_path="/dummy/repo", + ) + with ( + patch( + "vimini.commit.handle_commit_response", + side_effect=RuntimeError("Vim buffer split failed"), + ), + patch("vimini.commit._revert_amend") as mock_revert_amend, + patch("vimini.util.display_message") as mock_display, + ): + handler.handle_response({"text": "Commit msg"}) + assert mock_revert_amend.called + mock_revert_amend.assert_called_once_with("/dummy/repo", "head_sha_123") + assert any( + "Vim buffer split failed" in str(c) for c in mock_display.call_args_list + ) + + +def test_commit_channel_handler_handle_response_exception_reverts_staged(): + handler = CommitChannelHandler( + "job_err2", + amend=False, + repo_path="/dummy/repo", + ) + with ( + patch( + "vimini.commit.handle_commit_response", + side_effect=RuntimeError("Tempfile error"), + ), + patch("vimini.commit._revert_staged") as mock_revert_staged, + patch("vimini.util.display_message") as mock_display, + ): + handler.handle_response({"text": "Commit msg", "regenerate": False}) + assert mock_revert_staged.called + mock_revert_staged.assert_called_once_with("/dummy/repo") + + +def test_commit_channel_handler_handle_error_regenerate_does_not_unstage(): + handler = CommitChannelHandler( + "job_err_regen", + amend=False, + regenerate=True, + repo_path="/dummy/repo", + ) + with ( + patch("vimini.commit._revert_staged") as mock_revert_staged, + patch("vimini.commit._revert_amend") as mock_revert_amend, + patch("vimini.util.display_message") as mock_display, + ): + handler.handle_error({"message": "Server error"}) + assert not mock_revert_staged.called + assert not mock_revert_amend.called + assert any("Server error" in str(c) for c in mock_display.call_args_list) + + +def test_commit_channel_handler_handle_error_reverts_staged_when_not_regenerate(): + handler = CommitChannelHandler( + "job_err_normal", + amend=False, + regenerate=False, + repo_path="/dummy/repo", + ) + with ( + patch("vimini.commit._revert_staged") as mock_revert_staged, + patch("vimini.util.display_message") as mock_display, + ): + handler.handle_error({"message": "Server error"}) + assert mock_revert_staged.called + mock_revert_staged.assert_called_once_with("/dummy/repo") + + +def test_commit_channel_handler_handle_error_reverts_amend(): + handler = CommitChannelHandler( + "job_err_amend", + orig_head="orig_sha", + amend=True, + regenerate=True, + repo_path="/dummy/repo", + ) + with ( + patch("vimini.commit._revert_amend") as mock_revert_amend, + patch("vimini.util.display_message") as mock_display, + ): + handler.handle_error({"message": "Server error"}) + assert mock_revert_amend.called + mock_revert_amend.assert_called_once_with("/dummy/repo", "orig_sha") + + +def test_finalize_commit_comment_in_body_not_treated_as_deselection(tmp_path): + import vim + + tmp_file = tmp_path / "commit_msg_comment_body.txt" + repo_path = str(tmp_path) + commit_files = ["src/included.py"] + + # Body contains a comment line that looks like deselection: #- src/included.py + content = ( + "Commit subject\n\n" + "Commit body\n" + "#- src/included.py was refactored\n\n" + "# --- Diff stat ---\n" + "# (Press to toggle file inclusion)\n" + "# src/included.py | 2 +-\n" + ) + tmp_file.write_text(content, encoding="utf-8") + + def mock_eval_fn(expr): + if "vimini_commit_tmp_file" in expr: + return str(tmp_file) + if "vimini_commit_repo_path" in expr: + return repo_path + if "vimini_commit_regenerate" in expr: + return 0 + if "vimini_commit_assistant" in expr: + return 1 + if "vimini_commit_files" in expr: + return json.dumps(commit_files) + return "" + + with ( + patch.object(vim, "eval", side_effect=mock_eval_fn), + patch("subprocess.run") as mock_run, + patch("vimini.util.display_message") as mock_display, + ): + mock_proc_ok = MagicMock( + returncode=0, stdout="[main abc1234] Commit subject", stderr="" + ) + mock_run.return_value = mock_proc_ok + + finalize_commit() + + run_cmds = [c[0][0] for c in mock_run.call_args_list] + unstage_calls = [cmd for cmd in run_cmds if "reset" in cmd] + assert len(unstage_calls) == 0 + + commit_calls = [cmd for cmd in run_cmds if "commit" in cmd and "-s" in cmd] + assert len(commit_calls) == 1 + + +def test_real_git_commits_workflow(tmp_path): + import vim + import subprocess + from vimini.handler import get_handler + + # 1. Initialize a new tree with a single file (ensure test repo has no other files) + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "-C", str(repo), "init", "-b", "main"], check=True) + subprocess.run(["git", "-C", str(repo), "config", "user.name", "Test"], check=True) + subprocess.run( + ["git", "-C", str(repo), "config", "user.email", "test@test.com"], check=True + ) + (repo / "init.txt").write_text("initial file\n") + subprocess.run(["git", "-C", str(repo), "add", "."], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-m", "init commit"], check=True) + + init_commit_sha = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + # Helper to simulate Vimini commit loop + mock_eval_dict = {} + + def fake_cmd(cmd_str): + if cmd_str.startswith("let "): + var_part = cmd_str[4:].strip() + k, v = var_part.split(" = ", 1) + if k.startswith("b:"): + k = k[2:] + if (v.startswith("'") and v.endswith("'")) or ( + v.startswith('"') and v.endswith('"') + ): + v = v[1:-1] + elif v.isdigit(): + v = int(v) + mock_eval_dict[k] = v + + def fake_eval(expr): + for k in sorted(mock_eval_dict.keys(), key=len, reverse=True): + if k in expr: + return mock_eval_dict[k] + return "" + + mock_buf = [] + + def run_commit_prompt(commit_kwargs, msg="A commit\n---\nBody"): + mock_eval_dict.clear() + mock_buf.clear() + + def fake_send(req): + job_id = req["id"] + handler = get_handler(job_id) + handler.handle_response( + { + "text": msg, + "repo_path": req["params"]["repo_path"], + "diff_stat_output": req["params"]["diff_stat_output"], + "commit_files": req["params"]["commit_files"], + "regenerate": req["params"]["regenerate"], + "amend": req["params"]["amend"], + "orig_head": req["params"]["orig_head"], + "assistant": False, + } + ) + return True + + with ( + patch("vimini.util.get_git_repo_root", return_value=str(repo)), + patch.object(vim.current, "buffer", mock_buf), + patch("vimini.util.send_channel_request", side_effect=fake_send), + patch("vimini.util.new_split"), + patch.object(vim, "command", side_effect=fake_cmd), + ): + commit(assistant=False, **commit_kwargs) + return mock_eval_dict.get("vimini_commit_tmp_file") + + # 2. Add one file and commit (no regen, no amend), abort the commit, ensure no file was committed. + (repo / "f_abort.txt").write_text("abort file\n") + tmp_file_2 = run_commit_prompt({}) + assert tmp_file_2 and os.path.exists(tmp_file_2) + # Abort by saving empty content (no commit message) + with open(tmp_file_2, "w", encoding="utf-8") as f: + f.write("# empty commit message\n") + with ( + patch("vimini.util.get_git_repo_root", return_value=str(repo)), + patch.object(vim, "eval", side_effect=fake_eval), + ): + finalize_commit() + + head_after_2 = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + assert head_after_2 == init_commit_sha + (repo / "f_abort.txt").unlink() + subprocess.run(["git", "-C", str(repo), "reset", "HEAD", "--"], check=True) + + # 3. Add two files, make a commit (no regen, no amend), deselect second file, + # complete commit, ensure second file not committed, first file is. + (repo / "f1.txt").write_text("f1 content\n") + (repo / "f2.txt").write_text("f2 content\n") + tmp_file_3 = run_commit_prompt({}) + # Deselect f2.txt + for idx, line in enumerate(mock_buf): + if "f2.txt" in line: + mock_buf[idx] = line.replace("# ", "#- ") + with open(tmp_file_3, "w", encoding="utf-8") as f: + f.write("\n".join(mock_buf) + "\n") + with ( + patch("vimini.util.get_git_repo_root", return_value=str(repo)), + patch.object(vim, "eval", side_effect=fake_eval), + ): + finalize_commit() + + head_after_3 = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + assert head_after_3 != init_commit_sha + show_head_3 = subprocess.run( + ["git", "-C", str(repo), "show", "--name-only", "--pretty=", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.split() + assert "f1.txt" in show_head_3 + assert "f2.txt" not in show_head_3 + + # 4. Do the same as (3), if all files are deselected this should completely abort + # ensure no empty commit was added + (repo / "f3.txt").write_text("f3 content\n") + tmp_file_4 = run_commit_prompt({}) + # Deselect all files in buffer + for idx, line in enumerate(mock_buf): + if "f2.txt" in line or "f3.txt" in line: + mock_buf[idx] = line.replace("# ", "#- ") + with open(tmp_file_4, "w", encoding="utf-8") as f: + f.write("\n".join(mock_buf) + "\n") + with ( + patch("vimini.util.get_git_repo_root", return_value=str(repo)), + patch.object(vim, "eval", side_effect=fake_eval), + ): + finalize_commit() + + head_after_4 = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + assert head_after_4 == head_after_3 + (repo / "f3.txt").unlink() + subprocess.run(["git", "-C", str(repo), "reset", "HEAD", "--"], check=True) + + # 5. Change 2 files, and "amend" the commit, deselect 1 file, and continue the + # commit, ensure that only the change to that file is missing from the commit, + # and the new commit includes all the changes from the old commit and the single + # file that was amended. + # f1.txt is in HEAD (old commit). f2.txt is uncommitted. + # We modify f1.txt and f2.txt. + (repo / "f1.txt").write_text("f1 modified\n") + (repo / "f2.txt").write_text("f2 modified\n") + tmp_file_5 = run_commit_prompt({"amend": True}) + # Deselect f1.txt (which was part of the old commit!) + for idx, line in enumerate(mock_buf): + if "f1.txt" in line: + mock_buf[idx] = line.replace("# ", "#- ") + with open(tmp_file_5, "w", encoding="utf-8") as f: + f.write("\n".join(mock_buf) + "\n") + with ( + patch("vimini.util.get_git_repo_root", return_value=str(repo)), + patch.object(vim, "eval", side_effect=fake_eval), + ): + finalize_commit() + + # Ensure HEAD was amended: f1.txt should have the old commit's content ("f1 content\n"), + # and f2.txt should have the new content ("f2 modified\n") + f1_head_5 = subprocess.run( + ["git", "-C", str(repo), "show", "HEAD:f1.txt"], + capture_output=True, + text=True, + check=True, + ).stdout + assert f1_head_5 == "f1 content\n" + f2_head_5 = subprocess.run( + ["git", "-C", str(repo), "show", "HEAD:f2.txt"], + capture_output=True, + text=True, + check=True, + ).stdout + assert f2_head_5 == "f2 modified\n" + assert (repo / "f1.txt").read_text() == "f1 modified\n" + + # 6. Commit with regenerate only, ensure only the commit message was changed and all + # the contents are the same. + tree_before_6 = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD^{tree}"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + tmp_file_6 = run_commit_prompt( + {"regenerate": True}, msg="Regenerated commit message\n---\nNew body" + ) + assert "# (Press to toggle file inclusion)" not in mock_buf + # In pure regenerate mode, ensure trying to toggle any file emits an error + with ( + patch.object(vim.current, "buffer", mock_buf), + patch("vimini.util.display_message") as mock_display, + patch.object(vim, "eval", side_effect=fake_eval), + patch.object(vim, "command"), + ): + for idx, line in enumerate(mock_buf): + if "f2.txt" in line: + vim.current.window.cursor = (idx + 1, 4) + toggle_file() + assert "f2.txt" in mock_buf[idx] + assert not mock_buf[idx].startswith("#-") + err_calls = [ + c for c in mock_display.call_args_list if c.kwargs.get("error") + ] + assert any( + "Files cannot be deselected in regenerate mode" in str(c) + for c in err_calls + ) + with open(tmp_file_6, "w", encoding="utf-8") as f: + f.write("\n".join(mock_buf) + "\n") + with ( + patch("vimini.util.get_git_repo_root", return_value=str(repo)), + patch.object(vim, "eval", side_effect=fake_eval), + ): + finalize_commit() + + tree_after_6 = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD^{tree}"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + assert tree_after_6 == tree_before_6 + msg_after_6 = subprocess.run( + ["git", "-C", str(repo), "log", "-1", "--pretty=%s"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + assert msg_after_6 == "Regenerated commit message"