Skip to content

Commit 6adc84f

Browse files
authored
feat: improve merge behavior (#70)
When merging, we use the `sync(prompt)` commit as parent to prevent inaccurate conflicts due to prior changes.
1 parent ca19014 commit 6adc84f

File tree

8 files changed

+97
-57
lines changed

8 files changed

+97
-57
lines changed

docs/git-draft.adoc

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,6 @@ git draft [options] --show-templates [--json | [--edit] TEMPLATE]
7474
Lists available templates.
7575
With an template name argument, displays the corresponding template's contents or, if the `--edit` option is set, opens an interactive editor.
7676

77-
-t TIMEOUT::
78-
--timeout=TIMEOUT::
79-
Action timeout.
80-
8177
--version::
8278
Show version and exit.
8379

src/git_draft/__main__.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from collections.abc import Sequence
6+
import enum
67
import importlib.metadata
78
import logging
89
import optparse
@@ -11,7 +12,7 @@
1112

1213
from .bots import load_bot
1314
from .common import PROGRAM, Config, UnreachableError, ensure_state_home
14-
from .drafter import Accept, Drafter
15+
from .drafter import Drafter, DraftMergeStrategy
1516
from .editor import open_editor
1617
from .git import Repo
1718
from .prompt import Template, TemplatedPrompt, find_template, templates_table
@@ -95,15 +96,28 @@ def callback(
9596
action="store_const",
9697
const=0,
9798
)
98-
parser.add_option(
99-
"--timeout",
100-
dest="timeout",
101-
help="generation timeout",
102-
)
10399

104100
return parser
105101

106102

103+
class Accept(enum.Enum):
104+
"""Valid change accept mode"""
105+
106+
MANUAL = 0
107+
MERGE = enum.auto()
108+
MERGE_THEIRS = enum.auto()
109+
FINALIZE = enum.auto()
110+
111+
def merge_strategy(self) -> DraftMergeStrategy | None:
112+
match self.value:
113+
case Accept.MANUAL:
114+
return None
115+
case Accept.MERGE:
116+
return "ignore-all-space"
117+
case _:
118+
return "theirs"
119+
120+
107121
class ToolPrinter(ToolVisitor):
108122
"""Visitor implementation which prints invocations to stdout"""
109123

@@ -199,17 +213,17 @@ def main() -> None: # noqa: PLR0912 PLR0915
199213
drafter.generate_draft(
200214
prompt,
201215
bot,
202-
accept=accept,
203-
bot_name=opts.bot,
204216
prompt_transform=open_editor if editable else None,
217+
merge_strategy=accept.merge_strategy(),
205218
tool_visitors=[ToolPrinter()],
206219
)
207220
match accept:
208221
case Accept.MANUAL:
209222
print("Generated draft.")
210-
case Accept.MERGE:
223+
case Accept.MERGE | Accept.MERGE_THEIRS:
211224
print("Merged draft.")
212225
case Accept.FINALIZE:
226+
drafter.finalize_folio()
213227
print("Finalized draft.")
214228
case _:
215229
raise UnreachableError()

src/git_draft/bots/common.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class Goal:
1414
"""Bot request"""
1515

1616
prompt: str
17-
timeout: float | None
17+
# TODO: Add timeout.
1818

1919

2020
@dataclasses.dataclass

src/git_draft/drafter.py

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
from collections.abc import Callable, Sequence
66
import dataclasses
77
from datetime import datetime, timedelta
8-
import enum
98
import json
109
import logging
1110
from pathlib import PurePosixPath
1211
import re
1312
import textwrap
1413
import time
14+
from typing import Literal
1515

1616
from .bots import Action, Bot, Goal
1717
from .common import JSONObject, qualified_class_name
@@ -24,14 +24,6 @@
2424
_logger = logging.getLogger(__name__)
2525

2626

27-
class Accept(enum.Enum):
28-
"""Valid change accept mode"""
29-
30-
MANUAL = 0
31-
MERGE = enum.auto()
32-
FINALIZE = enum.auto()
33-
34-
3527
@dataclasses.dataclass(frozen=True)
3628
class Draft:
3729
"""Generated changes"""
@@ -81,6 +73,18 @@ def _active_folio(repo: Repo) -> Folio | None:
8173
return Folio(int(match[1]))
8274

8375

76+
#: Select ort strategies.
77+
DraftMergeStrategy = Literal[
78+
"ours",
79+
"theirs",
80+
"ignore-space-change",
81+
"ignore-all-space",
82+
"ignore-space-at-eol",
83+
"ignore-cr-at-eol",
84+
"find-renames",
85+
]
86+
87+
8488
class Drafter:
8589
"""Draft state orchestrator"""
8690

@@ -94,19 +98,14 @@ def create(cls, repo: Repo, store: Store) -> Drafter:
9498
cursor.executescript(sql("create-tables"))
9599
return cls(store, repo)
96100

97-
def generate_draft( # noqa: PLR0913
101+
def generate_draft(
98102
self,
99103
prompt: str | TemplatedPrompt,
100104
bot: Bot,
101-
accept: Accept = Accept.MANUAL,
102-
bot_name: str | None = None,
105+
merge_strategy: DraftMergeStrategy | None = None,
103106
prompt_transform: Callable[[str], str] | None = None,
104-
timeout: float | None = None,
105107
tool_visitors: Sequence[ToolVisitor] | None = None,
106108
) -> Draft:
107-
if timeout is not None:
108-
raise NotImplementedError() # TODO: Implement
109-
110109
# Handle prompt templating and editing. We do this first in case this
111110
# fails, to avoid creating unnecessary branches.
112111
toolbox, dirty = RepoToolbox.for_working_dir(self._repo)
@@ -134,7 +133,7 @@ def generate_draft( # noqa: PLR0913
134133
operation_recorder = _OperationRecorder()
135134
change = self._generate_change(
136135
bot,
137-
Goal(prompt_contents, timeout),
136+
Goal(prompt_contents),
138137
toolbox.with_visitors(
139138
[operation_recorder, *list(tool_visitors or [])],
140139
),
@@ -159,7 +158,6 @@ def generate_draft( # noqa: PLR0913
159158
{
160159
"commit_sha": commit_sha,
161160
"prompt_id": prompt_id,
162-
"bot_name": bot_name,
163161
"bot_class": qualified_class_name(bot.__class__),
164162
"walltime_seconds": change.walltime.total_seconds(),
165163
"request_count": change.action.request_count,
@@ -181,18 +179,21 @@ def generate_draft( # noqa: PLR0913
181179
)
182180
_logger.info("Created new change in folio %s.", folio.id)
183181

184-
if accept.value >= Accept.MERGE.value:
182+
if merge_strategy:
183+
if parent_commit_rev != "HEAD":
184+
# If there was a sync(prompt) commit, we move forward to it.
185+
# This will avoid conflicts with changes that happened earlier.
186+
self._repo.git("reset", "--soft", parent_commit_rev)
185187
self._sync_head("merge")
186188
self._repo.git(
187189
"merge",
188190
"--no-ff",
189-
"-Xtheirs",
191+
"-X",
192+
merge_strategy,
190193
"-m",
191194
"draft! merge",
192195
commit_sha,
193196
)
194-
if accept.value >= Accept.FINALIZE.value:
195-
self.finalize_folio()
196197

197198
return Draft(
198199
folio=folio,

src/git_draft/git.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,10 @@ def sync(
5050
stdout, stderr = popen.communicate(input=stdin)
5151
code = popen.returncode
5252
if expect_codes and code not in expect_codes:
53-
raise GitError(
54-
f"Git command failed with code {code}\n{stderr}\n{stdout}"
55-
)
53+
message = f"Git command failed with code exit {code}: {stderr}"
54+
if stdout:
55+
message += f"\n{stdout}"
56+
raise GitError(message)
5657
return cls(code, stdout.rstrip(), stderr.rstrip())
5758

5859

src/git_draft/queries/add-action.sql

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
insert into actions (
22
commit_sha,
33
prompt_id,
4-
bot_name,
54
bot_class,
65
walltime_seconds,
76
request_count,
87
token_count)
98
values (
109
:commit_sha,
1110
:prompt_id,
12-
:bot_name,
1311
:bot_class,
1412
:walltime_seconds,
1513
:request_count,

src/git_draft/queries/create-tables.sql

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ create table if not exists actions (
2323
commit_sha text primary key,
2424
created_at timestamp default current_timestamp,
2525
prompt_id integer not null,
26-
bot_name text,
2726
bot_class text not null,
2827
walltime_seconds real not null,
2928
request_count int,

tests/git_draft/drafter_test.py

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from git_draft.bots import Action, Bot, Goal, Toolbox
88
import git_draft.drafter as sut
9-
from git_draft.git import SHA, Repo
9+
from git_draft.git import SHA, GitError, Repo
1010
from git_draft.store import Store
1111

1212
from .conftest import RepoFS
@@ -71,28 +71,61 @@ def test_generate_empty_draft(self) -> None:
7171
assert len(self._commits()) == 1
7272
assert len(self._commits("@{u}")) == 2
7373

74-
def test_generate_draft_accept_merge(self) -> None:
74+
def test_generate_draft_merge(self) -> None:
7575
self._fs.write("p1", "a")
76+
7677
self._drafter.generate_draft(
77-
"hello",
78-
_SimpleBot({"p2": "b"}),
79-
accept=sut.Accept.MERGE,
78+
"hello", _SimpleBot({"p2": "b"}), merge_strategy="ignore-all-space"
8079
)
81-
assert len(self._commits()) == 5 # init, sync, prompt, sync, merge
80+
# No sync(merge) commit since no changes happened between.
81+
assert len(self._commits()) == 4 # init, sync(prompt), prompt, merge
8282
assert self._fs.read("p1") == "a"
8383
assert self._fs.read("p2") == "b"
8484

85-
def test_generate_draft_accept_finalize(self) -> None:
85+
def test_generate_draft_merge_no_conflict(self) -> None:
8686
self._fs.write("p1", "a")
87+
88+
def update(_goal: Goal) -> str:
89+
self._fs.write("p2", "b")
90+
return "A"
91+
8792
self._drafter.generate_draft(
8893
"hello",
89-
_SimpleBot({"p1": "A", "p2": "b"}),
90-
accept=sut.Accept.FINALIZE,
94+
_SimpleBot({"p1": update}),
95+
merge_strategy="ignore-all-space",
9196
)
92-
assert len(self._commits()) == 1 # init
97+
assert len(self._commits()) == 5 # init, sync, prompt, sync, merge
9398
assert self._fs.read("p1") == "A"
9499
assert self._fs.read("p2") == "b"
95100

101+
def test_generate_draft_merge_theirs(self) -> None:
102+
self._fs.write("p1", "a")
103+
104+
def update(_goal: Goal) -> str:
105+
self._fs.write("p1", "b")
106+
return "A"
107+
108+
self._drafter.generate_draft(
109+
"hello", _SimpleBot({"p1": update}), merge_strategy="theirs"
110+
)
111+
# sync(merge) commit here since p1 was updated separately.
112+
assert len(self._commits()) == 5 # init, sync, prompt, sync, merge
113+
assert self._fs.read("p1") == "A"
114+
115+
def test_generate_draft_merge_conflict(self) -> None:
116+
self._fs.write("p1", "a")
117+
118+
def update(_goal: Goal) -> str:
119+
self._fs.write("p1", "b")
120+
return "A"
121+
122+
with pytest.raises(GitError):
123+
self._drafter.generate_draft(
124+
"hello",
125+
_SimpleBot({"p1": update}),
126+
merge_strategy="ignore-all-space",
127+
)
128+
96129
def test_generate_outside_branch(self) -> None:
97130
self._repo.git("checkout", "--detach")
98131
with pytest.raises(RuntimeError):
@@ -104,18 +137,16 @@ def test_generate_empty_prompt(self) -> None:
104137

105138
def test_generate_reuse_branch(self) -> None:
106139
bot = _SimpleBot({"prompt": lambda goal: goal.prompt})
107-
self._drafter.generate_draft("prompt1", bot, sut.Accept.MERGE)
108-
self._drafter.generate_draft("prompt2", bot, sut.Accept.MERGE)
140+
self._drafter.generate_draft("prompt1", bot, "theirs")
141+
self._drafter.generate_draft("prompt2", bot, "theirs")
109142
assert self._fs.read("prompt") == "prompt2"
110143

111144
def test_delete_unknown_file(self) -> None:
112145
self._drafter.generate_draft("hello", _SimpleBot({"p1": None}))
113146

114147
def test_finalize_keeps_changes(self) -> None:
115148
self._fs.write("p1.txt", "a1")
116-
self._drafter.generate_draft(
117-
"hello", _SimpleBot.prompt(), sut.Accept.MERGE
118-
)
149+
self._drafter.generate_draft("hello", _SimpleBot.prompt(), "theirs")
119150
self._fs.write("p1.txt", "a2")
120151
self._drafter.finalize_folio()
121152
assert self._fs.read("p1.txt") == "a2"

0 commit comments

Comments
 (0)