From a59ae5d8f103e9dddae01f41187ad45a5e8ac53c Mon Sep 17 00:00:00 2001 From: Matthieu Monsch Date: Fri, 8 Aug 2025 06:55:15 -0700 Subject: [PATCH 1/4] feat: add tool exposing editable files --- src/git_draft/toolbox.py | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/git_draft/toolbox.py b/src/git_draft/toolbox.py index 176af24..29c4348 100644 --- a/src/git_draft/toolbox.py +++ b/src/git_draft/toolbox.py @@ -3,10 +3,10 @@ from __future__ import annotations import collections -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterator, Sequence import dataclasses import logging -from pathlib import PurePosixPath +from pathlib import Path, PurePosixPath import tempfile from typing import Protocol, Self, override @@ -20,8 +20,8 @@ class Toolbox: """File-system intermediary - Note that the toolbox is not thread-safe. Concurrent operations should be - serialized by the caller. + Note that toolbox implementations may not be thread-safe. Concurrent + operations should be serialized by the caller. """ # TODO: Something similar to https://aider.chat/docs/repomap.html, @@ -31,6 +31,13 @@ class Toolbox: # TODO: Support a diff-based edit method. # https://gist.github.com/noporpoise/16e731849eb1231e86d78f9dfeca3abc + # TODO: Add user feedback tool here. This will make it possible to request + # feedback more than once during a bot action, which leads to a better + # experience when used interactively. + + # TODO: Remove all reason arguments. They are not currently used, and there + # is no obvious use-case at the moment. + def __init__(self, visitors: Sequence[ToolVisitor] | None = None) -> None: self._visitors = visitors or [] @@ -78,9 +85,20 @@ def rename_file( dst_path: PurePosixPath, reason: str | None = None, ) -> None: + """Rename a single file""" self._dispatch(lambda v: v.on_rename_file(src_path, dst_path, reason)) self._rename(src_path, dst_path) + def expose_files(self) -> Iterator[Path]: # pragma: no cover + """Creates a temporary folder with editable copies of all files + + All updates are synced back afterwards. Other operations should not be + performed concurrently as they may be stale or lost. + """ + self._dispatch(lambda v: v.on_expose_files()) + # TODO: Expose updated files to hook? + return self._expose() + def _list(self) -> Sequence[PurePosixPath]: # pragma: no cover raise NotImplementedError() @@ -103,6 +121,9 @@ def _rename( self._write(dst_path, contents) self._delete(src_path) + def _expose(self) -> Iterator[Path]: # pragma: no cover + raise NotImplementedError() + class ToolVisitor(Protocol): """Tool usage hook""" @@ -130,6 +151,8 @@ def on_rename_file( reason: str | None, ) -> None: ... # pragma: no cover + def on_expose_files(self) -> None: ... # pragma: no cover + class NoopToolbox(Toolbox): """No-op read-only toolbox""" @@ -150,6 +173,10 @@ def _write(self, _path: PurePosixPath, _contents: str) -> None: def _delete(self, _path: PurePosixPath) -> None: raise RuntimeError() + @override + def _expose(self) -> Iterator[Path]: + raise RuntimeError() + class RepoToolbox(Toolbox): """Git-repo backed toolbox implementation From c3bf95e693bdc43e6f94adba4519e48e52a9bc1b Mon Sep 17 00:00:00 2001 From: Matthieu Monsch Date: Fri, 8 Aug 2025 10:48:44 -0700 Subject: [PATCH 2/4] fixup! feat: add tool exposing editable files --- src/git_draft/git.py | 18 +++++++---- src/git_draft/toolbox.py | 57 ++++++++++++++++++++++++--------- tests/git_draft/toolbox_test.py | 25 +++++++++++++++ 3 files changed, 77 insertions(+), 23 deletions(-) diff --git a/src/git_draft/git.py b/src/git_draft/git.py index 4393522..c6d265c 100644 --- a/src/git_draft/git.py +++ b/src/git_draft/git.py @@ -79,17 +79,16 @@ def fullname(self) -> str: class Repo: """Git repository""" - def __init__(self, working_dir: Path, uuid: uuid.UUID) -> None: + def __init__(self, working_dir: Path) -> None: self.working_dir = working_dir - self.uuid = uuid @classmethod def enclosing(cls, path: Path) -> Self: """Returns the repo enclosing the given path""" call = GitCall.sync("rev-parse", "--show-toplevel", working_dir=path) working_dir = Path(call.stdout) - uuid = _ensure_repo_uuid(working_dir) - return cls(working_dir, uuid) + _ensure_repo_uuid(working_dir) + return cls(working_dir) def git( self, @@ -107,6 +106,12 @@ def git( working_dir=self.working_dir, ) + @property + def uuid(self) -> uuid.UUID: + value = _get_config_value(_ConfigKey.REPO_UUID, self.working_dir) + assert value + return uuid.UUID(value) + def active_branch(self) -> str | None: return self.git("branch", "--show-current").stdout or None @@ -125,10 +130,10 @@ def _get_config_value(key: _ConfigKey, working_dir: Path) -> str | None: return None if call.code else call.stdout -def _ensure_repo_uuid(working_dir: Path) -> uuid.UUID: +def _ensure_repo_uuid(working_dir: Path) -> None: value = _get_config_value(_ConfigKey.REPO_UUID, working_dir) if value: - return uuid.UUID(value) + return repo_uuid = uuid.uuid4() GitCall.sync( "config", @@ -138,7 +143,6 @@ def _ensure_repo_uuid(working_dir: Path) -> uuid.UUID: working_dir=working_dir, ) _logger.debug("Set repo UUID. [uuid=%s]", repo_uuid) - return repo_uuid def null_delimited(arg: str) -> Iterator[str]: diff --git a/src/git_draft/toolbox.py b/src/git_draft/toolbox.py index 29c4348..cd07768 100644 --- a/src/git_draft/toolbox.py +++ b/src/git_draft/toolbox.py @@ -4,6 +4,7 @@ import collections from collections.abc import Callable, Iterator, Sequence +import contextlib import dataclasses import logging from pathlib import Path, PurePosixPath @@ -204,22 +205,30 @@ def __init__( def for_working_dir(cls, repo: Repo) -> tuple[Self, bool]: index_tree_sha = repo.git("write-tree").stdout toolbox = cls(repo, index_tree_sha) - - # Apply any changes from the working directory. - deleted = set[SHA]() - for path in null_delimited(repo.git("ls-files", "-dz").stdout): - deleted.add(path) - toolbox._delete(PurePosixPath(path)) - for path in null_delimited( - repo.git("ls-files", "-moz", "--exclude-standard").stdout - ): - if path in deleted: - continue # Deleted files also show up as modified - toolbox._write_from_disk(PurePosixPath(path), path) - + toolbox._sync_updates() # Apply any changes from the working directory head_tree_sha = repo.git("rev-parse", "HEAD^{tree}").stdout return toolbox, toolbox.tree_sha() != head_tree_sha + def _sync_updates(self, *, worktree_path: Path | None = None) -> None: + repo = self._repo + if worktree_path: + repo = Repo(worktree_path) + + def ls_files(*args: str) -> Iterator[str]: + return null_delimited(repo.git("ls-files", *args).stdout) + + deleted = set[str]() + for path_str in ls_files("-dz"): + deleted.add(path_str) + self._delete(PurePosixPath(path_str)) + for path_str in ls_files("-moz", "--exclude-standard"): + if path_str in deleted: + continue # Deleted files also show up as modified + self._write_from_disk( + PurePosixPath(path_str), + worktree_path / path_str if worktree_path else Path(path_str), + ) + def with_visitors(self, visitors: Sequence[ToolVisitor]) -> Self: return self.__class__(self._repo, self.tree_sha(), visitors) @@ -251,17 +260,33 @@ def _write(self, path: PurePosixPath, contents: str) -> None: with tempfile.NamedTemporaryFile(delete_on_close=False) as temp: temp.write(contents.encode("utf8")) temp.close() - self._write_from_disk(path, temp.name) + self._write_from_disk(path, Path(temp.name)) + + @override + @contextlib.contextmanager + def _expose(self) -> Iterator[Path]: + tree_sha = self.tree_sha() + commit_sha = self._repo.git( + "commit-tree", "-m", "draft! worktree", tree_sha + ).stdout + with tempfile.TemporaryDirectory() as name: + try: + self._repo.git("worktree", "add", "--detach", name, commit_sha) + path = Path(name) + yield path + self._sync_updates(worktree_path=path) + finally: + self._repo.git("worktree", "remove", "-f", name) def _write_from_disk( - self, path: PurePosixPath, contents_path: str + self, path: PurePosixPath, contents_path: Path ) -> None: blob_sha = self._repo.git( "hash-object", "-w", "--path", str(path), - contents_path, + str(contents_path), ).stdout self._tree_updates.append(_WriteBlob(path, blob_sha)) diff --git a/tests/git_draft/toolbox_test.py b/tests/git_draft/toolbox_test.py index 085cadc..57e1630 100644 --- a/tests/git_draft/toolbox_test.py +++ b/tests/git_draft/toolbox_test.py @@ -67,3 +67,28 @@ def test_for_working_dir_dirty(self) -> None: assert toolbox.read_file(PPP("f1")) == "aa" assert toolbox.read_file(PPP("f2")) is None assert toolbox.read_file(PPP("f3")) == "c" + + def test_expose_files(self) -> None: + self._fs.write("f1", "a") + self._fs.write("f2", "b") + self._fs.flush() + toolbox = sut.RepoToolbox(self._repo, "HEAD") + toolbox.delete_file(PPP("f1")) + toolbox.write_file(PPP("f3"), "c") + + with toolbox.expose_files() as path: + assert {".git", "f2", "f3"} == set(c.name for c in path.iterdir()) + with open(path / "f2", "w") as w: + w.write("bb") + with open(path / "f4", "w") as w: + w.write("d") + (path / "f3").unlink() + + # Before sync, toolbox does not have changes. + assert toolbox.read_file(PPP("f2")) == "b" + assert toolbox.read_file(PPP("f3")) == "c" + + # After sync, toolbox has changes propagated. + assert toolbox.read_file(PPP("f2")) == "bb" + assert toolbox.read_file(PPP("f3")) is None + assert toolbox.read_file(PPP("f4")) == "d" From f8c1d9ff91b41bef379aa72836d449509d04c79d Mon Sep 17 00:00:00 2001 From: Matthieu Monsch Date: Fri, 8 Aug 2025 11:32:05 -0700 Subject: [PATCH 3/4] fixup! feat: add tool exposing editable files --- src/git_draft/git.py | 3 +-- src/git_draft/toolbox.py | 10 ++++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/git_draft/git.py b/src/git_draft/git.py index c6d265c..150bcd8 100644 --- a/src/git_draft/git.py +++ b/src/git_draft/git.py @@ -131,8 +131,7 @@ def _get_config_value(key: _ConfigKey, working_dir: Path) -> str | None: def _ensure_repo_uuid(working_dir: Path) -> None: - value = _get_config_value(_ConfigKey.REPO_UUID, working_dir) - if value: + if _get_config_value(_ConfigKey.REPO_UUID, working_dir): return repo_uuid = uuid.uuid4() GitCall.sync( diff --git a/src/git_draft/toolbox.py b/src/git_draft/toolbox.py index cd07768..40deca6 100644 --- a/src/git_draft/toolbox.py +++ b/src/git_draft/toolbox.py @@ -269,14 +269,16 @@ def _expose(self) -> Iterator[Path]: commit_sha = self._repo.git( "commit-tree", "-m", "draft! worktree", tree_sha ).stdout - with tempfile.TemporaryDirectory() as name: + with tempfile.TemporaryDirectory() as path_str: try: - self._repo.git("worktree", "add", "--detach", name, commit_sha) - path = Path(name) + self._repo.git( + "worktree", "add", "--detach", path_str, commit_sha + ) + path = Path(path_str) yield path self._sync_updates(worktree_path=path) finally: - self._repo.git("worktree", "remove", "-f", name) + self._repo.git("worktree", "remove", "-f", path_str) def _write_from_disk( self, path: PurePosixPath, contents_path: Path From a32b44ccca8e73510d986e5891a1f0e3ce0663f5 Mon Sep 17 00:00:00 2001 From: Matthieu Monsch Date: Fri, 8 Aug 2025 11:35:49 -0700 Subject: [PATCH 4/4] fixup! feat: add tool exposing editable files --- src/git_draft/drafter.py | 4 ++++ src/git_draft/toolbox.py | 10 +++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/git_draft/drafter.py b/src/git_draft/drafter.py index 64bd708..c240eec 100644 --- a/src/git_draft/drafter.py +++ b/src/git_draft/drafter.py @@ -479,6 +479,10 @@ def on_rename_file( dst_path=str(dst_path), ) + def on_expose_files(self) -> None: + self._progress.report("Exposed files.") + self._record(None, "expose_files") + def _record(self, reason: str | None, tool: str, **kwargs) -> None: op = _Operation( tool=tool, details=kwargs, reason=reason, start=datetime.now() diff --git a/src/git_draft/toolbox.py b/src/git_draft/toolbox.py index 40deca6..a0ffbb1 100644 --- a/src/git_draft/toolbox.py +++ b/src/git_draft/toolbox.py @@ -90,7 +90,9 @@ def rename_file( self._dispatch(lambda v: v.on_rename_file(src_path, dst_path, reason)) self._rename(src_path, dst_path) - def expose_files(self) -> Iterator[Path]: # pragma: no cover + def expose_files( + self, + ) -> contextlib.AbstractContextManager[Path]: # pragma: no cover """Creates a temporary folder with editable copies of all files All updates are synced back afterwards. Other operations should not be @@ -122,7 +124,9 @@ def _rename( self._write(dst_path, contents) self._delete(src_path) - def _expose(self) -> Iterator[Path]: # pragma: no cover + def _expose( + self, + ) -> contextlib.AbstractContextManager[Path]: # pragma: no cover raise NotImplementedError() @@ -175,7 +179,7 @@ def _delete(self, _path: PurePosixPath) -> None: raise RuntimeError() @override - def _expose(self) -> Iterator[Path]: + def _expose(self) -> contextlib.AbstractContextManager[Path]: raise RuntimeError()