Skip to content
Merged
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
4 changes: 4 additions & 0 deletions src/git_draft/drafter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
21 changes: 12 additions & 9 deletions src/git_draft/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand All @@ -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",
Expand All @@ -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]:
Expand Down
98 changes: 78 additions & 20 deletions src/git_draft/toolbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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 []

Expand Down Expand Up @@ -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()

Expand All @@ -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"""
Expand Down Expand Up @@ -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"""
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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))

Expand Down
25 changes: 25 additions & 0 deletions tests/git_draft/toolbox_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"