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/git.py b/src/git_draft/git.py index 4393522..150bcd8 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,9 @@ 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: - value = _get_config_value(_ConfigKey.REPO_UUID, working_dir) - if value: - return uuid.UUID(value) +def _ensure_repo_uuid(working_dir: Path) -> None: + if _get_config_value(_ConfigKey.REPO_UUID, working_dir): + return repo_uuid = uuid.uuid4() GitCall.sync( "config", @@ -138,7 +142,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 176af24..a0ffbb1 100644 --- a/src/git_draft/toolbox.py +++ b/src/git_draft/toolbox.py @@ -3,10 +3,11 @@ from __future__ import annotations import collections -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterator, Sequence +import contextlib import dataclasses import logging -from pathlib import PurePosixPath +from pathlib import Path, PurePosixPath import tempfile from typing import Protocol, Self, override @@ -20,8 +21,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 +32,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 +86,22 @@ 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, + ) -> 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 + 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 +124,11 @@ def _rename( self._write(dst_path, contents) self._delete(src_path) + def _expose( + self, + ) -> contextlib.AbstractContextManager[Path]: # pragma: no cover + raise NotImplementedError() + class ToolVisitor(Protocol): """Tool usage hook""" @@ -130,6 +156,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 +178,10 @@ def _write(self, _path: PurePosixPath, _contents: str) -> None: def _delete(self, _path: PurePosixPath) -> None: raise RuntimeError() + @override + def _expose(self) -> contextlib.AbstractContextManager[Path]: + raise RuntimeError() + class RepoToolbox(Toolbox): """Git-repo backed toolbox implementation @@ -177,22 +209,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) @@ -224,17 +264,35 @@ 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 path_str: + try: + 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", path_str) 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"