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"