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: Changing current subtree with /subtree command #2881

Open
wants to merge 2 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
74 changes: 74 additions & 0 deletions aider/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -1292,6 +1292,33 @@ def cmd_settings(self, args):
output = f"{announcements}\n{settings}"
self.io.tool_output(output)

def cmd_subtree(self, args):
"Set the current subtree path for file filtering"
if not self.coder.repo:
self.io.tool_error("No git repository found.")
return

if not args.strip():
# Get current subtree relative to repo root
try:
current_path = self.coder.repo.current_subtree.relative_to(
Path(self.coder.repo.root).resolve()
)
self.io.tool_output(f"Current subtree path: {current_path}")
except ValueError:
self.io.tool_error("Current subtree is not within repository root.")
return

[success, error_message] = self.coder.repo.set_current_subtree(args.strip())
if success:
# Get the relative path from repo root for display
new_path = self.coder.repo.current_subtree.relative_to(
Path(self.coder.repo.root).resolve()
)
self.io.tool_output(f"Changed subtree path to: {new_path}")
else:
self.io.tool_error(error_message)

def completions_raw_load(self, document, complete_event):
return self.completions_raw_read_only(document, complete_event)

Expand Down Expand Up @@ -1327,6 +1354,53 @@ def cmd_load(self, args):
def completions_raw_save(self, document, complete_event):
return self.completions_raw_read_only(document, complete_event)

def completions_raw_subtree(self, document, complete_event):
# Get the text before the cursor
text = document.text_before_cursor

# Skip the first word and the space after it
after_command = text.split()[-1]

# Create a new Document object with the text after the command
new_document = Document(after_command, cursor_position=len(after_command))

def get_paths():
if not self.coder.repo:
return None
return [self.coder.repo.root]

path_completer = PathCompleter(
get_paths=get_paths,
only_directories=True,
expanduser=True,
)

# Adjust the start_position to replace all of 'after_command'
adjusted_start_position = -len(after_command)

# Collect all completions
all_completions = []

# Iterate over the completions and modify them
for completion in path_completer.get_completions(new_document, complete_event):
quoted_text = self.quote_fname(after_command + completion.text)
all_completions.append(
Completion(
text=quoted_text,
start_position=adjusted_start_position,
display=completion.display,
style=completion.style,
selected_style=completion.selected_style,
)
)

# Sort all completions based on their text
sorted_completions = sorted(all_completions, key=lambda c: c.text)

# Yield the sorted completions
for completion in sorted_completions:
yield completion

def cmd_save(self, args):
"Save commands to a file that can reconstruct the current chat session's files"
if not args.strip():
Expand Down
41 changes: 40 additions & 1 deletion aider/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def __init__(
self.attribute_commit_message_committer = attribute_commit_message_committer
self.commit_prompt = commit_prompt
self.subtree_only = subtree_only
self.current_subtree = Path.cwd().resolve()
self.ignore_file_cache = {}

if git_dname:
Expand Down Expand Up @@ -183,6 +184,44 @@ def get_rel_repo_dir(self):
except (ValueError, OSError):
return self.repo.git_dir

def set_current_subtree(self, new_path):
"""
Change the current subtree path used for file filtering.

:param new_path: The new path to set as the current subtree. It's either
an absolute path or relative to the repo root.

:return: A tuple with a boolean indicating success and an optional error message.
"""
if not self.subtree_only:
return (
False,
"Subtree mode is not enabled. Run aider with --subtree-only flag.",
)

root_path = Path(self.root).resolve()
new_path = Path(new_path)

if new_path.is_absolute():
abs_path = new_path
else:
abs_path = (root_path / new_path).resolve()

try:
# Additional validation: path must exist and be a directory
if not abs_path.is_dir():
return False, "The path must be a directory."

# Verify the new path is within the git repo
if not abs_path.is_relative_to(root_path):
return False, "The path must be within the git repo."

self.current_subtree = abs_path
self.ignore_file_cache = {}
return True, None
except ValueError:
return False, "Invalid path."

def get_commit_message(self, diffs, context):
diffs = "# Diffs:\n" + diffs

Expand Down Expand Up @@ -371,7 +410,7 @@ def ignored_file_raw(self, fname):
if self.subtree_only:
try:
fname_path = Path(self.normalize_path(fname))
cwd_path = Path.cwd().resolve().relative_to(Path(self.root).resolve())
cwd_path = self.current_subtree.relative_to(Path(self.root).resolve())
except ValueError:
# Issue #1524
# ValueError: 'C:\\dev\\squid-certbot' is not in the subpath of
Expand Down
1 change: 1 addition & 0 deletions aider/website/docs/usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ cog.out(get_help_md())
| **/run** | Run a shell command and optionally add the output to the chat (alias: !) |
| **/save** | Save commands to a file that can reconstruct the current chat session's files |
| **/settings** | Print out the current settings |
| **/subtree** | Change current subtree |
| **/test** | Run a shell command and add the output to the chat on non-zero exit code |
| **/tokens** | Report on the number of tokens used by the current chat context |
| **/undo** | Undo the last git commit if it was done by aider |
Expand Down
122 changes: 122 additions & 0 deletions tests/basic/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -1107,6 +1107,128 @@ def test_cmd_add_read_only_file(self):
)
)

def test_cmd_subtree_no_args(self):
"""Test /subtree with no arguments shows current path"""
with GitTemporaryDirectory() as repo_dir:
# Create a new repo
raw_repo = git.Repo()

# Change to repo dir first
os.chdir(repo_dir)

# Create and commit files
root_file = Path("root.txt")
root_file.touch()
raw_repo.git.add(str(root_file))
raw_repo.git.commit("-m", "Initial commit")

io = InputOutput(pretty=False, fancy_input=False, yes=True)
repo = GitRepo(io, None, None, subtree_only=True)
coder = Coder.create(self.GPT35, None, io, repo=repo)
commands = Commands(io, coder)

# Mock tool_output to capture output
with mock.patch.object(io, "tool_output") as mock_output:
commands.cmd_subtree("")
mock_output.assert_called_with("Current subtree path: .")

def test_cmd_subtree_absolute_path(self):
"""Test /subtree with absolute path"""
with GitTemporaryDirectory() as repo_dir:
# Create a new repo
raw_repo = git.Repo()

# Change to repo dir first
os.chdir(repo_dir)

# Create and commit files
root_file = Path("root.txt")
root_file.touch()
subdir = Path("subdir")
subdir.mkdir()

raw_repo.git.add(str(root_file))
raw_repo.git.commit("-m", "Initial commit")

io = InputOutput(pretty=False, fancy_input=False, yes=True)
repo = GitRepo(io, None, None, subtree_only=True)
coder = Coder.create(self.GPT35, None, io, repo=repo)
commands = Commands(io, coder)

# Test with absolute path
with mock.patch.object(io, "tool_output") as mock_output:
commands.cmd_subtree(str(subdir))
mock_output.assert_called_with("Changed subtree path to: subdir")

def test_cmd_subtree_relative_path(self):
"""Test /subtree with relative path"""
with GitTemporaryDirectory() as repo_dir:
# Create a new repo
raw_repo = git.Repo()

# Change to repo dir first
os.chdir(repo_dir)

# Create and commit files
root_file = Path("root.txt")
root_file.touch()
Path("dir1/dir2").mkdir(parents=True)

raw_repo.git.add(str(root_file))
raw_repo.git.commit("-m", "Initial commit")

io = InputOutput(pretty=False, fancy_input=False, yes=True)
repo = GitRepo(io, None, None, subtree_only=True)
coder = Coder.create(self.GPT35, None, io, repo=repo)
commands = Commands(io, coder)

# Test with relative path
with mock.patch.object(io, "tool_output") as mock_output:
commands.cmd_subtree(os.path.join("dir1", "dir2"))
expected_path = os.path.normpath("dir1/dir2")
mock_output.assert_called_with(f"Changed subtree path to: {expected_path}")

def test_cmd_subtree_no_repo(self):
"""Test /subtree with no git repo"""
with ChdirTemporaryDirectory():
io = InputOutput(pretty=False, fancy_input=False, yes=True)
coder = Coder.create(self.GPT35, None, io)
commands = Commands(io, coder)

# Test with no repo
with mock.patch.object(io, "tool_error") as mock_error:
commands.cmd_subtree("some/path")
mock_error.assert_called_with("No git repository found.")

def test_cmd_subtree_outside_repo(self):
"""Test /subtree with path outside repo"""
with GitTemporaryDirectory() as repo_dir:
# Create a new repo
raw_repo = git.Repo()

# Change to repo dir first
os.chdir(repo_dir)

# Create and commit files
root_file = Path("root.txt")
root_file.touch()
raw_repo.git.add(str(root_file))
raw_repo.git.commit("-m", "Initial commit")

io = InputOutput(pretty=False, fancy_input=False, yes=True)
repo = GitRepo(io, None, None, subtree_only=True)
coder = Coder.create(self.GPT35, None, io, repo=repo)
commands = Commands(io, coder)

# Create a path that's definitely outside the repo
outside_path = os.path.abspath(os.path.join(repo_dir, "..", "outside"))
os.makedirs(outside_path, exist_ok=True)

# Try to set path outside repo
with mock.patch.object(io, "tool_error") as mock_error:
commands.cmd_subtree(outside_path)
mock_error.assert_called_with("The path must be within the git repo.")

def test_cmd_test_unbound_local_error(self):
with ChdirTemporaryDirectory():
io = InputOutput(pretty=False, fancy_input=False, yes=False)
Expand Down
Loading
Loading