diff --git a/aider/commands.py b/aider/commands.py index a552569e25b..e76f9f5d9fd 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -291,6 +291,34 @@ def raw_cmd_commit(self, args=None): commit_message = args.strip() if args else None self.coder.repo.commit(message=commit_message) + def cmd_scommit(self, args=None): + "Commit edits to the repo made outside the chat (commit message optional)" + try: + self.raw_cmd_scommit(args) + except ANY_GIT_ERROR as err: + self.io.tool_error(f"Unable to complete commit: {err}") + + def raw_cmd_scommit(self, args=None): + if not self.coder.repo: + self.io.tool_error("No git repository found.") + return + + commit_message = args.strip() if args else None + + # Show the diff of the staged changes + self.io.tool_output("Staged changes diff:") + diff = self.coder.repo.repo.git.diff("--cached") # Access git attribute of self.repo.repo + self.io.print(diff) + + # Check if the repository is dirty (optional warning) + if self.coder.repo.is_dirty(): + self.io.tool_warning("The repository has uncommitted changes in the working tree. Proceeding with the commit of staged changes.") + + # staged ONLY: + self.coder.repo.repo.git.commit("-m", commit_message or "Staged changes commit") # test1.txt should be clean after commit + + self.io.tool_output("Staged changes committed successfully.") + def cmd_lint(self, args="", fnames=None): "Lint and fix in-chat files or all dirty files if none in chat" @@ -1379,6 +1407,51 @@ def cmd_copy(self, args): except Exception as e: self.io.tool_error(f"An unexpected error occurred while copying to clipboard: {str(e)}") + def cmd_md(self, args): + "Save the last assistant message to a specified file and directory" + if not args.strip(): + self.io.tool_error("Please provide a filename to save the message to.") + return + + try: + # Parse the filename from the arguments + filename = args.strip() + default_dir = Path(self.coder.root) / "notes" + + # Ensure the default directory exists + default_dir.mkdir(parents=True, exist_ok=True) + + # Set the filepath to the default directory if only a filename is provided + if not Path(filename).parent or Path(filename).parent == Path('.'): + filepath = default_dir / filename + else: + filepath = Path(expanduser(filename)) + + # Get the last assistant message + all_messages = self.coder.done_messages + self.coder.cur_messages + assistant_messages = [msg for msg in reversed(all_messages) if msg["role"] == "assistant"] + + if not assistant_messages: + self.io.tool_error("No assistant messages found to save.") + return + + last_assistant_message = assistant_messages[0]["content"] + + # Save the message to the specified file + with open(filepath, "w", encoding=self.io.encoding) as f: + f.write(last_assistant_message) + + self.io.tool_output(f"Saved last assistant message to {filepath}") + + except PermissionError as e: + self.io.tool_error(f"Permission denied: {e}") + self.io.tool_output("Please ensure you have write permissions for the specified directory.") + except ValueError: + self.io.tool_error("Please provide a valid filename.") + except Exception as e: + self.io.tool_error(f"An unexpected error occurred while saving the message: {str(e)}") + + def cmd_report(self, args): "Report a problem by opening a GitHub Issue" from aider.report import report_github_issue diff --git a/tests/basic/test_commands.py b/tests/basic/test_commands.py index a234c9b1d9b..3e1774e84bf 100644 --- a/tests/basic/test_commands.py +++ b/tests/basic/test_commands.py @@ -145,6 +145,36 @@ def test_cmd_copy_pyperclip_exception(self): # Assert that tool_error was called with the clipboard error message mock_tool_error.assert_called_once_with("Failed to copy to clipboard: Clipboard error") + def test_cmd_md(self): + # Initialize InputOutput and Coder instances + io = InputOutput(pretty=False, fancy_input=False, yes=True) + coder = Coder.create(self.GPT35, None, io) + commands = Commands(io, coder) + + # Add some assistant messages to the chat history + coder.done_messages = [ + {"role": "assistant", "content": "First assistant message"}, + {"role": "user", "content": "User message"}, + {"role": "assistant", "content": "Second assistant message"}, + ] + + # Create a temporary directory for the notes + notes_dir = Path(self.tempdir) / "notes" + notes_dir.mkdir() + + # Define the filename to save the message + filename = "test_note.md" + filepath = notes_dir / filename + + # Invoke the /md command + commands.cmd_md(filename) + + # Check if the file was created and contains the last assistant message + self.assertTrue(filepath.exists()) + with open(filepath, "r", encoding=io.encoding) as f: + content = f.read() + self.assertEqual(content, "Second assistant message") + def test_cmd_add_bad_glob(self): # https://github.com/Aider-AI/aider/issues/293 @@ -461,6 +491,77 @@ def test_cmd_commit(self): commands.cmd_commit(commit_message) self.assertFalse(repo.is_dirty()) + def test_cmd_scommit(self): + with GitTemporaryDirectory() as repo_dir: + repo = git.Repo() + + fname1 = "ignoreme1.txt" + fname2 = "ignoreme2.txt" + fname3 = "file3.txt" + + file_path = Path(repo_dir) / fname3 + file_path.write_text("Initial content\n") + + Path(fname2).touch() + repo.git.add(str(fname2)) + repo.git.commit("-m", "initial") + + repo.git.add(fname3) + + aignore = Path(".aiderignore") + aignore.write_text(f"{fname1}\n{fname2}\ndir\n") + + io = InputOutput(yes=True) + + fnames = [fname1, fname2] + repo = GitRepo( + io, + fnames, + None, + aider_ignore_file=str(aignore), + ) + + coder = Coder.create( + self.GPT35, + None, + io, + fnames=fnames, + repo=repo, + ) + commands = Commands(io, coder) + commands.cmd_scommit("") + + self.assertFalse(coder.repo.is_dirty(path=fname3)) + + def test_cmd_scommit_mock(self): + with GitTemporaryDirectory() as repo_dir: + io = InputOutput(pretty=False, fancy_input=False, yes=True) + coder = Coder.create(self.GPT35, None, io) + commands = Commands(io, coder) + + # Create and stage file + (Path(repo_dir) / "test1.txt").write_text("Initial content 1") + + # Add and commit files using the git command + coder.repo.repo.git.add("test1.txt") + coder.repo.repo.git.commit("-m", "Initial commit") + + # Modify files to make the repository dirty + (Path(repo_dir) / "test1.txt").write_text("Modified content 1") + + # Stage one of the modified files + coder.repo.repo.git.add("test1.txt") + self.assertTrue(coder.repo.repo.is_dirty(path="test1.txt")) + + # Mock the commit method on the Repo object + with mock.patch.object(coder.repo.repo, 'git', create=True) as mock_git: + mock_git.commit.return_value = None + # Run cmd_scommit + commands.cmd_scommit("") + + # Check if the commit method was called with the correct message + mock_git.commit.assert_called_once_with("-m", "Staged changes commit") + def test_cmd_add_from_outside_root(self): with ChdirTemporaryDirectory() as tmp_dname: root = Path("root")