Skip to content

Commit 3a1cf44

Browse files
authored
feat: add tool exposing editable files (#95)
1 parent dbee8e2 commit 3a1cf44

File tree

4 files changed

+119
-29
lines changed

4 files changed

+119
-29
lines changed

src/git_draft/drafter.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,10 @@ def on_rename_file(
479479
dst_path=str(dst_path),
480480
)
481481

482+
def on_expose_files(self) -> None:
483+
self._progress.report("Exposed files.")
484+
self._record(None, "expose_files")
485+
482486
def _record(self, reason: str | None, tool: str, **kwargs) -> None:
483487
op = _Operation(
484488
tool=tool, details=kwargs, reason=reason, start=datetime.now()

src/git_draft/git.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,17 +79,16 @@ def fullname(self) -> str:
7979
class Repo:
8080
"""Git repository"""
8181

82-
def __init__(self, working_dir: Path, uuid: uuid.UUID) -> None:
82+
def __init__(self, working_dir: Path) -> None:
8383
self.working_dir = working_dir
84-
self.uuid = uuid
8584

8685
@classmethod
8786
def enclosing(cls, path: Path) -> Self:
8887
"""Returns the repo enclosing the given path"""
8988
call = GitCall.sync("rev-parse", "--show-toplevel", working_dir=path)
9089
working_dir = Path(call.stdout)
91-
uuid = _ensure_repo_uuid(working_dir)
92-
return cls(working_dir, uuid)
90+
_ensure_repo_uuid(working_dir)
91+
return cls(working_dir)
9392

9493
def git(
9594
self,
@@ -107,6 +106,12 @@ def git(
107106
working_dir=self.working_dir,
108107
)
109108

109+
@property
110+
def uuid(self) -> uuid.UUID:
111+
value = _get_config_value(_ConfigKey.REPO_UUID, self.working_dir)
112+
assert value
113+
return uuid.UUID(value)
114+
110115
def active_branch(self) -> str | None:
111116
return self.git("branch", "--show-current").stdout or None
112117

@@ -125,10 +130,9 @@ def _get_config_value(key: _ConfigKey, working_dir: Path) -> str | None:
125130
return None if call.code else call.stdout
126131

127132

128-
def _ensure_repo_uuid(working_dir: Path) -> uuid.UUID:
129-
value = _get_config_value(_ConfigKey.REPO_UUID, working_dir)
130-
if value:
131-
return uuid.UUID(value)
133+
def _ensure_repo_uuid(working_dir: Path) -> None:
134+
if _get_config_value(_ConfigKey.REPO_UUID, working_dir):
135+
return
132136
repo_uuid = uuid.uuid4()
133137
GitCall.sync(
134138
"config",
@@ -138,7 +142,6 @@ def _ensure_repo_uuid(working_dir: Path) -> uuid.UUID:
138142
working_dir=working_dir,
139143
)
140144
_logger.debug("Set repo UUID. [uuid=%s]", repo_uuid)
141-
return repo_uuid
142145

143146

144147
def null_delimited(arg: str) -> Iterator[str]:

src/git_draft/toolbox.py

Lines changed: 78 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
from __future__ import annotations
44

55
import collections
6-
from collections.abc import Callable, Sequence
6+
from collections.abc import Callable, Iterator, Sequence
7+
import contextlib
78
import dataclasses
89
import logging
9-
from pathlib import PurePosixPath
10+
from pathlib import Path, PurePosixPath
1011
import tempfile
1112
from typing import Protocol, Self, override
1213

@@ -20,8 +21,8 @@
2021
class 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

107133
class 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

134162
class 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

154186
class 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

tests/git_draft/toolbox_test.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,28 @@ def test_for_working_dir_dirty(self) -> None:
6767
assert toolbox.read_file(PPP("f1")) == "aa"
6868
assert toolbox.read_file(PPP("f2")) is None
6969
assert toolbox.read_file(PPP("f3")) == "c"
70+
71+
def test_expose_files(self) -> None:
72+
self._fs.write("f1", "a")
73+
self._fs.write("f2", "b")
74+
self._fs.flush()
75+
toolbox = sut.RepoToolbox(self._repo, "HEAD")
76+
toolbox.delete_file(PPP("f1"))
77+
toolbox.write_file(PPP("f3"), "c")
78+
79+
with toolbox.expose_files() as path:
80+
assert {".git", "f2", "f3"} == set(c.name for c in path.iterdir())
81+
with open(path / "f2", "w") as w:
82+
w.write("bb")
83+
with open(path / "f4", "w") as w:
84+
w.write("d")
85+
(path / "f3").unlink()
86+
87+
# Before sync, toolbox does not have changes.
88+
assert toolbox.read_file(PPP("f2")) == "b"
89+
assert toolbox.read_file(PPP("f3")) == "c"
90+
91+
# After sync, toolbox has changes propagated.
92+
assert toolbox.read_file(PPP("f2")) == "bb"
93+
assert toolbox.read_file(PPP("f3")) is None
94+
assert toolbox.read_file(PPP("f4")) == "d"

0 commit comments

Comments
 (0)