diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 56613782b02..7814035222f 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -573,6 +573,11 @@ def abs_root_path(self, path): self.abs_root_path_cache[key] = res return res + def abs_new_file_path(self, path): + if self.repo: + return self.repo.abs_new_file_path(path) + return self.abs_root_path(path) + fences = all_fences fence = fences[0] @@ -2190,12 +2195,13 @@ def check_for_dirty_commit(self, path): def allowed_to_edit(self, path): full_path = self.abs_root_path(path) + new_file_path = self.abs_new_file_path(path) if self.repo: need_to_add = not self.repo.path_in_repo(path) else: need_to_add = False - if full_path in self.abs_fnames: + if full_path in self.abs_fnames or new_file_path in self.abs_fnames: self.check_for_dirty_commit(path) return True @@ -2204,24 +2210,25 @@ def allowed_to_edit(self, path): return if not Path(full_path).exists(): - if not self.io.confirm_ask("Create new file?", subject=path): - self.io.tool_output(f"Skipping edits to {path}") - return - - if not self.dry_run: - if not utils.touch_file(full_path): - self.io.tool_error(f"Unable to create {path}, skipping edits.") + if new_file_path != full_path and Path(new_file_path).exists(): + full_path = new_file_path + else: + if not self.io.confirm_ask("Create new file?", subject=path): + self.io.tool_output(f"Skipping edits to {path}") return - # Seems unlikely that we needed to create the file, but it was - # actually already part of the repo. - # But let's only add if we need to, just to be safe. - if need_to_add and self.auto_commits: - self.repo.repo.git.add(full_path) + if not self.dry_run: + if not utils.touch_file(new_file_path): + self.io.tool_error(f"Unable to create {path}, skipping edits.") + return - self.abs_fnames.add(full_path) - self.check_added_files() - return True + # Add the file at the path we actually created. + if need_to_add and self.auto_commits: + self.repo.repo.git.add(new_file_path) + + self.abs_fnames.add(new_file_path) + self.check_added_files() + return True if not self.io.confirm_ask( "Allow edits to file that has not been added to the chat?", diff --git a/aider/commands.py b/aider/commands.py index 3881403c5c1..6990539fe3c 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -808,11 +808,11 @@ def cmd_add(self, args): else: fname = Path(self.coder.root) / word - if self.coder.repo and self.coder.repo.ignored_file(fname): - self.io.tool_warning(f"Skipping {fname} due to aiderignore or --subtree-only.") - continue - if fname.exists(): + if self.coder.repo and self.coder.repo.ignored_file(fname): + self.io.tool_warning(f"Skipping {fname} due to aiderignore or --subtree-only.") + continue + if fname.is_file(): all_matched_files.add(str(fname)) continue @@ -835,13 +835,22 @@ def cmd_add(self, args): self.io.tool_output(f"You can add to git with: /git add {fname}") continue - if self.io.confirm_ask(f"No files matched '{word}'. Do you want to create {fname}?"): + create_path = Path(self.coder.abs_new_file_path(word)) + if self.coder.repo and self.coder.repo.ignored_file(create_path): + self.io.tool_warning( + f"Skipping {create_path} due to aiderignore or --subtree-only." + ) + continue + + if self.io.confirm_ask( + f"No files matched '{word}'. Do you want to create {create_path}?" + ): try: - fname.parent.mkdir(parents=True, exist_ok=True) - fname.touch() - all_matched_files.add(str(fname)) + create_path.parent.mkdir(parents=True, exist_ok=True) + create_path.touch() + all_matched_files.add(str(create_path)) except OSError as e: - self.io.tool_error(f"Error creating file {fname}: {e}") + self.io.tool_error(f"Error creating file {create_path}: {e}") for matched_file in sorted(all_matched_files): abs_file_path = self.coder.abs_root_path(matched_file) diff --git a/aider/repo.py b/aider/repo.py index 92b5e3bf5b8..a71e3d2c919 100644 --- a/aider/repo.py +++ b/aider/repo.py @@ -578,6 +578,26 @@ def abs_root_path(self, path): res = Path(self.root) / path return utils.safe_abs_path(res) + def abs_new_file_path(self, path): + path = Path(path) + if path.is_absolute() or not self.subtree_only: + return utils.safe_abs_path(path) if path.is_absolute() else self.abs_root_path(path) + + try: + cwd_path = Path.cwd().resolve() + root_path = Path(self.root).resolve() + cwd_relative = cwd_path.relative_to(root_path) + except ValueError: + return self.abs_root_path(path) + + if ( + cwd_relative != Path(".") + and path.parts[: len(cwd_relative.parts)] == cwd_relative.parts + ): + return self.abs_root_path(path) + + return utils.safe_abs_path(cwd_path / path) + def get_dirty_files(self): """ Returns a list of all files which are dirty (not committed), either staged or in the working diff --git a/tests/basic/test_coder.py b/tests/basic/test_coder.py index 9dd0ce37ad8..67d5f6b12d3 100644 --- a/tests/basic/test_coder.py +++ b/tests/basic/test_coder.py @@ -51,6 +51,68 @@ def test_allowed_to_edit(self): self.assertFalse(coder.need_commit_before_edits) + def test_allowed_to_edit_subtree_only_creates_new_file_in_cwd(self): + with GitTemporaryDirectory(): + repo_root = Path.cwd() + subdir = repo_root / "subdir" + subdir.mkdir() + start_cwd = Path.cwd() + os.chdir(subdir) + try: + io = InputOutput(yes=True) + repo = GitRepo(io, None, None, subtree_only=True) + coder = Coder.create(self.GPT35, None, io, repo=repo) + + self.assertEqual(coder.abs_root_path("new.txt"), str(repo_root / "new.txt")) + + self.assertTrue(coder.allowed_to_edit("new.txt")) + + self.assertTrue(Path("new.txt").exists()) + self.assertFalse((repo_root / "new.txt").exists()) + self.assertIn(str(Path("new.txt").resolve()), coder.abs_fnames) + finally: + os.chdir(start_cwd) + + def test_allowed_to_edit_without_subtree_only_keeps_repo_root_for_new_files(self): + with GitTemporaryDirectory(): + repo_root = Path.cwd() + subdir = repo_root / "subdir" + subdir.mkdir() + start_cwd = Path.cwd() + os.chdir(subdir) + try: + io = InputOutput(yes=True) + repo = GitRepo(io, None, None, subtree_only=False) + coder = Coder.create(self.GPT35, None, io, repo=repo) + + self.assertTrue(coder.allowed_to_edit("new.txt")) + + self.assertTrue((repo_root / "new.txt").exists()) + self.assertFalse((subdir / "new.txt").exists()) + self.assertIn(str((repo_root / "new.txt").resolve()), coder.abs_fnames) + finally: + os.chdir(start_cwd) + + def test_allowed_to_edit_subtree_only_keeps_repo_relative_subtree_paths(self): + with GitTemporaryDirectory(): + repo_root = Path.cwd() + subdir = repo_root / "subdir" + subdir.mkdir() + start_cwd = Path.cwd() + os.chdir(subdir) + try: + io = InputOutput(yes=True) + repo = GitRepo(io, None, None, subtree_only=True) + coder = Coder.create(self.GPT35, None, io, repo=repo) + + self.assertTrue(coder.allowed_to_edit("subdir/new.txt")) + + self.assertTrue((repo_root / "subdir" / "new.txt").exists()) + self.assertFalse((repo_root / "subdir" / "subdir" / "new.txt").exists()) + self.assertIn(str((repo_root / "subdir" / "new.txt").resolve()), coder.abs_fnames) + finally: + os.chdir(start_cwd) + def test_allowed_to_edit_no(self): with GitTemporaryDirectory(): repo = git.Repo() diff --git a/tests/basic/test_commands.py b/tests/basic/test_commands.py index 406e928ebf2..5d991afbd1b 100644 --- a/tests/basic/test_commands.py +++ b/tests/basic/test_commands.py @@ -213,6 +213,27 @@ def test_cmd_add_no_match_but_make_it(self): self.assertEqual(len(coder.abs_fnames), 1) self.assertTrue(fname.exists()) + def test_cmd_add_subtree_only_creates_unmatched_file_in_cwd(self): + with GitTemporaryDirectory(): + repo_root = Path.cwd() + subdir = repo_root / "subdir" + subdir.mkdir() + start_cwd = Path.cwd() + os.chdir(subdir) + try: + 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) + + commands.cmd_add("new.txt") + + self.assertTrue(Path("new.txt").exists()) + self.assertFalse((repo_root / "new.txt").exists()) + self.assertIn(str(Path("new.txt").resolve()), coder.abs_fnames) + finally: + os.chdir(start_cwd) + def test_cmd_add_drop_directory(self): # Initialize the Commands and InputOutput objects io = InputOutput(pretty=False, fancy_input=False, yes=False) diff --git a/tests/basic/test_repo.py b/tests/basic/test_repo.py index 71ba9479830..6096843206f 100644 --- a/tests/basic/test_repo.py +++ b/tests/basic/test_repo.py @@ -620,6 +620,43 @@ def test_subtree_only(self): self.assertNotIn(str(root_file), tracked_files) self.assertNotIn(str(another_subdir_file), tracked_files) + def test_subtree_only_new_file_path_resolution(self): + with GitTemporaryDirectory(): + raw_repo = git.Repo() + + subdir_file = Path("subdir/subdir_file.txt") + subdir_file.parent.mkdir() + subdir_file.touch() + + raw_repo.git.add(str(subdir_file)) + raw_repo.git.commit("-m", "Initial commit") + + repo_root = Path.cwd() + start_cwd = Path.cwd() + os.chdir(subdir_file.parent) + try: + subtree_repo = GitRepo(InputOutput(), None, None, subtree_only=True) + self.assertEqual( + subtree_repo.abs_new_file_path("new.txt"), + str(repo_root / "subdir" / "new.txt"), + ) + self.assertEqual( + subtree_repo.abs_new_file_path("subdir/new.txt"), + str(repo_root / "subdir" / "new.txt"), + ) + self.assertNotEqual( + subtree_repo.abs_new_file_path("subdir/new.txt"), + str(repo_root / "subdir" / "subdir" / "new.txt"), + ) + + full_repo = GitRepo(InputOutput(), None, None, subtree_only=False) + self.assertEqual( + full_repo.abs_new_file_path("new.txt"), + str(repo_root / "new.txt"), + ) + finally: + os.chdir(start_cwd) + @patch("aider.models.Model.simple_send_with_retries") def test_noop_commit(self, mock_send): mock_send.return_value = '"a good commit message"'