Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: new command /scommit for committing staged files only #2763

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions aider/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions tests/basic/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down