Skip to content

Commit ba30752

Browse files
authored
feat: implement basic draft creation (#10)
1 parent cb2871b commit ba30752

File tree

7 files changed

+624
-24
lines changed

7 files changed

+624
-24
lines changed

docs/git-draft.adoc

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,12 @@ git-draft - git-friendly code assistant
1515

1616
== Synopsis
1717

18-
*git-draft* _-C_ _NAME_
18+
*git-draft* _-C_
1919

20-
*git-draft* _-P_
20+
*git-draft* _-E_
2121

2222
*git-draft* _-A_
2323

24-
*git-draft* _-D_ _[-b BRANCH]_
25-
2624

2725
== Description
2826

@@ -33,19 +31,15 @@ _git-draft_ is a git-centric way to edit code using AI.
3331
When you create a new draft with `git draft -C $name`, a new branch called `$branch/drafts/$name-$hash` is created (`$hash` is a random suffix used to guarantee uniqueness of branch names) and checked out.
3432
Additionally, any uncommitted changes are automatically committed (`draft! sync`).
3533

36-
Once the draft is created, we can use AI to edit our code using `git draft -P`.
37-
It expects the prompt as standard input, for example `echo "Add a test for compute_offset in chart.py" | git draft -P`.
34+
Once the draft is created, we can use AI to edit our code using `git draft -E`.
35+
It expects the prompt as standard input, for example `echo "Add a test for compute_offset in chart.py" | git draft -E`.
3836
The prompt will automatically get augmented with information about the files in the repository, and give the AI access to tools for reading and writing files.
3937
Once the response has been received and changes, applied a commit is created (`draft! prompt: a short summary of the change`).
4038

4139
The prompt step can be repeated as many times as needed. Once you are satisfied with the changes, run `git draft -A` to apply them.
4240
This will check out the branch used when creating the draft, adding the final state of the draft to the worktree.
4341
Note that you can come back to an existing draft anytime (by checking its branch out), but you will not be able to apply it if its origin branch has moved since the draft was created.
4442

45-
Finally, calling `git draft -D` will delete all drafts associated with a branch.
46-
By default the currently active branch is used, you can use the `-b` option to select another.
47-
You can also delete the drafts manually by deleting their branches using `git branch`.
48-
4943

5044
== See also
5145

poetry.lock

Lines changed: 459 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ python = '>=3.12,<4'
2121
black = '^25.1.0'
2222
flake8 = '^7.0.0'
2323
flake8-pyproject = '^1.2.3'
24+
gitpython = '^3.1.44'
2425
mypy = '^1.2.0'
26+
openai = '^1.64.0'
2527
poethepoet = '^0.25.0'
2628
pytest = '^7.1.2'
2729

src/git_draft/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1-
from .backend import Backend
1+
from .actions import apply_draft, create_draft, extend_draft
22

3-
__all__ = ["Backend"]
3+
__all__ = [
4+
"apply_draft",
5+
"create_draft",
6+
"extend_draft",
7+
]

src/git_draft/__main__.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from __future__ import annotations
22

33
import optparse
4+
import sys
5+
6+
from . import apply_draft, create_draft, extend_draft
47

58

69
parser = optparse.OptionParser(prog="git-draft")
@@ -42,17 +45,14 @@ def option_group(self) -> optparse.OptionGroup:
4245
parser.add_option_group(self._option_group)
4346
return self._option_group
4447

45-
def __call__(self, _option, _opt, value, parser, name) -> None:
48+
def __call__(self, _option, _opt, _value, parser, name) -> None:
4649
parser.values.command = name
47-
parser.values.command_args = value
4850

4951

50-
Command.register(
51-
"create", help="create a draft", type="string", metavar="NAME"
52-
)
52+
Command.register("create", help="create a draft")
5353

5454
Command.register(
55-
"prompt", help="read a prompt from stdin to add to the current draft"
55+
"extend", help="read a prompt from stdin to add to the current draft"
5656
)
5757

5858
apply_command = Command.register(
@@ -79,11 +79,12 @@ def main() -> None:
7979
(opts, args) = parser.parse_args()
8080
command = getattr(opts, "command", None)
8181
if command == "create":
82-
print("Creating draft...")
83-
elif command == "prompt":
84-
print("Updating draft...")
82+
create_draft()
83+
elif command == "extend":
84+
prompt = sys.stdin.read()
85+
extend_draft(prompt)
8586
elif command == "apply":
86-
print("Applying draft...")
87+
apply_draft()
8788
elif command == "delete":
8889
print("Deleting draft...")
8990
else:

src/git_draft/actions.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
from __future__ import annotations
2+
3+
import dataclasses
4+
import git
5+
import random
6+
from pathlib import PurePosixPath
7+
import re
8+
import string
9+
import tempfile
10+
from typing import Match, Sequence
11+
12+
from .backend import NewFileBackend
13+
14+
15+
def _enclosing_repo() -> git.Repo:
16+
return git.Repo(search_parent_directories=True)
17+
18+
19+
_random = random.Random()
20+
21+
_SUFFIX_LENGTH = 8
22+
23+
_branch_name_pattern = re.compile(r"drafts/(.+)/(\w+)")
24+
25+
26+
@dataclasses.dataclass(frozen=True)
27+
class _DraftBranch:
28+
parent: str
29+
suffix: str
30+
repo: git.Repo
31+
32+
def __str__(self) -> str:
33+
return f"drafts/{self.parent}/{self.suffix}"
34+
35+
@classmethod
36+
def create(cls, repo: git.Repo) -> _DraftBranch:
37+
if not repo.active_branch:
38+
raise RuntimeError("No currently active branch")
39+
suffix = "".join(
40+
_random.choice(string.ascii_lowercase + string.digits)
41+
for _ in range(_SUFFIX_LENGTH)
42+
)
43+
return cls(repo.active_branch.name, suffix, repo)
44+
45+
@classmethod
46+
def active(cls, repo: git.Repo) -> _DraftBranch:
47+
match: Match | None = None
48+
if repo.active_branch:
49+
match = _branch_name_pattern.fullmatch(repo.active_branch.name)
50+
if not match:
51+
raise RuntimeError("Not currently on a draft branch")
52+
return _DraftBranch(match[1], match[2], repo)
53+
54+
55+
@dataclasses.dataclass(frozen=True)
56+
class _CommitNotes:
57+
# https://stackoverflow.com/a/40496777
58+
pass
59+
60+
61+
def create_draft() -> None:
62+
repo = _enclosing_repo()
63+
draft_branch = _DraftBranch.create(repo)
64+
ref = repo.create_head(str(draft_branch))
65+
repo.git.checkout(ref)
66+
67+
68+
class _Toolbox:
69+
def __init__(self, repo: git.Repo) -> None:
70+
self._repo = repo
71+
72+
def list_files(self) -> Sequence[PurePosixPath]:
73+
# Show staged files.
74+
return self._repo.git.ls_files()
75+
76+
def read_file(self, path: PurePosixPath) -> str:
77+
# Read the file from the index.
78+
return self._repo.git.show(f":{path}")
79+
80+
def write_file(self, path: PurePosixPath, data: str) -> None:
81+
# Update the index without touching the worktree.
82+
# https://stackoverflow.com/a/25352119
83+
with tempfile.NamedTemporaryFile(delete_on_close=False) as temp:
84+
temp.write(data.encode("utf8"))
85+
temp.close()
86+
sha = self._repo.git.hash_object("-w", "--path", path, temp.name)
87+
mode = 644 # TODO: Read from original file if it exists.
88+
self._repo.git.update_index(
89+
"--add", "--cacheinfo", f"{mode},{sha},{path}"
90+
)
91+
92+
93+
def extend_draft(prompt: str) -> None:
94+
repo = _enclosing_repo()
95+
_ = _DraftBranch.active(repo)
96+
97+
if repo.is_dirty():
98+
repo.git.add(all=True)
99+
repo.index.commit("draft! sync")
100+
101+
NewFileBackend().run(_Toolbox(repo))
102+
103+
104+
def apply_draft(delete=False) -> None:
105+
repo = _enclosing_repo()
106+
branch = _DraftBranch.active(repo)
107+
108+
# TODO: Check that parent has not moved. We could do this for example by
109+
# adding a note to the draft branch with the original branch's commit ref.
110+
111+
# https://stackoverflow.com/a/15993574
112+
repo.git.checkout("--detach")
113+
repo.git.reset("--soft", branch.parent)
114+
repo.git.checkout(branch.parent)
115+
116+
if delete:
117+
repo.git.branch("-D", str(branch))

src/git_draft/backend.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,26 @@
1+
from pathlib import PurePosixPath
2+
from typing import Protocol, Sequence
3+
4+
5+
class Toolbox(Protocol):
6+
def list_files(self) -> Sequence[PurePosixPath]: ...
7+
def read_file(self, path: PurePosixPath) -> str: ...
8+
def write_file(self, path: PurePosixPath, data: str) -> None: ...
9+
10+
111
class Backend:
2-
pass
12+
def run(self, toolbox: Toolbox) -> None: ...
13+
14+
15+
class NewFileBackend(Backend):
16+
def run(self, toolbox: Toolbox) -> None:
17+
# send request to backend...
18+
import time
19+
20+
time.sleep(2)
21+
22+
# Add files to index.
23+
import random
24+
25+
name = f"foo-{random.randint(1, 100)}"
26+
toolbox.write_file(PurePosixPath(name), "hello!\n")

0 commit comments

Comments
 (0)