Skip to content

Commit 9895284

Browse files
committed
feat: implement accept option
1 parent 57eb02e commit 9895284

File tree

5 files changed

+113
-34
lines changed

5 files changed

+113
-34
lines changed

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ pipx install git-draft[openai]
2323
* Mechanism for reporting feedback from a bot, and possibly allowing user to
2424
interactively respond.
2525
* Add configuration option to auto sync and `--no-sync` flag. Similar to reset.
26-
* Add "amend" commit when finalizing. This could be useful training data,
27-
showing what the bot did not get right.
28-
* Convenience `--accept` functionality for simple cases: checkout option which
29-
applies the changes, and finalizes the draft if specified multiple times. For
30-
example `git draft -aa add-test symbol=foo`
26+
* Add optional "amend" commit when finalizing. This could be useful training
27+
data, showing what the bot did not get right.
28+
* Support file rename tool.
29+
* https://stackoverflow.com/q/49853177/1062617
30+
* https://stackoverflow.com/q/6658313/1062617

docs/git-draft.adoc

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ IMPORTANT: `git-draft` is WIP.
1818
== Synopsis
1919

2020
[verse]
21-
git draft [options] [--generate] [--bot BOT] [--edit] [--reset | --no-reset] [--sync] [TEMPLATE [VARIABLE...]]
21+
git draft [options] [--generate] [--accept...] [--bot BOT] [--edit]
22+
[--reset | --no-reset] [--sync]
23+
[TEMPLATE [VARIABLE...]]
2224
git draft [options] --finalize [--clean | --revert] [--delete]
2325
git draft [options] --show-drafts [--json]
2426
git draft [options] --show-prompts [--json] [PROMPT]
@@ -27,34 +29,43 @@ git draft [options] --show-templates [--json | [--edit] TEMPLATE]
2729

2830
== Description
2931

30-
`git-draft` is a git-centric way to edit code using AI.
32+
`git-draft` is a git-centric way to develop using AI.
3133

3234

3335
== Options
3436

37+
-a::
38+
--accept*::
39+
Check out generated changes automatically.
40+
Can be repeated.
41+
This may fail if you manually edit files that the bot updates during generation.
42+
43+
3544
-b BOT::
3645
--bot=BOT::
3746
Bot name.
3847

3948
-c::
40-
--clean::
41-
TODO
49+
--clean*::
50+
Remove files deleted during a draft which otherwise would show up as untracked.
4251

4352
-d::
4453
--delete::
4554
Delete finalized branch.
4655

4756
-e::
4857
--edit::
49-
Edit.
58+
Enable interactive editing of prompts and templates.
59+
See `--generate` and `--show-templates` for details.
5060

5161
-F::
5262
--finalize::
53-
TODO
63+
Go back to the draft's origin branch with the current working directory.
5464

5565
-G::
5666
--generate::
57-
TODO
67+
Add an AI-generated commit.
68+
If the `--edit` option is set, an interactive editor will be open with the rendered prompt to allow modification before it is shared with the bot.
5869

5970
-h::
6071
--help::
@@ -69,26 +80,29 @@ git draft [options] --show-templates [--json | [--edit] TEMPLATE]
6980

7081
--reset::
7182
--no-reset::
72-
TODO
83+
Controls behavior when staged changes are present at the start of a generate command.
84+
If enabled, these changes are automatically reset and combined with other working directory changes.
85+
Otherwise an error is raised.
7386

7487
-r::
75-
--revert::
88+
--revert*::
7689
Abandon any changes since draft creation.
7790

7891
--root::
7992
Repository search root.
8093

8194
-D::
8295
--show-drafts::
83-
TODO
96+
List recently created drafts.
8497

8598
-P::
8699
--show-prompts::
87-
TODO
100+
Lists recently used prompts.
88101

89102
-T::
90103
--show-templates::
91-
TODO
104+
Lists available templates.
105+
With an template name argument, displays the corresponding template's contents or, if the `--edit` option is set, opens an interactive editor.
92106

93107
-s::
94108
--sync::
@@ -102,6 +116,10 @@ git draft [options] --show-templates [--json | [--edit] TEMPLATE]
102116
Show version and exit.
103117

104118

119+
Options suffixed with * may modify the working directory's contents.
120+
All other combinations of options never write to it, and are safe to do concurrently with other working directory operations.
121+
122+
105123
== Examples
106124

107125
The workhorse command is `git draft --generate` which leverages AI to edit our code.

src/git_draft/__main__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from .bots import load_bot
1313
from .common import PROGRAM, Config, UnreachableError, ensure_state_home
14-
from .drafter import Drafter
14+
from .drafter import Accept, Drafter
1515
from .editor import open_editor
1616
from .prompt import Template, TemplatedPrompt, templates_table
1717
from .store import Store
@@ -58,6 +58,12 @@ def callback(_option, _opt, _value, parser) -> None:
5858
add_command("show-prompts", short="P", help="show prompt history")
5959
add_command("show-templates", short="T", help="show template information")
6060

61+
parser.add_option(
62+
"-a",
63+
"--accept",
64+
help="apply generated changes",
65+
action="count",
66+
)
6167
parser.add_option(
6268
"-b",
6369
"--bot",
@@ -204,6 +210,7 @@ def main() -> None: # noqa: PLR0912 PLR0915
204210
name = drafter.generate_draft(
205211
prompt,
206212
bot,
213+
accept=Accept(opts.accept or 0),
207214
bot_name=opts.bot,
208215
prompt_transform=open_editor if editable else None,
209216
tool_visitors=[ToolPrinter()],

src/git_draft/drafter.py

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import dataclasses
66
from datetime import datetime
7+
import enum
78
import json
89
import logging
910
import os
@@ -57,6 +58,14 @@ def new_suffix():
5758
return random_id(9)
5859

5960

61+
class Accept(enum.Enum):
62+
"""Valid accept modes"""
63+
64+
MANUAL = enum.auto()
65+
CHECKOUT = enum.auto()
66+
FINALIZE = enum.auto()
67+
68+
6069
class Drafter:
6170
"""Draft state orchestrator"""
6271

@@ -77,12 +86,13 @@ def generate_draft( # noqa: PLR0913
7786
self,
7887
prompt: str | TemplatedPrompt,
7988
bot: Bot,
89+
accept: Accept = Accept.MANUAL,
8090
bot_name: str | None = None,
81-
tool_visitors: Sequence[ToolVisitor] | None = None,
8291
prompt_transform: Callable[[str], str] | None = None,
8392
reset: bool = False,
8493
sync: bool = False,
8594
timeout: float | None = None,
95+
tool_visitors: Sequence[ToolVisitor] | None = None,
8696
) -> str:
8797
if timeout is not None:
8898
raise NotImplementedError() # TODO
@@ -172,7 +182,17 @@ def generate_draft( # noqa: PLR0913
172182
)
173183

174184
_logger.info("Completed generation for %s.", branch)
175-
return str(branch)
185+
if accept == Accept.MANUAL:
186+
return str(branch)
187+
188+
# Check out files from the index. Since we assume that users do not
189+
# manually update the index in draft branches, this is equivalent to
190+
# checking out the files from the latest (generated, here) commit.
191+
# delta = self._delta(
192+
self._repo.git.checkout(".", theirs=True)
193+
if accept == Accept.CHECKOUT:
194+
return str(branch)
195+
return self.exit_draft(revert=False, clean=accept == Accept.CLEAN)
176196

177197
def exit_draft(self, *, revert: bool, clean=False, delete=False) -> str:
178198
branch = _Branch.active(self._repo)
@@ -195,9 +215,10 @@ def exit_draft(self, *, revert: bool, clean=False, delete=False) -> str:
195215
raise RuntimeError("Parent branch has moved, please rebase first")
196216

197217
if clean and not revert:
198-
# We delete files which have been deleted in the draft manually,
218+
_logger.debug("Cleaning up files.")
219+
# We manually delete files which have been deleted in the draft,
199220
# otherwise they would still show up as untracked.
200-
origin_delta = self._delta(f"{origin_branch}..{branch}")
221+
origin_delta = self._delta(start=origin_branch, end=str(branch))
201222
deleted = self._untracked() & origin_delta.deleted
202223
for path in deleted:
203224
os.remove(osp.join(self._repo.working_dir, path))
@@ -211,17 +232,18 @@ def exit_draft(self, *, revert: bool, clean=False, delete=False) -> str:
211232
self._repo.git.checkout(origin_branch)
212233

213234
if revert:
235+
_logger.debug("Reverting changes... [sync_sha=%s]", sync_sha)
214236
# We revert the relevant files if needed. If a sync commit had been
215237
# created, we simply revert to it. Otherwise we compute which files
216238
# have changed due to draft commits and revert only those.
217239
if sync_sha:
218-
delta = self._delta(sync_sha)
219-
if delta.changed:
220-
self._repo.git.checkout(sync_sha, "--", ".")
240+
self._repo.git.checkout("-f", sync_sha)
221241
_logger.info("Reverted to sync commit. [sha=%s]", sync_sha)
222242
else:
223-
origin_delta = self._delta(f"{origin_branch}..{branch}")
224-
head_delta = self._delta("HEAD")
243+
origin_delta = self._delta(
244+
start=origin_branch, end=str(branch)
245+
)
246+
head_delta = self._delta(end="HEAD")
225247
changed = head_delta.touched & origin_delta.changed
226248
if changed:
227249
self._repo.git.checkout("--", *changed)
@@ -304,15 +326,18 @@ def _create_branch(self, sync: bool) -> _Branch:
304326
def _stage_changes(self, sync: bool) -> str | None:
305327
self._repo.git.add(all=True)
306328
if not sync or not self._repo.is_dirty(untracked_files=True):
329+
_logger.debug("Skipped sync commit creation. [sync=%s]", sync)
307330
return None
308331
ref = self._repo.index.commit("draft! sync")
332+
_logger.debug("Created sync commit. [sha=%s]", ref.hexsha)
309333
return ref.hexsha
310334

311335
def _untracked(self) -> frozenset[str]:
312336
text = self._repo.git.ls_files(exclude_standard=True, others=True)
313337
return frozenset(text.splitlines())
314338

315-
def _delta(self, spec) -> _Delta:
339+
def _delta(self, *, start: str | None = None, end: str) -> _Delta:
340+
spec = f"{start}..{end}" if start else end
316341
changed = list[str]()
317342
deleted = list[str]()
318343
for line in self._repo.git.diff(spec, name_status=True).splitlines():
@@ -321,7 +346,9 @@ def _delta(self, spec) -> _Delta:
321346
deleted.append(name)
322347
else:
323348
changed.append(name)
324-
return _Delta(changed=frozenset(changed), deleted=frozenset(deleted))
349+
delta = _Delta(changed=frozenset(changed), deleted=frozenset(deleted))
350+
_logger.debug("Computed delta for %s: %s", spec, delta)
351+
return delta
325352

326353

327354
@dataclasses.dataclass(frozen=True)

tests/git_draft/drafter_test.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def test_generate_dirty_index_reset_sync(self) -> None:
100100
assert self._read("PROMPT") == "hi"
101101
assert len(self._commits()) == 3 # init, sync, prompt
102102

103-
def test_generate_clean_index_sync(self) -> None:
103+
def test_generate_empty_index_sync(self) -> None:
104104
prompt = TemplatedPrompt("add-test", {"symbol": "abc"})
105105
self._drafter.generate_draft(prompt, FakeBot(), sync=True)
106106
self._repo.git.checkout(".")
@@ -142,20 +142,45 @@ def act(self, _goal: Goal, toolbox: Toolbox) -> Action:
142142

143143
def test_sync_delete_revert(self) -> None:
144144
self._write("p1", "a")
145+
self._write("p2", "b")
145146
self._repo.git.add(all=True)
146147
self._repo.index.commit("advance")
147148
self._delete("p1")
149+
self._delete("p2")
150+
self._write("p3", "c")
148151

149152
class CustomBot(Bot):
150153
def act(self, _goal: Goal, toolbox: Toolbox) -> Action:
151154
toolbox.write_file(PurePosixPath("p2"), "b")
155+
toolbox.write_file(PurePosixPath("p4"), "d")
152156
return Action()
153157

154-
self._drafter.generate_draft("hello", CustomBot(), sync=True)
158+
self._drafter.generate_draft(
159+
"hello", CustomBot(), accept=sut.Accept.CHECKOUT, sync=True
160+
)
155161
assert self._read("p1") is None
162+
assert self._read("p2") == "b"
163+
assert self._read("p3") == "c"
164+
assert self._read("p4") == "d"
156165

157166
self._drafter.exit_draft(revert=True)
158167
assert self._read("p1") is None
168+
assert self._read("p2") is None
169+
assert self._read("p3") == "c"
170+
assert self._read("p4") is None
171+
172+
def test_generate_accept_deletion(self) -> None:
173+
self._write("p1", "a")
174+
175+
class CustomBot(Bot):
176+
def act(self, _goal: Goal, toolbox: Toolbox) -> Action:
177+
toolbox.delete_file(PurePosixPath("p1"))
178+
return Action()
179+
180+
self._drafter.generate_draft(
181+
"hello", CustomBot(), accept=sut.Accept.CHECKOUT
182+
)
183+
assert self._read("p1") is None
159184

160185
def test_generate_delete_finalize_clean(self) -> None:
161186
self._write("p1", "a")
@@ -167,10 +192,12 @@ def act(self, _goal: Goal, toolbox: Toolbox) -> Action:
167192
toolbox.delete_file(PurePosixPath("p1"))
168193
return Action()
169194

170-
self._drafter.generate_draft("hello", CustomBot())
171-
assert self._read("p1") == "a"
195+
self._drafter.generate_draft(
196+
"hello", CustomBot(), accept=sut.Accept.CHECKOUT
197+
)
198+
assert self._read("p1") is None
172199

173-
self._drafter.exit_draft(revert=False, clean=True)
200+
self._drafter.exit_draft(revert=False)
174201
assert self._read("p1") is None
175202

176203
def test_revert_outside_draft(self) -> None:

0 commit comments

Comments
 (0)