From c35f125f7cd75e20de842f42bcf7f335a2a00dd6 Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Fri, 23 Oct 2026 13:54:38 -0400 Subject: [PATCH] Allow toggling file inclusion in commit buffer List files slated for commit as comments at the bottom of the commit buffer, allowing users to toggle inclusion of individual files using `` in Normal mode. Deselected files (marked with `-`) are automatically unstaged before the commit or amend is finalized. Additionally, add a fallback to `git apply` when the `patch` executable is missing from the environment, and include unit tests for the commit file selection workflow. Assisted-by: Gemini:Gemini 3.8 Flash Signed-off-by: Simo Sorce --- README.md | 13 +- doc/vimini.txt | 4 + plugin/vimini.vim | 11 ++ python3/vimini/agent/server.py | 3 + python3/vimini/code.py | 108 +++++++----- python3/vimini/commit.py | 287 ++++++++++++++++++++++++++++++-- python3/vimini/main.py | 3 + tests/test_commit_files.py | 293 +++++++++++++++++++++++++++++++++ 8 files changed, 659 insertions(+), 63 deletions(-) create mode 100644 tests/test_commit_files.py diff --git a/README.md b/README.md index a06ce18..568921a 100644 --- a/README.md +++ b/README.md @@ -353,16 +353,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. Displays the generated message for confirmation (`y/n`). -4. If confirmed, it commits the changes with the generated message. +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. **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. It displays the message for confirmation (`y/n`). -4. If confirmed, it amends the `HEAD` commit with the new message. This is useful for quickly rewriting a commit message you just made. +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. **Amend Code and Regenerate Mode (`-a`):** @@ -370,8 +371,8 @@ 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. Displays the message for confirmation (`y/n`). -5. If confirmed, it amends the `HEAD` commit with the new message. +4. Opens the commit message buffer listing files (press `` in Normal mode to toggle/deselect files). +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 d5253ef..e233f64 100644 --- a/doc/vimini.txt +++ b/doc/vimini.txt @@ -26,6 +26,10 @@ 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. -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 6170a2c..c9642d1 100644 --- a/plugin/vimini.vim +++ b/plugin/vimini.vim @@ -407,6 +407,17 @@ endfunction command! -nargs=* ViminiCommit call ViminiCommit() +" Expose a function to toggle file selection in commit buffer +function! ViminiCommitToggleFile() + py3 << EOF +try: + from vimini.commit import toggle_file + toggle_file() +except Exception as e: + pass +EOF +endfunction + " Expose a function to manage uploaded files function! ViminiFiles() py3 << EOF diff --git a/python3/vimini/agent/server.py b/python3/vimini/agent/server.py index 4ef3638..46295e3 100644 --- a/python3/vimini/agent/server.py +++ b/python3/vimini/agent/server.py @@ -103,6 +103,9 @@ def execute_function(req_id, method, params, result_queue, conn): "diff_stat_output": params.get("diff_stat_output") if isinstance(params, dict) else None, + "commit_files": params.get("commit_files") + if isinstance(params, dict) + else None, "regenerate": params.get("regenerate") if isinstance(params, dict) else False, diff --git a/python3/vimini/code.py b/python3/vimini/code.py index c4559bf..d23e290 100644 --- a/python3/vimini/code.py +++ b/python3/vimini/code.py @@ -403,58 +403,76 @@ def apply_patch(diff_content, project_root=None, silent=False): util.display_message(err_msg, error=True) return False, err_msg - # Success - util.display_message("Successfully applied modified diff.", history=True) - - # Parse diff to identify modified files for reloading - modified_files = set() - for line in diff_content.split("\n"): - if line.startswith("--- a/"): - path = line[len("--- a/") :].strip() - if path != "/dev/null": - modified_files.add(path) - elif line.startswith("+++ b/"): - path = line[len("+++ b/") :].strip() - if path != "/dev/null": - modified_files.add(path) - - for relative_path in modified_files: - absolute_path = os.path.join(project_root, relative_path) - - # Clean up .orig files potentially created by patch - orig_path = absolute_path + ".orig" - if os.path.exists(orig_path): - try: - os.remove(orig_path) - except Exception: - pass - - normalized_target_path = os.path.realpath(absolute_path) - for buf in vim.buffers: - if buf.name and os.path.realpath(buf.name) == normalized_target_path: - # Reload buffer if visible - win_nr = vim.eval(f"bufwinnr({buf.number})") - if int(win_nr) > 0: - vim.command(f"{win_nr}wincmd w") - vim.command("e!") - vim.command("wincmd p") - else: - # Mark buffer to be reloaded when entered - vim.command(f"checktime {buf.number}") - break - return True, None - except FileNotFoundError: - err_msg = "Error: `patch` command not found. Is it in your PATH?" - if not silent: - util.display_message(err_msg, error=True) - return False, err_msg + # Fall back to git apply if patch command is not in PATH + try: + git_cmd = ["git", "-C", project_root, "apply", "--whitespace=nowarn", "-"] + result = subprocess.run( + git_cmd, + input=diff_content, + encoding="utf-8", + errors="replace", + text=True, + check=False, + capture_output=True, + ) + if result.returncode != 0: + err_msg = (result.stderr or result.stdout or "git apply failed").strip() + if not silent: + util.display_message(f"Git apply failed: {err_msg}", error=True) + return False, err_msg + except Exception as ge: + err_msg = f"Error: `patch` and `git` commands not available: {ge}" + if not silent: + util.display_message(err_msg, error=True) + return False, err_msg except Exception as e: err_msg = f"Error applying diff: {e}" if not silent: util.display_message(err_msg, error=True) return False, err_msg + # Success + util.display_message("Successfully applied modified diff.", history=True) + + # Parse diff to identify modified files for reloading + modified_files = set() + for line in diff_content.split("\n"): + if line.startswith("--- a/"): + path = line[len("--- a/") :].strip() + if path != "/dev/null": + modified_files.add(path) + elif line.startswith("+++ b/"): + path = line[len("+++ b/") :].strip() + if path != "/dev/null": + modified_files.add(path) + + for relative_path in modified_files: + absolute_path = os.path.join(project_root, relative_path) + + # Clean up .orig files potentially created by patch + orig_path = absolute_path + ".orig" + if os.path.exists(orig_path): + try: + os.remove(orig_path) + except Exception: + pass + + normalized_target_path = os.path.realpath(absolute_path) + for buf in vim.buffers: + if buf.name and os.path.realpath(buf.name) == normalized_target_path: + # Reload buffer if visible + win_nr = vim.eval(f"bufwinnr({buf.number})") + if int(win_nr) > 0: + vim.command(f"{win_nr}wincmd w") + vim.command("e!") + vim.command("wincmd p") + else: + # Mark buffer to be reloaded when entered + vim.command(f"checktime {buf.number}") + break + return True, None + def apply_code(job_id=None): """ diff --git a/python3/vimini/commit.py b/python3/vimini/commit.py index 9cef0e4..c8099d4 100644 --- a/python3/vimini/commit.py +++ b/python3/vimini/commit.py @@ -1,5 +1,6 @@ import vim import os +import json import subprocess import textwrap import tempfile @@ -7,6 +8,9 @@ from vimini.util import get_model_name from vimini.handler import BaseChannelHandler, register_handler, unregister_handler +# In-memory store for commit files keyed by temporary commit filename +_commit_files_by_file = {} + def _run_format_command(repo_path): """ @@ -122,13 +126,119 @@ def _stage_changes(repo_path, message="Staging changes..."): return True +def _revert_staged(repo_path): + """Reverts git add / unstages changes.""" + if not repo_path: + return + res = subprocess.run( + ["git", "-C", repo_path, "rev-parse", "--verify", "HEAD"], + capture_output=True, + check=False, + ) + if res.returncode == 0: + reset_cmd = ["git", "-C", repo_path, "reset", "HEAD", "--"] + else: + reset_cmd = ["git", "-C", repo_path, "rm", "--cached", "-r", "--", "."] + subprocess.run(reset_cmd, check=False) + + +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. + """ + 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 + + return None, None + + +def _get_commit_files_for_buffer(): + """Retrieves the list of commit files for the current buffer.""" + try: + raw = vim.eval("get(b:, 'vimini_commit_files', '')") + if raw: + return json.loads(raw) + except Exception: + pass + try: + tmp_filename = vim.eval("get(b:, 'vimini_commit_tmp_file', '')") + return _commit_files_by_file.get(tmp_filename, []) + except Exception: + pass + return [] + + +def toggle_file(): + """ + 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() + if not commit_files: + return + + cursor = vim.current.window.cursor + row, col = cursor[0], cursor[1] # row is 1-indexed + + if row < 1 or row > len(vim.current.buffer): + return + + line = vim.current.buffer[row - 1] + fn, is_deselected = parse_file_line(line, commit_files) + if fn is None: + return + + if is_deselected: + # Re-add file + new_line = f"# {fn}" + else: + # Deselect file (mark with -) + new_line = f"# - {fn}" + + vim.current.buffer[row - 1] = new_line + new_col = min(col, len(new_line) - 1 if new_line else 0) + vim.current.window.cursor = (row, new_col) + vim.command("redraw") + except Exception as e: + util.log_info(f"toggle_file error: {e}") + + class CommitChannelHandler(BaseChannelHandler): """ Handles channel responses for commit message generation requests. """ + def __init__(self, req_id, files_to_commit=None): + super().__init__(req_id) + self.files_to_commit = files_to_commit or [] + 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) def handle_error(self, error): @@ -145,6 +255,7 @@ def handle_commit_response(req_id, result): text = result.get("text", "") repo_path = result.get("repo_path", "") diff_stat_output = result.get("diff_stat_output", "") + commit_files = result.get("commit_files") or [] regenerate = bool(result.get("regenerate", False)) assistant = bool(result.get("assistant", True)) @@ -172,8 +283,7 @@ def handle_commit_response(req_id, result): msg = "Failed to generate a commit message." if not regenerate and repo_path: msg += " Reverting `git add`." - reset_cmd = ["git", "-C", repo_path, "reset", "HEAD", "--"] - subprocess.run(reset_cmd, check=False) + _revert_staged(repo_path) util.display_message(msg, error=True) return @@ -192,28 +302,40 @@ def handle_commit_response(req_id, result): buffer_content.extend(body.split("\n")) if diff_stat_output: - stat_header = ( - "# --- Files in commit ---" if regenerate else "# --- Staged 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}") + vim.current.buffer[:] = buffer_content safe_tmp = tmp_filename.replace("'", "''") safe_repo = repo_path.replace("'", "''") + safe_files = json.dumps(commit_files).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_assistant = {1 if assistant else 0}") + vim.command(f"let b:vimini_commit_files = '{safe_files}'") + _commit_files_by_file[tmp_filename] = list(commit_files) + + vim.command("nnoremap :call ViminiCommitToggleFile()") vim.command( "autocmd BufWipeout py3 from vimini.commit import finalize_commit; finalize_commit()" ) util.display_message( - "Review the commit message. Save and close the buffer to commit, or close without saving to abort.", + "Review the commit message. Toggle files with . Save and close the buffer to commit, or close without saving to abort.", history=True, ) vim.command("redraw!") @@ -237,6 +359,7 @@ def commit(assistant=True, regenerate=False, amend=False, refinement=None): diff_to_process = "" diff_stat_output = "" + files_to_commit = [] if not regenerate: if not _run_format_command(repo_path): @@ -296,6 +419,29 @@ def commit(assistant=True, regenerate=False, amend=False, refinement=None): ) if stat_result.returncode == 0: diff_stat_output = stat_result.stdout.strip() + + files_cmd = [ + "git", + "-C", + repo_path, + "diff-tree", + "--no-commit-id", + "--name-only", + "-r", + "-z", + "--root", + "HEAD", + ] + files_result = subprocess.run( + files_cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + if files_result.returncode == 0 and files_result.stdout: + files_to_commit = [f for f in files_result.stdout.split("\0") if f] else: if not _stage_changes(repo_path, "Staging changes..."): return @@ -333,6 +479,28 @@ def commit(assistant=True, regenerate=False, amend=False, refinement=None): if staged_stat_result.returncode == 0: diff_stat_output = staged_stat_result.stdout.strip() + staged_files_cmd = [ + "git", + "-C", + repo_path, + "diff", + "--staged", + "--name-only", + "-z", + ] + staged_files_result = subprocess.run( + staged_files_cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + if staged_files_result.returncode == 0 and staged_files_result.stdout: + files_to_commit = [ + f for f in staged_files_result.stdout.split("\0") if f + ] + if not diff_to_process: message = ( "HEAD commit is empty. Nothing to regenerate." @@ -363,7 +531,7 @@ def commit(assistant=True, regenerate=False, amend=False, refinement=None): job_id = str(util.reserve_next_job_id("Commit")) - handler = CommitChannelHandler(job_id) + handler = CommitChannelHandler(job_id, files_to_commit=files_to_commit) register_handler(job_id, handler) req = { @@ -374,6 +542,7 @@ def commit(assistant=True, regenerate=False, amend=False, refinement=None): "prompt": prompt, "repo_path": repo_path, "diff_stat_output": diff_stat_output, + "commit_files": files_to_commit, "regenerate": regenerate, "assistant": assistant, }, @@ -384,8 +553,7 @@ def commit(assistant=True, regenerate=False, amend=False, refinement=None): msg = "Commit cancelled (sending request to agent server failed)." if not regenerate: msg += " Reverting `git add`." - reset_cmd = ["git", "-C", repo_path, "reset", "HEAD", "--"] - subprocess.run(reset_cmd, check=False) + _revert_staged(repo_path) util.display_message(msg, error=True) except FileNotFoundError: @@ -404,12 +572,19 @@ def finalize_commit(): 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_files = vim.eval("get(b:, 'vimini_commit_files', '')") + commit_files = json.loads(raw_files) if raw_files 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, []) + else: + _commit_files_by_file.pop(tmp_filename, None) + if not tmp_filename or not os.path.exists(tmp_filename): - util.log_info(f"Failed to find commit log file") + util.log_info("Failed to find commit log file") return try: @@ -437,10 +612,98 @@ def finalize_commit(): "Commit cancelled (no commit message saved). Reverting `git add`.", error=True, ) - reset_cmd = ["git", "-C", repo_path, "reset", "HEAD", "--"] - subprocess.run(reset_cmd, check=False) + _revert_staged(repo_path) return + # Parse file lines to find deselected files (marked with -) + 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) + + remaining_files = [f for f in commit_files if f not in deselected_files] + if not remaining_files: + if regenerate: + util.display_message( + "Amend cancelled (no files selected to commit).", + error=True, + ) + else: + util.display_message( + "Commit cancelled (no files selected to commit). Reverting `git add`.", + error=True, + ) + _revert_staged(repo_path) + 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, + ) + 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: + 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 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 + ) + return + util.log_info("Commit Message accepted") commit_cmd = ["git", "-C", repo_path, "commit", "-s", "--cleanup=strip"] diff --git a/python3/vimini/main.py b/python3/vimini/main.py index 5cbc3fe..b4d1562 100644 --- a/python3/vimini/main.py +++ b/python3/vimini/main.py @@ -9,6 +9,7 @@ commit, finalize_commit, _finalize_commit, + toggle_file, ) from vimini.review import review from vimini.ripgrep import command as ripgrep_command @@ -308,6 +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.", " -n: No co-author trailer.", " -r: Regenerate/Amend HEAD.", " -a: Amend changes into HEAD and regenerate commit message.", diff --git a/tests/test_commit_files.py b/tests/test_commit_files.py new file mode 100644 index 0000000..692cf18 --- /dev/null +++ b/tests/test_commit_files.py @@ -0,0 +1,293 @@ +import sys +import os +import json +import pytest +from unittest.mock import MagicMock, patch, call + +sys.path.insert( + 0, os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "python3")) +) + +from vimini.commit import ( + parse_file_line, + toggle_file, + handle_commit_response, + finalize_commit, + _revert_staged, + _commit_files_by_file, +) + + +def test_parse_file_line(): + commit_files = ["src/app.py", "README.md", "path/with space/file.txt"] + + # Included file + fn, deselected = parse_file_line("# src/app.py", commit_files) + assert fn == "src/app.py" + assert deselected is False + + # Deselected file + 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) + 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 + + fn, deselected = parse_file_line("# src/app.py", commit_files) + assert fn == "src/app.py" + assert deselected is False + + # Path with space + fn, deselected = parse_file_line("# path/with space/file.txt", commit_files) + assert fn == "path/with space/file.txt" + assert deselected is False + + fn, deselected = parse_file_line("# - path/with space/file.txt", commit_files) + assert fn == "path/with space/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( + "# (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("# 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) + + +def test_toggle_file(): + import vim + + commit_files = ["src/app.py", "README.md"] + mock_buffer = [ + "Commit title", + "", + "# --- Files to commit ---", + "# src/app.py", + "# - README.md", + ] + + with ( + patch.object(vim, "eval") as mock_eval, + patch.object(vim, "command") as mock_cmd, + ): + vim.current.buffer = mock_buffer + vim.current.window.cursor = (4, 4) # Row 4 is "# src/app.py" + + mock_eval.side_effect = lambda expr: ( + json.dumps(commit_files) if "vimini_commit_files" in expr else "" + ) + + # Toggle src/app.py (included -> deselected) + toggle_file() + assert vim.current.buffer[3] == "# - src/app.py" + + # Toggle src/app.py again (deselected -> included) + toggle_file() + assert vim.current.buffer[3] == "# src/app.py" + + # Move to row 5 ("# - README.md") and toggle (deselected -> included) + vim.current.window.cursor = (5, 4) + toggle_file() + assert vim.current.buffer[4] == "# README.md" + + # Move to non-file line (Row 3: header) and toggle -> should not modify line + vim.current.window.cursor = (3, 2) + toggle_file() + assert vim.current.buffer[2] == "# --- Files to commit ---" + + +def test_handle_commit_response_adds_files_and_mapping(tmp_path): + import vim + + result = { + "text": "Add user authentication\n---\nImplements OAuth2 login.", + "repo_path": "/dummy/repo", + "diff_stat_output": " src/app.py | 2 +-\n 1 file changed", + "commit_files": ["src/app.py"], + "regenerate": False, + "assistant": True, + } + + mock_buffer = [] + vim.current.buffer = mock_buffer + + with ( + patch("vimini.util.new_split") as mock_split, + patch.object(vim, "command") as mock_cmd, + patch("vimini.util.display_message") as mock_display, + ): + 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 + + 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_finalize_commit_removes_deselected_files(tmp_path): + import vim + + tmp_file = tmp_path / "commit_msg.txt" + repo_path = str(tmp_path) + commit_files = ["src/included.py", "src/excluded.py"] + + content = ( + "Commit subject\n\n" + "Commit body\n\n" + "# --- Files to commit ---\n" + "# src/included.py\n" + "# - src/excluded.py\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() + + # Check subprocess calls + run_cmds = [c[0][0] for c in mock_run.call_args_list] + + # Verify unstage was called for excluded.py + unstage_calls = [ + cmd for cmd in run_cmds if "reset" in cmd and "src/excluded.py" in cmd + ] + assert len(unstage_calls) == 1 + assert "src/included.py" not in unstage_calls[0] + + # Verify commit command was called + 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# --- Files to commit ---\n# - file1.py\n# - file2.py\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_regenerate_uses_head_parent(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# --- Files in commit ---\n# fileA.py\n# - fileB.py\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_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() + + 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 + ] + assert len(unstage_calls) == 1 + + # Verify commit command includes --amend + commit_calls = [cmd for cmd in run_cmds if "commit" in cmd and "--amend" in cmd] + assert len(commit_calls) == 1