33from __future__ import annotations
44
55import collections
6- from collections .abc import Callable , Sequence
6+ from collections .abc import Callable , Iterator , Sequence
7+ import contextlib
78import dataclasses
89import logging
9- from pathlib import PurePosixPath
10+ from pathlib import Path , PurePosixPath
1011import tempfile
1112from typing import Protocol , Self , override
1213
2021class Toolbox :
2122 """File-system intermediary
2223
23- Note that the toolbox is not thread-safe. Concurrent operations should be
24- serialized by the caller.
24+ Note that toolbox implementations may not be thread-safe. Concurrent
25+ operations should be serialized by the caller.
2526 """
2627
2728 # TODO: Something similar to https://aider.chat/docs/repomap.html,
@@ -31,6 +32,13 @@ class Toolbox:
3132 # TODO: Support a diff-based edit method.
3233 # https://gist.github.com/noporpoise/16e731849eb1231e86d78f9dfeca3abc
3334
35+ # TODO: Add user feedback tool here. This will make it possible to request
36+ # feedback more than once during a bot action, which leads to a better
37+ # experience when used interactively.
38+
39+ # TODO: Remove all reason arguments. They are not currently used, and there
40+ # is no obvious use-case at the moment.
41+
3442 def __init__ (self , visitors : Sequence [ToolVisitor ] | None = None ) -> None :
3543 self ._visitors = visitors or []
3644
@@ -78,9 +86,22 @@ def rename_file(
7886 dst_path : PurePosixPath ,
7987 reason : str | None = None ,
8088 ) -> None :
89+ """Rename a single file"""
8190 self ._dispatch (lambda v : v .on_rename_file (src_path , dst_path , reason ))
8291 self ._rename (src_path , dst_path )
8392
93+ def expose_files (
94+ self ,
95+ ) -> contextlib .AbstractContextManager [Path ]: # pragma: no cover
96+ """Creates a temporary folder with editable copies of all files
97+
98+ All updates are synced back afterwards. Other operations should not be
99+ performed concurrently as they may be stale or lost.
100+ """
101+ self ._dispatch (lambda v : v .on_expose_files ())
102+ # TODO: Expose updated files to hook?
103+ return self ._expose ()
104+
84105 def _list (self ) -> Sequence [PurePosixPath ]: # pragma: no cover
85106 raise NotImplementedError ()
86107
@@ -103,6 +124,11 @@ def _rename(
103124 self ._write (dst_path , contents )
104125 self ._delete (src_path )
105126
127+ def _expose (
128+ self ,
129+ ) -> contextlib .AbstractContextManager [Path ]: # pragma: no cover
130+ raise NotImplementedError ()
131+
106132
107133class ToolVisitor (Protocol ):
108134 """Tool usage hook"""
@@ -130,6 +156,8 @@ def on_rename_file(
130156 reason : str | None ,
131157 ) -> None : ... # pragma: no cover
132158
159+ def on_expose_files (self ) -> None : ... # pragma: no cover
160+
133161
134162class NoopToolbox (Toolbox ):
135163 """No-op read-only toolbox"""
@@ -150,6 +178,10 @@ def _write(self, _path: PurePosixPath, _contents: str) -> None:
150178 def _delete (self , _path : PurePosixPath ) -> None :
151179 raise RuntimeError ()
152180
181+ @override
182+ def _expose (self ) -> contextlib .AbstractContextManager [Path ]:
183+ raise RuntimeError ()
184+
153185
154186class RepoToolbox (Toolbox ):
155187 """Git-repo backed toolbox implementation
@@ -177,22 +209,30 @@ def __init__(
177209 def for_working_dir (cls , repo : Repo ) -> tuple [Self , bool ]:
178210 index_tree_sha = repo .git ("write-tree" ).stdout
179211 toolbox = cls (repo , index_tree_sha )
180-
181- # Apply any changes from the working directory.
182- deleted = set [SHA ]()
183- for path in null_delimited (repo .git ("ls-files" , "-dz" ).stdout ):
184- deleted .add (path )
185- toolbox ._delete (PurePosixPath (path ))
186- for path in null_delimited (
187- repo .git ("ls-files" , "-moz" , "--exclude-standard" ).stdout
188- ):
189- if path in deleted :
190- continue # Deleted files also show up as modified
191- toolbox ._write_from_disk (PurePosixPath (path ), path )
192-
212+ toolbox ._sync_updates () # Apply any changes from the working directory
193213 head_tree_sha = repo .git ("rev-parse" , "HEAD^{tree}" ).stdout
194214 return toolbox , toolbox .tree_sha () != head_tree_sha
195215
216+ def _sync_updates (self , * , worktree_path : Path | None = None ) -> None :
217+ repo = self ._repo
218+ if worktree_path :
219+ repo = Repo (worktree_path )
220+
221+ def ls_files (* args : str ) -> Iterator [str ]:
222+ return null_delimited (repo .git ("ls-files" , * args ).stdout )
223+
224+ deleted = set [str ]()
225+ for path_str in ls_files ("-dz" ):
226+ deleted .add (path_str )
227+ self ._delete (PurePosixPath (path_str ))
228+ for path_str in ls_files ("-moz" , "--exclude-standard" ):
229+ if path_str in deleted :
230+ continue # Deleted files also show up as modified
231+ self ._write_from_disk (
232+ PurePosixPath (path_str ),
233+ worktree_path / path_str if worktree_path else Path (path_str ),
234+ )
235+
196236 def with_visitors (self , visitors : Sequence [ToolVisitor ]) -> Self :
197237 return self .__class__ (self ._repo , self .tree_sha (), visitors )
198238
@@ -224,17 +264,35 @@ def _write(self, path: PurePosixPath, contents: str) -> None:
224264 with tempfile .NamedTemporaryFile (delete_on_close = False ) as temp :
225265 temp .write (contents .encode ("utf8" ))
226266 temp .close ()
227- self ._write_from_disk (path , temp .name )
267+ self ._write_from_disk (path , Path (temp .name ))
268+
269+ @override
270+ @contextlib .contextmanager
271+ def _expose (self ) -> Iterator [Path ]:
272+ tree_sha = self .tree_sha ()
273+ commit_sha = self ._repo .git (
274+ "commit-tree" , "-m" , "draft! worktree" , tree_sha
275+ ).stdout
276+ with tempfile .TemporaryDirectory () as path_str :
277+ try :
278+ self ._repo .git (
279+ "worktree" , "add" , "--detach" , path_str , commit_sha
280+ )
281+ path = Path (path_str )
282+ yield path
283+ self ._sync_updates (worktree_path = path )
284+ finally :
285+ self ._repo .git ("worktree" , "remove" , "-f" , path_str )
228286
229287 def _write_from_disk (
230- self , path : PurePosixPath , contents_path : str
288+ self , path : PurePosixPath , contents_path : Path
231289 ) -> None :
232290 blob_sha = self ._repo .git (
233291 "hash-object" ,
234292 "-w" ,
235293 "--path" ,
236294 str (path ),
237- contents_path ,
295+ str ( contents_path ) ,
238296 ).stdout
239297 self ._tree_updates .append (_WriteBlob (path , blob_sha ))
240298
0 commit comments